diff --git a/.editorconfig b/.editorconfig index 8cdb92d11c..67f98f94eb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -135,7 +135,7 @@ csharp_preferred_modifier_order = public,private,protected,internal,new,abstract csharp_style_expression_bodied_accessors = true:warning csharp_style_expression_bodied_constructors = false:none csharp_style_expression_bodied_indexers = true:warning -csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_operators = true:warning csharp_style_expression_bodied_properties = true:warning csharp_style_expression_bodied_local_functions = true:silent diff --git a/.github/ISSUE_TEMPLATE/00-mobile-issues.md b/.github/ISSUE_TEMPLATE/00-mobile-issues.md deleted file mode 100644 index f171e80b8b..0000000000 --- a/.github/ISSUE_TEMPLATE/00-mobile-issues.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: Mobile Report -about: ⚠ Due to current development priorities we are not accepting mobile reports at this time (unless you're willing to fix them yourself!) ---- - -⚠ **PLEASE READ** ⚠: Due to prioritising finishing the client for desktop first we are not accepting reports related to mobile platforms for the time being, unless you're willing to fix them. -If you'd like to report a problem or suggest a feature and then work on it, feel free to open an issue and highlight that you'd like to address it yourself in the issue body; mobile pull requests are also welcome. -Otherwise, please check back in the future when the focus of development shifts towards mobile! diff --git a/.github/ISSUE_TEMPLATE/01-bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md index c8c41e5a78..0b80ce44dd 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-issues.md +++ b/.github/ISSUE_TEMPLATE/01-bug-issues.md @@ -8,4 +8,9 @@ about: Issues regarding encountered bugs. **osu!lazer version:** -**Logs:** +**Logs:** + diff --git a/.github/ISSUE_TEMPLATE/02-crash-issues.md b/.github/ISSUE_TEMPLATE/02-crash-issues.md index 8ad27e9e31..ada8de73c0 100644 --- a/.github/ISSUE_TEMPLATE/02-crash-issues.md +++ b/.github/ISSUE_TEMPLATE/02-crash-issues.md @@ -8,6 +8,11 @@ about: Issues regarding crashes or permanent freezes. **osu!lazer version:** -**Logs:** +**Logs:** + **Computer Specifications:** diff --git a/.gitignore b/.gitignore index e6b5db5904..732b171f69 100644 --- a/.gitignore +++ b/.gitignore @@ -331,3 +331,6 @@ fastlane/report.xml # inspectcode inspectcodereport.xml inspectcode + +# BenchmarkDotNet +/BenchmarkDotNet.Artifacts diff --git a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml index 7515e76054..4bb9f4d2a0 100644 --- a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml +++ b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml new file mode 100644 index 0000000000..1815c271b4 --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml index a4154623b6..512ac4393a 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml index 080dc04001..dec1ef717f 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml index 3de6a7e609..d9370d5440 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml index da14c2a29e..def4940bb1 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml index 45d1ce25e9..1ffa73c257 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml index ba80f7c100..e64da796b7 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml index 911c3ed9b7..22105e1de2 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml new file mode 100644 index 0000000000..31f1fda09d --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml index ec3c81f4cd..cc243f6901 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml @@ -1,8 +1,8 @@ - + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index e7b691909e..4e8af405a2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,7 @@ { "version": "0.2.0", - "configurations": [{ + "configurations": [ + { "name": "osu! (Debug)", "type": "coreclr", "request": "launch", @@ -10,11 +11,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -27,11 +23,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -44,13 +35,9 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tests (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tests/bin/Debug/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" - }, { + }, + { "name": "osu! (Tests, Release)", "type": "coreclr", "request": "launch", @@ -60,11 +47,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tests (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tests/bin/Release/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -78,11 +60,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -96,11 +73,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -114,11 +86,6 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tournament tests (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -132,11 +99,19 @@ ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tournament tests (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp3.1:${env:LD_LIBRARY_PATH}" - } - }, + "console": "internalConsole" + }, + { + "name": "Benchmark", + "type": "coreclr", + "request": "launch", + "program": "${workspaceRoot}/osu.Game.Benchmarks/bin/Release/netcoreapp3.1/osu.Game.Benchmarks.dll", + "args": [ + "--filter", + "*" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build benchmarks", "console": "internalConsole" }, { @@ -154,4 +129,4 @@ "externalConsole": false } ] -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c087800d2a..e638dec767 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,8 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", - "tasks": [{ + "tasks": [ + { "label": "Build osu! (Debug)", "type": "shell", "command": "dotnet", @@ -78,7 +79,8 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, { + }, + { "label": "Build tournament tests (Release)", "type": "shell", "command": "dotnet", @@ -94,6 +96,22 @@ "group": "build", "problemMatcher": "$msCompile" }, + { + "label": "Build benchmarks", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Benchmarks", + "/p:Configuration=Release", + "/p:GenerateFullPaths=true", + "/m", + "/verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, { "label": "Restore (netcoreapp3.1)", "type": "shell", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..6c327f01b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contributing Guidelines + +Thank you for showing interest in the development of osu!lazer! 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) + +## I would like to submit an issue! + +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. + +* **Before submitting an issue, try searching existing issues first.** + + 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. + +* **When submitting a bug report, please try to include as much detail as possible.** + + 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: + + * 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/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. + +* **Provide more information when asked to do so.** + + 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 lazer 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! + +* **When submitting a feature proposal, please describe it in the most understandable way you can.** + + 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. + +* **Refrain from posting "+1" comments.** + + 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. + +* **Refrain from asking if an issue has been resolved yet.** + + 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. + +* **Avoid long discussions about non-development topics.** + + 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. + +## I would like to submit a pull request! + +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. + +However, do keep in mind that the core team is committed to bringing osu!lazer up to par with 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). + +Here are some key things to note before jumping in: + +* **Make sure you are comfortable with C\# and your development environment.** + + 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. + + 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. + +* **Make sure you are familiar with git and the pull request workflow.** + + [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. + + 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). + +* **Double-check designs before starting work on new functionality.** + + 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 lazer 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. diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index a92191a439..e34626a59e 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -4,3 +4,5 @@ M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals( M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead. T:System.IComparable;Don't use non-generic IComparable. Use generic version instead. M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. +T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. +T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. diff --git a/Directory.Build.props b/Directory.Build.props index 27a0bd0d48..2cd40c8675 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,9 +16,9 @@ - + - + $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset @@ -40,7 +40,7 @@ https://github.com/ppy/osu Automated release. ppy Pty Ltd - Copyright (c) 2019 ppy Pty Ltd + Copyright (c) 2020 ppy Pty Ltd osu game \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index ab594aee74..bf971d2c22 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,26 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.1) + CFPropertyList (3.0.2) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) atomos (0.1.3) + aws-eventstream (1.1.0) + aws-partitions (1.329.0) + aws-sdk-core (3.99.2) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.34.1) + aws-sdk-core (~> 3, >= 3.99.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.68.1) + aws-sdk-core (~> 3, >= 3.99.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.1.4) + aws-eventstream (~> 1.0, >= 1.0.2) babosa (1.0.3) claide (1.0.3) colored (1.2) @@ -13,36 +29,37 @@ GEM highline (~> 1.7.2) declarative (0.0.10) declarative-option (0.1.0) - digest-crc (0.4.1) + digest-crc (0.5.1) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (2.7.5) emoji_regex (1.0.1) - excon (0.67.0) - faraday (0.15.4) + excon (0.74.0) + faraday (1.0.1) multipart-post (>= 1.2, < 3) faraday-cookie_jar (0.0.6) faraday (>= 0.7.4) http-cookie (~> 1.0.0) - faraday_middleware (0.13.1) - faraday (>= 0.7.4, < 1.0) + faraday_middleware (1.0.0) + faraday (~> 1.0) fastimage (2.1.7) - fastlane (2.133.0) + fastlane (2.149.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) + aws-sdk-s3 (~> 1.0) babosa (>= 1.0.2, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) colored commander-fastlane (>= 4.4.6, < 5.0.0) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 2.0) - excon (>= 0.45.0, < 1.0.0) - faraday (< 0.16.0) + excon (>= 0.71.0, < 1.0.0) + faraday (>= 0.17, < 2.0) faraday-cookie_jar (~> 0.0.6) - faraday_middleware (< 0.16.0) + faraday_middleware (>= 0.13.1, < 2.0) fastimage (>= 2.1.0, < 3.0.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-api-client (>= 0.21.2, < 0.24.0) + google-api-client (>= 0.37.0, < 0.39.0) google-cloud-storage (>= 1.15.0, < 2.0.0) highline (>= 1.7.2, < 2.0.0) json (< 3.0.0) @@ -61,58 +78,61 @@ GEM tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) - xcodeproj (>= 1.8.1, < 2.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.8.1) - souyuz (>= 0.8.1) + fastlane-plugin-souyuz (0.9.1) + souyuz (= 0.9.1) fastlane-plugin-xamarin (0.6.3) gh_inspector (1.1.3) - google-api-client (0.23.9) + google-api-client (0.38.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.5, < 0.7.0) + googleauth (~> 0.9) httpclient (>= 2.8.1, < 3.0) - mime-types (~> 3.0) + mini_mime (~> 1.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - signet (~> 0.9) - google-cloud-core (1.3.1) + signet (~> 0.12) + google-cloud-core (1.5.0) google-cloud-env (~> 1.0) - google-cloud-env (1.2.1) - faraday (~> 0.11) - google-cloud-storage (1.16.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.3.2) + faraday (>= 0.17.3, < 2.0) + google-cloud-errors (1.0.1) + google-cloud-storage (1.26.2) + addressable (~> 2.5) digest-crc (~> 0.4) - google-api-client (~> 0.23) + google-api-client (~> 0.33) google-cloud-core (~> 1.2) - googleauth (>= 0.6.2, < 0.10.0) - googleauth (0.6.7) - faraday (~> 0.12) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.12.0) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.7) + signet (~> 0.14) highline (1.7.10) http-cookie (1.0.3) domain_name (~> 0.5) httpclient (2.8.3) - json (2.2.0) + jmespath (1.4.0) + json (2.3.0) jwt (2.1.0) - memoist (0.16.0) - mime-types (3.3) - mime-types-data (~> 3.2015) - mime-types-data (3.2019.1009) - mini_magick (4.9.5) + memoist (0.16.2) + mini_magick (4.10.1) + mini_mime (1.0.2) mini_portile2 (2.4.0) - multi_json (1.13.1) + multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.0.0) nanaimo (0.2.6) naturally (2.2.0) - nokogiri (1.10.4) + nokogiri (1.10.7) mini_portile2 (~> 2.4.0) - os (1.0.1) + os (1.1.0) plist (3.5.0) public_suffix (2.0.5) representable (3.0.4) @@ -123,33 +143,33 @@ GEM rouge (2.0.7) rubyzip (1.3.0) security (0.1.3) - signet (0.12.0) + signet (0.14.0) addressable (~> 2.3) - faraday (~> 0.9) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simctl (1.6.6) + simctl (1.6.8) CFPropertyList naturally slack-notifier (2.3.2) - souyuz (0.8.1) - fastlane (>= 2.29.0) + souyuz (0.9.1) + fastlane (>= 1.103.0) highline (~> 1.7) nokogiri (~> 1.7) terminal-notifier (2.0.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - tty-cursor (0.7.0) - tty-screen (0.7.0) - tty-spinner (0.9.1) + tty-cursor (0.7.1) + tty-screen (0.8.0) + tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.6) - unicode-display_width (1.6.0) + unf_ext (0.0.7.7) + unicode-display_width (1.7.0) word_wrap (1.0.0) - xcodeproj (1.12.0) + xcodeproj (1.16.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) diff --git a/LICENCE b/LICENCE index 21c6a7090f..2435c23545 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2019 ppy Pty Ltd . +Copyright (c) 2020 ppy Pty Ltd . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 67027bb9f3..dc3ee63844 100644 --- a/README.md +++ b/README.md @@ -13,35 +13,38 @@ Rhythm is just a *click* away. The future of [osu!](https://osu.ppy.sh) and the ## Status -This project is still heavily under development, but is in a state where users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve over the coming months and hopefully bring some new unique features to the table. +This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. -We are accepting bug reports (please report with as much detail as possible). Feature requests are welcome as long as you read and understand the contribution guidelines listed below. +**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passses come at the end of development, preceeded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. -Detailed changelogs are published on the [official osu! site](https://osu.ppy.sh/home/changelog). +We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project: -## Requirements - -- A desktop platform with the [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download) or higher installed. -- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. -- When running on Windows 7 or 8.1, **[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/dependencies?tabs=netcore31&pivots=os-windows)** may be required to correctly run .NET Core applications if your operating system is not up-to-date with the latest service packs. -- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). -- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). +- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). +- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). +- Read peppy's [latest blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where lazer is currently and the roadmap going forward. ## Running osu! -### Releases - -If you are not interested in developing the game, you can still consume our [binary releases](https://github.com/ppy/osu/releases). +If you are looking to install or test osu! without setting up a development environment, you can consume our [binary releases](https://github.com/ppy/osu/releases). Handy links below will download the latest version for your operating system of choice: **Latest build:** -| [Windows (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [iOS(iOS 10+)](https://osu.ppy.sh/home/testflight) | [Android (5+)](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) -| ------------- | ------------- | ------------- | ------------- | +| [Windows (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS(iOS 10+)](https://osu.ppy.sh/home/testflight) | [Android (5+)](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| ------------- | ------------- | ------------- | ------------- | ------------- | -- **Linux** users are recommended to self-compile until we have official deployment in place. +- When running on Windows 7 or 8.1, **[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/dependencies?tabs=netcore31&pivots=os-windows)** may be required to correctly run .NET Core applications if your operating system is not up-to-date with the latest service packs. If your platform is not listed above, there is still a chance you can manually build it by following the instructions below. +## Developing or debugging + +Please make sure you have the following prerequisites: + +- A desktop platform with the [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download) or higher installed. +- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). +- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). +- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. + ### Downloading the source code Clone the repository: @@ -90,11 +93,7 @@ JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it ## Contributing -We welcome all contributions, but 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. - -If you're unsure of what you can help with, check out the [list of open issues](https://github.com/ppy/osu/issues) (especially those with the ["good first issue"](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22good+first+issue%22) label). - -Before starting, please make sure you are familiar with the [development and testing](https://github.com/ppy/osu-framework/wiki/Development-and-Testing) procedure we have set up. New component development, and where possible, bug fixing and debugging existing components **should always be done under VisualTests**. +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. diff --git a/build/InspectCode.cake b/build/InspectCode.cake index 06c56dce87..c8f4f37c94 100644 --- a/build/InspectCode.cake +++ b/build/InspectCode.cake @@ -1,5 +1,5 @@ -#addin "nuget:?package=CodeFileSanity&version=0.0.33" -#addin "nuget:?package=JetBrains.ReSharper.CommandLineTools&version=2019.3.0" +#addin "nuget:?package=CodeFileSanity&version=0.0.36" +#addin "nuget:?package=JetBrains.ReSharper.CommandLineTools&version=2020.1.3" #tool "nuget:?package=NVika.MSBuild&version=1.0.1" var nVikaToolPath = GetFiles("./tools/NVika.MSBuild.*/tools/NVika.exe").First(); diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 28a83fbbae..4fd0e5e8c7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -97,8 +97,10 @@ platform :ios do changelog.gsub!('$BUILD_ID', options[:build]) pilot( - wait_processing_interval: 1800, + wait_processing_interval: 900, changelog: changelog, + groups: ['osu! supporters', 'public'], + distribute_external: true, ipa: './osu.iOS/bin/iPhone/Release/osu.iOS.ipa' ) end @@ -111,7 +113,6 @@ platform :ios do souyuz( platform: "ios", - build_target: "osu_iOS", plist_path: "../osu.iOS/Info.plist" ) end diff --git a/global.json b/global.json index 6858d4044d..233a040d18 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ "version": "3.1.100" }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "2.0.24" + "Microsoft.Build.Traversal": "2.0.52" } } \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 0b41d5cda4..71d4e5aacf 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -25,7 +25,6 @@ portable False DEBUG;TRACE - false false true false @@ -34,7 +33,6 @@ false None True - true false False true @@ -53,7 +51,7 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 2e5fa59d20..9839d16030 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -9,7 +9,7 @@ using osu.Framework.Android; namespace osu.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullSensor, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = true)] + [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullSensor, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false)] public class OsuGameActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuGameAndroid(); diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index a91c010809..7542a2b997 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -3,6 +3,7 @@ using System; using Android.App; +using Android.OS; using osu.Game; using osu.Game.Updater; @@ -18,8 +19,32 @@ namespace osu.Android try { - string versionName = packageInfo.VersionCode.ToString(); - // undo play store version garbling + // We store the osu! build number in the "VersionCode" field to better support google play releases. + // If we were to use the main build number, it would require a new submission each time (similar to TestFlight). + // In order to do this, we should split it up and pad the numbers to still ensure sequential increase over time. + // + // We also need to be aware that older SDK versions store this as a 32bit int. + // + // Basic conversion format (as done in Fastfile): 2020.606.0 -> 202006060 + + // https://stackoverflow.com/questions/52977079/android-sdk-28-versioncode-in-packageinfo-has-been-deprecated + string versionName = string.Empty; + + if (Build.VERSION.SdkInt >= BuildVersionCodes.P) + { + versionName = packageInfo.LongVersionCode.ToString(); + // ensure we only read the trailing portion of long (the part we are interested in). + versionName = versionName.Substring(versionName.Length - 9); + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete + // this is required else older SDKs will report missing method exception. + versionName = packageInfo.VersionCode.ToString(); +#pragma warning restore CS0618 // Type or member is obsolete + } + + // undo play store version garbling (as mentioned above). return new Version(int.Parse(versionName.Substring(0, 4)), int.Parse(versionName.Substring(4, 4)), int.Parse(versionName.Substring(8, 1))); } catch @@ -30,11 +55,6 @@ namespace osu.Android } } - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(new SimpleUpdateManager()); - } + protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); } -} \ No newline at end of file +} diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index ac3905a372..0598a50530 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -13,6 +13,7 @@ osu.Android Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + false cjk;mideast;other;rare;west @@ -52,4 +53,4 @@ - \ No newline at end of file + diff --git a/osu.Desktop.slnf b/osu.Desktop.slnf index e6b6446f72..d2c14d321a 100644 --- a/osu.Desktop.slnf +++ b/osu.Desktop.slnf @@ -3,6 +3,7 @@ "path": "osu.sln", "projects": [ "osu.Desktop\\osu.Desktop.csproj", + "osu.Game.Benchmarks\\osu.Game.Benchmarks.csproj", "osu.Game.Rulesets.Catch.Tests\\osu.Game.Rulesets.Catch.Tests.csproj", "osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj", "osu.Game.Rulesets.Mania.Tests\\osu.Game.Rulesets.Mania.Tests.csproj", diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index 80bb82c769..08cc0e7f5f 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -75,6 +75,9 @@ namespace osu.Desktop private void updateStatus() { + if (!client.IsInitialized) + return; + if (status.Value is UserStatusOffline) { client.ClearPresence(); diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index f70cc24159..cd31df316a 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -6,15 +6,13 @@ using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Win32; using osu.Desktop.Overlays; using osu.Framework.Platform; using osu.Game; -using osuTK.Input; -using Microsoft.Win32; using osu.Desktop.Updater; using osu.Framework; using osu.Framework.Logging; -using osu.Framework.Platform.Windows; using osu.Framework.Screens; using osu.Game.Screens.Menu; using osu.Game.Updater; @@ -37,7 +35,11 @@ namespace osu.Desktop try { if (Host is DesktopGameHost desktopHost) - return new StableStorage(desktopHost); + { + string stablePath = getStableInstallPath(); + if (!string.IsNullOrEmpty(stablePath)) + return new DesktopStorage(stablePath, desktopHost); + } } catch (Exception) { @@ -47,20 +49,54 @@ namespace osu.Desktop return null; } + private string getStableInstallPath() + { + static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")); + + string stableInstallPath; + + try + { + using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString()?.Split('"')[1].Replace("osu!.exe", ""); + + if (checkExists(stableInstallPath)) + return stableInstallPath; + } + catch + { + } + + stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); + if (checkExists(stableInstallPath)) + return stableInstallPath; + + stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); + if (checkExists(stableInstallPath)) + return stableInstallPath; + + return null; + } + + protected override UpdateManager CreateUpdateManager() + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + return new SquirrelUpdateManager(); + + default: + return new SimpleUpdateManager(); + } + } + protected override void LoadComplete() { base.LoadComplete(); if (!noVersionOverlay) - { LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, Add); - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - Add(new SquirrelUpdateManager()); - else - Add(new SimpleUpdateManager()); - } - LoadComponentAsync(new DiscordRichPresence(), Add); } @@ -85,66 +121,32 @@ namespace osu.Desktop { base.SetHost(host); - if (host.Window is DesktopGameWindow desktopWindow) + switch (host.Window) { - desktopWindow.CursorState |= CursorState.Hidden; + // Legacy osuTK DesktopGameWindow + case DesktopGameWindow desktopGameWindow: + desktopGameWindow.CursorState |= CursorState.Hidden; + desktopGameWindow.SetIconFromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico")); + desktopGameWindow.Title = Name; + desktopGameWindow.FileDrop += (_, e) => fileDrop(e.FileNames); + break; - desktopWindow.SetIconFromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico")); - desktopWindow.Title = Name; - - desktopWindow.FileDrop += fileDrop; + // SDL2 DesktopWindow + case DesktopWindow desktopWindow: + desktopWindow.CursorState.Value |= CursorState.Hidden; + desktopWindow.Title = Name; + desktopWindow.DragDrop += f => fileDrop(new[] { f }); + break; } } - private void fileDrop(object sender, FileDropEventArgs e) + private void fileDrop(string[] filePaths) { - var filePaths = e.FileNames; - var firstExtension = Path.GetExtension(filePaths.First()); if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning); } - - /// - /// A method of accessing an osu-stable install in a controlled fashion. - /// - private class StableStorage : WindowsStorage - { - protected override string LocateBasePath() - { - static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")); - - string stableInstallPath; - - try - { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - return null; - } - - public StableStorage(DesktopGameHost host) - : base(string.Empty, host) - { - } - } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 141b2cdbbc..285a813d97 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,8 +22,9 @@ namespace osu.Desktop { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; + bool useSdl = args.Contains("--sdl"); - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useSdl: useSdl)) { host.ExceptionThrown += handleException; @@ -32,13 +33,11 @@ namespace osu.Desktop if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args { var importer = new ArchiveImportIPCChannel(host); - // Restore the cwd so relative paths given at the command line work correctly - Directory.SetCurrentDirectory(cwd); foreach (var file in args) { Console.WriteLine(@"Importing {0}", file); - if (!importer.ImportAsync(Path.GetFullPath(file)).Wait(3000)) + if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) throw new TimeoutException(@"IPC took too long to send"); } diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 60b47a8b3a..05c8e835ac 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -30,29 +30,27 @@ namespace osu.Desktop.Updater private static readonly Logger logger = Logger.GetLogger("updater"); [BackgroundDependencyLoader] - private void load(NotificationOverlay notification, OsuGameBase game) + private void load(NotificationOverlay notification) { notificationOverlay = notification; - if (game.IsDeployedBuild) - { - Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); - Schedule(() => Task.Run(() => checkForUpdateAsync())); - } + Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); } - private async void checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) + protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); + + private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) { - //should we schedule a retry on completion of this check? + // should we schedule a retry on completion of this check? bool scheduleRecheck = true; try { - if (updateManager == null) updateManager = await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true); + updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true); var info = await updateManager.CheckForUpdate(!useDeltaPatching); if (info.ReleasesToApply.Count == 0) - //no updates available. bail and retry later. + // no updates available. bail and retry later. return; if (notification == null) @@ -81,9 +79,9 @@ namespace osu.Desktop.Updater { logger.Add(@"delta patching failed; will attempt full download!"); - //could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) - //try again without deltas. - checkForUpdateAsync(false, notification); + // could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) + // try again without deltas. + await checkForUpdateAsync(false, notification); scheduleRecheck = false; } else @@ -101,8 +99,8 @@ namespace osu.Desktop.Updater { if (scheduleRecheck) { - //check again in 30 minutes. - Scheduler.AddDelayed(() => checkForUpdateAsync(), 60000 * 30); + // check again in 30 minutes. + Scheduler.AddDelayed(async () => await checkForUpdateAsync(), 60000 * 30); } } } diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index da47ad8223..7a99c70999 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -29,7 +29,11 @@ - + + + + + diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index a26b35fcd5..a919d54f38 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -12,7 +12,7 @@ click the circles. to the beat. click the circles. testing - Copyright (c) 2019 ppy Pty Ltd + Copyright (c) 2020 ppy Pty Ltd en-AU diff --git a/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs new file mode 100644 index 0000000000..1d207d04c7 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.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.IO; +using BenchmarkDotNet.Attributes; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Tests.Resources; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkBeatmapParsing : BenchmarkTest + { + private readonly MemoryStream beatmapStream = new MemoryStream(); + + public override void SetUp() + { + using (var resources = new DllResourceStore(typeof(TestResources).Assembly)) + using (var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz")) + using (var reader = new ZipArchiveReader(archive)) + reader.GetStream("Soleily - Renatus (Gamu) [Insane].osu").CopyTo(beatmapStream); + } + + [Benchmark] + public Beatmap BenchmarkBundledBeatmap() + { + beatmapStream.Seek(0, SeekOrigin.Begin); + var reader = new LineBufferedReader(beatmapStream); // no disposal + + var decoder = Decoder.GetDecoder(reader); + return decoder.Decode(reader); + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkTest.cs b/osu.Game.Benchmarks/BenchmarkTest.cs new file mode 100644 index 0000000000..34f5edd084 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkTest.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 BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; +using NUnit.Framework; + +namespace osu.Game.Benchmarks +{ + [TestFixture] + [MemoryDiagnoser] + public abstract class BenchmarkTest + { + [GlobalSetup] + [OneTimeSetUp] + public virtual void SetUp() + { + } + + [Test] + public void RunBenchmark() => BenchmarkRunner.Run(GetType()); + } +} diff --git a/osu.Game.Benchmarks/Program.cs b/osu.Game.Benchmarks/Program.cs new file mode 100644 index 0000000000..c55075fea6 --- /dev/null +++ b/osu.Game.Benchmarks/Program.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 BenchmarkDotNet.Running; + +namespace osu.Game.Benchmarks +{ + public static class Program + { + public static void Main(string[] args) + { + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args); + } + } +} diff --git a/osu.Game.Benchmarks/Properties/launchSettings.json b/osu.Game.Benchmarks/Properties/launchSettings.json new file mode 100644 index 0000000000..c1868088f9 --- /dev/null +++ b/osu.Game.Benchmarks/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "All Benchmarks": { + "commandName": "Project", + "commandLineArgs": "--filter *" + } + } +} \ No newline at end of file diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj new file mode 100644 index 0000000000..ff26f4afaa --- /dev/null +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + Exe + false + + + + + + + + + + + + + + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index 493ac7ae39..df54df7b01 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -5,10 +5,9 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Tests.Beatmaps; @@ -83,7 +82,7 @@ namespace osu.Game.Rulesets.Catch.Tests public float Position { - get => HitObject?.X * CatchPlayfield.BASE_WIDTH ?? position; + get => HitObject?.X ?? position; set => position = value; } diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 51fe0b035d..ee416e5a38 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; - [TestCase(4.2058561036909863d, "diffcalc-test")] + [TestCase(4.050601681491468d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs new file mode 100644 index 0000000000..b570f090ca --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class CatchSkinColourDecodingTest + { + [Test] + public void TestCatchSkinColourDecoding() + { + var store = new NamespacedResourceStore(new DllResourceStore(GetType().Assembly), "Resources/special-skin"); + var rawSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, store); + var skinSource = new SkinProvidingContainer(rawSkin); + var skin = new CatchLegacySkinTransformer(skinSource); + + Assert.AreEqual(new Color4(232, 185, 35, 255), skin.GetConfig(CatchSkinColour.HyperDash)?.Value); + Assert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value); + Assert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value); + } + + private class TestLegacySkin : LegacySkin + { + public TestLegacySkin(SkinInfo skin, IResourceStore storage) + // Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null). + : base(skin, storage, null, "skin.ini") + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs new file mode 100644 index 0000000000..378772fea3 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public abstract class CatchSkinnableTestScene : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs new file mode 100644 index 0000000000..3e06e78dba --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.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 NUnit.Framework; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public class TestSceneCatchModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + public TestSceneCatchModPerfect() + : base(new CatchModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestBananaShower(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new BananaShower { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestFruit(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Fruit { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestJuiceStream(bool shouldMiss) + { + var stream = new JuiceStream + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(100, 0), + }) + }; + + CreateHitObjectTest(new HitObjectTestData(stream), shouldMiss); + } + + // We only care about testing misses, hits are tested via JuiceStream + [TestCase(true)] + public void TestDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Droplet { StartTime = 1000 }), shouldMiss); + + // We only care about testing misses, hits are tested via JuiceStream + [TestCase(true)] + public void TestTinyDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new TinyDroplet { StartTime = 1000 }), shouldMiss); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png new file mode 100644 index 0000000000..76949ccfcc Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png new file mode 100644 index 0000000000..ec2fdbdbdb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png new file mode 100644 index 0000000000..8d9608cfc9 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png new file mode 100644 index 0000000000..be1bda0383 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png new file mode 100644 index 0000000000..3a6612378e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png new file mode 100644 index 0000000000..afb8698b2d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png new file mode 100644 index 0000000000..12c74f46e2 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png new file mode 100644 index 0000000000..bb37ba1920 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png new file mode 100644 index 0000000000..10699b1f31 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png new file mode 100644 index 0000000000..e86aa6e7e3 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png new file mode 100644 index 0000000000..42cc80399f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png new file mode 100644 index 0000000000..5c479da954 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png new file mode 100644 index 0000000000..9fe400bdd1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png new file mode 100644 index 0000000000..1da1fdde85 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png new file mode 100644 index 0000000000..f732092379 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png new file mode 100644 index 0000000000..2d312ceefd Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png new file mode 100644 index 0000000000..7884dc072d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png new file mode 100644 index 0000000000..3e4ec2e047 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png new file mode 100644 index 0000000000..f02ad11a17 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png new file mode 100755 index 0000000000..fe567d158d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png new file mode 100755 index 0000000000..17f3be9c26 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png new file mode 100755 index 0000000000..2c94ea78bf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png new file mode 100755 index 0000000000..2c94ea78bf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png new file mode 100755 index 0000000000..1eea5c2083 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png new file mode 100644 index 0000000000..786e5cc25a Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png new file mode 100644 index 0000000000..e93530fb16 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png new file mode 100644 index 0000000000..6f51257742 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png new file mode 100644 index 0000000000..953a04d4e4 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png new file mode 100644 index 0000000000..66a3cf9e0b Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png new file mode 100644 index 0000000000..ec4487f8fb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png new file mode 100755 index 0000000000..31be03b014 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png new file mode 100755 index 0000000000..56bf4a92fb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png new file mode 100755 index 0000000000..f259684055 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png new file mode 100755 index 0000000000..17f3be9c26 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini new file mode 100644 index 0000000000..96d50f1451 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini @@ -0,0 +1,4 @@ +[CatchTheBeat] +HyperDash: 232,185,35 +HyperDashFruit: 0,255,255 +HyperDashAfterImage: 232,74,35 diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 74a9c05bf9..d6bba3d55e 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -7,19 +7,13 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Screens.Play; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneAutoJuiceStream : PlayerTestScene + public class TestSceneAutoJuiceStream : TestSceneCatchPlayer { - public TestSceneAutoJuiceStream() - : base(new CatchRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -33,15 +27,15 @@ namespace osu.Game.Rulesets.Catch.Tests for (int i = 0; i < 100; i++) { - float width = (i % 10 + 1) / 20f; + float width = (i % 10 + 1) / 20f * CatchPlayfield.WIDTH; beatmap.HitObjects.Add(new JuiceStream { - X = 0.5f - width / 2, + X = CatchPlayfield.CENTER_X - width / 2, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, - new Vector2(width * CatchPlayfield.BASE_WIDTH, 0) + new Vector2(width, 0) }), StartTime = i * 2000, NewCombo = i % 8 == 0 @@ -51,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests return beatmap; } - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { SelectedMods.Value = SelectedMods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); return base.CreatePlayer(ruleset); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs index 0ad72412fc..e89a95ae37 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs @@ -1,32 +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 NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneBananaShower : PlayerTestScene + public class TestSceneBananaShower : TestSceneCatchPlayer { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(BananaShower), - typeof(DrawableBananaShower), - - typeof(CatchRuleset), - typeof(DrawableCatchRuleset), - }; - - public TestSceneBananaShower() - : base(new CatchRuleset()) + [Test] + public void TestBananaShower() { + AddUntilStep("player is done", () => !Player.ValidForResume); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) @@ -40,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.Tests } }; - beatmap.HitObjects.Add(new BananaShower { StartTime = 200, Duration = 5000, NewCombo = true }); + beatmap.HitObjects.Add(new BananaShower { StartTime = 200, Duration = 3000, NewCombo = true }); return beatmap; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs new file mode 100644 index 0000000000..f15da29993 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneCatchModHidden : ModTestScene + { + [BackgroundDependencyLoader] + private void load() + { + LocalConfig.Set(OsuSetting.IncreaseFirstObjectVisibility, false); + } + + [Test] + public void TestJuiceStream() + { + CreateModTest(new ModTestData + { + Beatmap = new Beatmap + { + HitObjects = new List + { + new JuiceStream + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(0, -192) }), + X = CatchPlayfield.WIDTH / 2 + } + } + }, + Mod = new CatchModHidden(), + PassCondition = () => Player.Results.Count > 0 + && Player.ChildrenOfType().Single().Alpha > 0 + && Player.ChildrenOfType().Last().Alpha > 0 + }); + } + + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs index 9836a7811a..31d0831fae 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs @@ -9,9 +9,6 @@ namespace osu.Game.Rulesets.Catch.Tests [TestFixture] public class TestSceneCatchPlayer : PlayerTestScene { - public TestSceneCatchPlayer() - : base(new CatchRuleset()) - { - } + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs index 9ce46ad6ba..1ff31697b8 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs @@ -4,18 +4,13 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatchStacker : PlayerTestScene + public class TestSceneCatchStacker : TestSceneCatchPlayer { - public TestSceneCatchStacker() - : base(new CatchRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -28,7 +23,14 @@ namespace osu.Game.Rulesets.Catch.Tests }; for (int i = 0; i < 512; i++) - beatmap.HitObjects.Add(new Fruit { X = 0.5f + i / 2048f * (i % 10 - 5), StartTime = i * 100, NewCombo = i % 8 == 0 }); + { + beatmap.HitObjects.Add(new Fruit + { + X = (0.5f + i / 2048f * (i % 10 - 5)) * CatchPlayfield.WIDTH, + StartTime = i * 100, + NewCombo = i % 8 == 0 + }); + } return beatmap; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 9b529a2e4c..6eeda2c731 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -3,104 +3,24 @@ using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; -using System; -using System.Collections.Generic; -using osu.Game.Skinning; -using osu.Framework.Graphics.Shapes; -using osuTK.Graphics; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; -using osu.Game.Audio; -using osu.Game.Graphics.Sprites; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcher : OsuTestScene + public class TestSceneCatcher : CatchSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherSprite), - }; - - private readonly Container container; - - public TestSceneCatcher() - { - Child = container = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - [BackgroundDependencyLoader] private void load() { - AddStep("show default catcher implementation", () => { container.Child = new CatcherSprite(); }); - - AddStep("show custom catcher implementation", () => + SetContents(() => new Catcher(new Container()) { - container.Child = new CatchCustomSkinSourceContainer - { - Child = new CatcherSprite() - }; + RelativePositionAxes = Axes.None, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, }); } - - private class CatcherCustomSkin : Container - { - public CatcherCustomSkin() - { - RelativeSizeAxes = Axes.Both; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Blue - }, - new OsuSpriteText - { - Text = "custom" - } - }; - } - } - - [Cached(typeof(ISkinSource))] - private class CatchCustomSkinSourceContainer : Container, ISkinSource - { - public event Action SourceChanged - { - add { } - remove { } - } - - public Drawable GetDrawableComponent(ISkinComponent component) - { - switch (component.LookupName) - { - case "Gameplay/catch/fruit-catcher-idle": - return new CatcherCustomSkin(); - } - - return null; - } - - public SampleChannel GetSample(ISampleInfo sampleInfo) => - throw new NotImplementedException(); - - public Texture GetTexture(string componentName) => - throw new NotImplementedException(); - - public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); - } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 3ae6886c31..b4f123598b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -1,45 +1,111 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Judgements; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcherArea : OsuTestScene + public class TestSceneCatcherArea : CatchSkinnableTestScene { private RulesetInfo catchRuleset; - private TestCatcherArea catcherArea; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherArea), - }; + [Resolved] + private OsuConfigManager config { get; set; } + + private Catcher catcher => this.ChildrenOfType().First().MovableCatcher; public TestSceneCatcherArea() { AddSliderStep("CircleSize", 0, 8, 5, createCatcher); - AddToggleStep("Hyperdash", t => catcherArea.ToggleHyperDash(t)); + AddToggleStep("Hyperdash", t => + CreatedDrawables.OfType().Select(i => i.Child) + .OfType().ForEach(c => c.ToggleHyperDash(t))); + + AddRepeatStep("catch fruit", () => catchFruit(new TestFruit(false) + { + X = catcher.X + }), 20); + AddRepeatStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) + { + X = catcher.X, + LastInCombo = true, + }), 20); + AddRepeatStep("catch kiai fruit", () => catchFruit(new TestFruit(true) + { + X = catcher.X + }), 20); + AddRepeatStep("miss fruit", () => catchFruit(new Fruit + { + X = catcher.X + 100, + LastInCombo = true, + }, true), 20); + } + + [TestCase(true)] + [TestCase(false)] + public void TestHitLighting(bool enable) + { + AddStep("create catcher", () => createCatcher(5)); + + AddStep("toggle hit lighting", () => config.Set(OsuSetting.HitLighting, enable)); + AddStep("catch fruit", () => catchFruit(new TestFruit(false) + { + X = catcher.X + })); + AddStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) + { + X = catcher.X, + LastInCombo = true + })); + AddAssert("check hit explosion", () => catcher.ChildrenOfType().Any() == enable); + } + + private void catchFruit(Fruit fruit, bool miss = false) + { + this.ChildrenOfType().ForEach(area => + { + DrawableFruit drawable = new DrawableFruit(fruit); + area.Add(drawable); + + Schedule(() => + { + area.AttemptCatch(fruit); + area.OnResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); + + drawable.Expire(); + }); + }); } private void createCatcher(float size) { - Child = new CatchInputManager(catchRuleset) + SetContents(() => new CatchInputManager(catchRuleset) { RelativeSizeAxes = Axes.Both, - Child = catcherArea = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) + Child = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + CreateDrawableRepresentation = ((DrawableRuleset)catchRuleset.CreateInstance().CreateDrawableRulesetWith(new CatchBeatmap())).CreateDrawableRepresentation }, - }; + }); } [BackgroundDependencyLoader] @@ -48,6 +114,17 @@ namespace osu.Game.Rulesets.Catch.Tests catchRuleset = rulesets.GetRuleset(2); } + public class TestFruit : Fruit + { + public TestFruit(bool kiai) + { + var kiaiCpi = new ControlPointInfo(); + kiaiCpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); + + ApplyDefaultsToSelf(kiaiCpi, new BeatmapDifficulty()); + } + } + private class TestCatcherArea : CatcherArea { public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) @@ -55,6 +132,8 @@ namespace osu.Game.Rulesets.Catch.Tests { } + public new Catcher MovableCatcher => base.MovableCatcher; + public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index 1eb913e900..d35f828e28 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.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 System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -22,20 +21,11 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneDrawableHitObjects : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherArea.Catcher), - typeof(DrawableCatchRuleset), - typeof(DrawableFruit), - typeof(DrawableJuiceStream), - typeof(DrawableBanana) - }; - private DrawableCatchRuleset drawableRuleset; private double playfieldTime => drawableRuleset.Playfield.Time.Current; - [BackgroundDependencyLoader] - private void load() + [SetUp] + public void Setup() => Schedule(() => { var controlPointInfo = new ControlPointInfo(); controlPointInfo.Add(0, new TimingControlPoint()); @@ -57,7 +47,7 @@ namespace osu.Game.Rulesets.Catch.Tests ControlPointInfo = controlPointInfo }); - Add(new Container + Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -66,16 +56,49 @@ namespace osu.Game.Rulesets.Catch.Tests { drawableRuleset = new DrawableCatchRuleset(new CatchRuleset(), beatmap.GetPlayableBeatmap(new CatchRuleset().RulesetInfo)) } - }); + }; + }); + + [Test] + public void TestFruits() + { + AddStep("hit fruits", () => spawnFruits(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); AddStep("miss fruits", () => spawnFruits()); - AddStep("hit fruits", () => spawnFruits(true)); - AddStep("miss juicestream", () => spawnJuiceStream()); - AddStep("hit juicestream", () => spawnJuiceStream(true)); - AddStep("miss bananas", () => spawnBananas()); - AddStep("hit bananas", () => spawnBananas(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is failed", () => catcherState == CatcherAnimationState.Fail); } + [Test] + public void TestJuicestream() + { + AddStep("hit juicestream", () => spawnJuiceStream(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + + AddStep("miss juicestream", () => spawnJuiceStream()); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is failed", () => catcherState == CatcherAnimationState.Fail); + } + + [Test] + public void TestBananas() + { + AddStep("hit bananas", () => spawnBananas(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + + AddStep("miss bananas", () => spawnBananas()); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + } + + private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive); + + private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState; + private void spawnFruits(bool hit = false) { for (int i = 1; i <= 4; i++) @@ -135,8 +158,8 @@ namespace osu.Game.Rulesets.Catch.Tests private float getXCoords(bool hit) { - const float x_offset = 0.2f; - float xCoords = drawableRuleset.Playfield.Width / 2; + const float x_offset = 0.2f * CatchPlayfield.WIDTH; + float xCoords = CatchPlayfield.CENTER_X; if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield) catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs index 8c3dfef39c..62fe5dca2c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Catch.Mods; @@ -11,8 +8,6 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneDrawableHitObjectsHidden : TestSceneDrawableHitObjects { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(CatchModHidden) }).ToList(); - [SetUp] public void SetUp() => Schedule(() => { diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 44517382f7..c07e4fdad3 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -2,81 +2,98 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneFruitObjects : OsuTestScene + public class TestSceneFruitObjects : CatchSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] + protected override void LoadComplete() { - typeof(CatchHitObject), - typeof(Fruit), - typeof(Droplet), - typeof(DrawableCatchHitObject), - typeof(DrawableFruit), - typeof(DrawableDroplet), - typeof(BananaShower), - typeof(Pulp), - }; + base.LoadComplete(); - public TestSceneFruitObjects() - { - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - createDrawable(0), - createDrawable(1), - createDrawable(2), - }, - new Drawable[] - { - createDrawable(3), - createDrawable(4), - createDrawable(5), - }, - } - }); + foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) + AddStep($"show {rep}", () => SetContents(() => createDrawable(rep))); + + AddStep("show droplet", () => SetContents(createDrawableDroplet)); + + AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); + + foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) + AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawable(rep, true))); } - private DrawableFruit createDrawable(int index) + private Drawable createDrawableTinyDroplet() { - Fruit fruit = index == 5 - ? new Banana - { - StartTime = 1000000000000, - IndexInBeatmap = index, - Scale = 1.5f, - } - : new Fruit - { - StartTime = 1000000000000, - IndexInBeatmap = index, - Scale = 1.5f, - }; + var droplet = new TinyDroplet + { + StartTime = Clock.CurrentTime, + Scale = 1.5f, + }; - return new DrawableFruit(fruit) + return new DrawableTinyDroplet(droplet) { Anchor = Anchor.Centre, - RelativePositionAxes = Axes.Both, + RelativePositionAxes = Axes.None, Position = Vector2.Zero, Alpha = 1, LifetimeStart = double.NegativeInfinity, LifetimeEnd = double.PositiveInfinity, }; } + + private Drawable createDrawableDroplet() + { + var droplet = new Droplet + { + StartTime = Clock.CurrentTime, + Scale = 1.5f, + }; + + return new DrawableDroplet(droplet) + { + Anchor = Anchor.Centre, + RelativePositionAxes = Axes.None, + Position = Vector2.Zero, + Alpha = 1, + LifetimeStart = double.NegativeInfinity, + LifetimeEnd = double.PositiveInfinity, + }; + } + + private Drawable createDrawable(FruitVisualRepresentation rep, bool hyperdash = false) + { + Fruit fruit = new TestCatchFruit(rep) + { + Scale = 1.5f, + HyperDashTarget = hyperdash ? new Banana() : null + }; + + return new DrawableFruit(fruit) + { + Anchor = Anchor.Centre, + RelativePositionAxes = Axes.None, + Position = Vector2.Zero, + Alpha = 1, + LifetimeStart = double.NegativeInfinity, + LifetimeEnd = double.PositiveInfinity, + }; + } + + public class TestCatchFruit : Fruit + { + public TestCatchFruit(FruitVisualRepresentation rep) + { + VisualRepresentation = rep; + StartTime = 1000000000000; + } + + public override FruitVisualRepresentation VisualRepresentation { get; } + } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index da36673930..ad24adf352 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -1,29 +1,40 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneHyperDash : PlayerTestScene + public class TestSceneHyperDash : TestSceneCatchPlayer { - public TestSceneHyperDash() - : base(new CatchRuleset()) - { - } - protected override bool Autoplay => true; [Test] public void TestHyperDash() { AddAssert("First note is hyperdash", () => Beatmap.Value.Beatmap.HitObjects[0] is Fruit f && f.HyperDash); + AddUntilStep("wait for right movement", () => getCatcher().Scale.X > 0); // don't check hyperdashing as it happens too fast. + + AddUntilStep("wait for left movement", () => getCatcher().Scale.X < 0); + + for (int i = 0; i < 3; i++) + { + AddUntilStep("wait for right hyperdash", () => getCatcher().Scale.X > 0 && getCatcher().HyperDashing); + AddUntilStep("wait for left hyperdash", () => getCatcher().Scale.X < 0 && getCatcher().HyperDashing); + } } + private Catcher getCatcher() => Player.ChildrenOfType().First().MovableCatcher; + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -35,17 +46,52 @@ namespace osu.Game.Rulesets.Catch.Tests } }; - // Should produce a hyper-dash - beatmap.HitObjects.Add(new Fruit { StartTime = 816, X = 308 / 512f, NewCombo = true }); - beatmap.HitObjects.Add(new Fruit { StartTime = 1008, X = 56 / 512f, }); + // Should produce a hyper-dash (edge case test) + beatmap.HitObjects.Add(new Fruit { StartTime = 1816, X = 56, NewCombo = true }); + beatmap.HitObjects.Add(new Fruit { StartTime = 2008, X = 308, NewCombo = true }); - for (int i = 0; i < 512; i++) - { - if (i % 5 < 3) - beatmap.HitObjects.Add(new Fruit { X = i % 10 < 5 ? 0.02f : 0.98f, StartTime = 2000 + i * 100, NewCombo = i % 8 == 0 }); - } + double startTime = 3000; + + const float left_x = 0.02f * CatchPlayfield.WIDTH; + const float right_x = 0.98f * CatchPlayfield.WIDTH; + + createObjects(() => new Fruit { X = left_x }); + createObjects(() => new TestJuiceStream(right_x), 1); + createObjects(() => new TestJuiceStream(left_x), 1); + createObjects(() => new Fruit { X = right_x }); + createObjects(() => new Fruit { X = left_x }); + createObjects(() => new Fruit { X = right_x }); + createObjects(() => new TestJuiceStream(left_x), 1); return beatmap; + + void createObjects(Func createObject, int count = 3) + { + const float spacing = 140; + + for (int i = 0; i < count; i++) + { + var hitObject = createObject(); + hitObject.StartTime = startTime + i * spacing; + beatmap.HitObjects.Add(hitObject); + } + + startTime += 700; + } + } + + private class TestJuiceStream : JuiceStream + { + public TestJuiceStream(float x) + { + X = x; + + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(new Vector2(30, 0)), + }); + } } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs new file mode 100644 index 0000000000..1e708cce4b --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -0,0 +1,206 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneHyperDashColouring : OsuTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + [Test] + public void TestDefaultCatcherColour() + { + var skin = new TestSkin(); + + checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR); + } + + [Test] + public void TestCustomCatcherColour() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod + }; + + checkHyperDashCatcherColour(skin, skin.HyperDashColour); + } + + [Test] + public void TestCustomEndGlowColour() + { + var skin = new TestSkin + { + HyperDashAfterImageColour = Color4.Lime + }; + + checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR, skin.HyperDashAfterImageColour); + } + + [Test] + public void TestCustomEndGlowColourPriority() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod, + HyperDashAfterImageColour = Color4.Lime + }; + + checkHyperDashCatcherColour(skin, skin.HyperDashColour, skin.HyperDashAfterImageColour); + } + + [Test] + public void TestDefaultFruitColour() + { + var skin = new TestSkin(); + + checkHyperDashFruitColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR); + } + + [Test] + public void TestCustomFruitColour() + { + var skin = new TestSkin + { + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestCustomFruitColourPriority() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod, + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestFruitColourFallback() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod + }; + + checkHyperDashFruitColour(skin, skin.HyperDashColour); + } + + private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null) + { + CatcherArea catcherArea = null; + CatcherTrailDisplay trails = null; + + AddStep("create hyper-dashing catcher", () => + { + Child = setupSkinHierarchy(catcherArea = new CatcherArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4f), + }, skin); + + trails = catcherArea.OfType().Single(); + catcherArea.MovableCatcher.SetHyperDashState(2); + }); + + AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour); + + AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour); + AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour)); + + AddStep("finish hyper-dashing", () => + { + catcherArea.MovableCatcher.SetHyperDashState(1); + catcherArea.MovableCatcher.FinishTransforms(); + }); + + AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White); + } + + private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour) + { + DrawableFruit drawableFruit = null; + + AddStep("create hyper-dash fruit", () => + { + var fruit = new Fruit { HyperDashTarget = new Banana() }; + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + Child = setupSkinHierarchy(drawableFruit = new DrawableFruit(fruit) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4f), + }, skin); + }); + + AddAssert("hyper-dash colour is correct", () => checkLegacyFruitHyperDashColour(drawableFruit, expectedColour)); + } + + private Drawable setupSkinHierarchy(Drawable child, ISkin skin) + { + var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info)); + var testSkinProvider = new SkinProvidingContainer(skin); + var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider)); + + return legacySkinProvider + .WithChild(testSkinProvider + .WithChild(legacySkinTransformer + .WithChild(child))); + } + + private bool checkLegacyFruitHyperDashColour(DrawableFruit fruit, Color4 expectedColour) => + fruit.ChildrenOfType().First().Drawable.ChildrenOfType().Any(c => c.Colour == expectedColour); + + private class TestSkin : LegacySkin + { + public Color4 HyperDashColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()] = value; + } + + public Color4 HyperDashAfterImageColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()] = value; + } + + public Color4 HyperDashFruitColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()] = value; + } + + public TestSkin() + : base(new SkinInfo(), null, null, string.Empty) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs new file mode 100644 index 0000000000..269e783899 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneJuiceStream : TestSceneCatchPlayer + { + [Test] + public void TestJuiceStreamEndingCombo() + { + AddUntilStep("player is done", () => !Player.ValidForResume); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty { CircleSize = 5, SliderMultiplier = 2 }, + Ruleset = ruleset + }, + HitObjects = new List + { + new JuiceStream + { + X = CatchPlayfield.CENTER_X, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(0, 100) + }), + StartTime = 200 + }, + new Banana + { + X = CatchPlayfield.CENTER_X, + StartTime = 1000, + NewCombo = true + } + } + }; + } +} 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 9559d13328..7c0b73e8c3 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -2,9 +2,9 @@ - + - + diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 90a6e609f0..145a40f5f5 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -5,7 +5,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using System.Collections.Generic; using System.Linq; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects; using osu.Framework.Extensions.IEnumerableExtensions; @@ -28,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps switch (obj) { - case IHasCurve curveData: + case IHasPathWithRepeats curveData: return new JuiceStream { StartTime = obj.StartTime, @@ -36,13 +35,13 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Path = curveData.Path, NodeSamples = curveData.NodeSamples, RepeatCount = curveData.RepeatCount, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH, + X = positionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0 }.Yield(); - case IHasEndTime endTime: + case IHasDuration endTime: return new BananaShower { StartTime = obj.StartTime, @@ -59,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Samples = obj.Samples, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH + X = positionData?.X ?? 0 }.Yield(); } } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index db52fbac1b..bb14988414 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -28,15 +28,18 @@ namespace osu.Game.Rulesets.Catch.Beatmaps ApplyPositionOffsets(Beatmap); - initialiseHyperDash((List)Beatmap.HitObjects); - int index = 0; foreach (var obj in Beatmap.HitObjects.OfType()) { - obj.IndexInBeatmap = index++; + obj.IndexInBeatmap = index; + foreach (var nested in obj.NestedHitObjects.OfType()) + nested.IndexInBeatmap = index; + if (obj.LastInCombo && obj.NestedHitObjects.LastOrDefault() is IHasComboInformation lastNested) lastNested.LastInCombo = true; + + index++; } } @@ -62,7 +65,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps case BananaShower bananaShower: foreach (var banana in bananaShower.NestedHitObjects.OfType()) { - banana.XOffset = (float)rng.NextDouble(); + banana.XOffset = (float)(rng.NextDouble() * CatchPlayfield.WIDTH); rng.Next(); // osu!stable retrieved a random banana type rng.Next(); // osu!stable retrieved a random banana rotation rng.Next(); // osu!stable retrieved a random banana colour @@ -71,13 +74,19 @@ namespace osu.Game.Rulesets.Catch.Beatmaps break; case JuiceStream juiceStream: + // Todo: BUG!! Stable used the last control point as the final position of the path, but it should use the computed path instead. + lastPosition = juiceStream.X + juiceStream.Path.ControlPoints[^1].Position.Value.X; + + // Todo: BUG!! Stable attempted to use the end time of the stream, but referenced it too early in execution and used the start time instead. + lastStartTime = juiceStream.StartTime; + foreach (var nested in juiceStream.NestedHitObjects) { var catchObject = (CatchHitObject)nested; catchObject.XOffset = 0; if (catchObject is TinyDroplet) - catchObject.XOffset = Math.Clamp(rng.Next(-20, 20) / CatchPlayfield.BASE_WIDTH, -catchObject.X, 1 - catchObject.X); + catchObject.XOffset = Math.Clamp(rng.Next(-20, 20), -catchObject.X, CatchPlayfield.WIDTH - catchObject.X); else if (catchObject is Droplet) rng.Next(); // osu!stable retrieved a random droplet rotation } @@ -85,20 +94,12 @@ namespace osu.Game.Rulesets.Catch.Beatmaps break; } } + + initialiseHyperDash(beatmap); } private static void applyHardRockOffset(CatchHitObject hitObject, ref float? lastPosition, ref double lastStartTime, FastRandom rng) { - if (hitObject is JuiceStream stream) - { - lastPosition = stream.EndX; - lastStartTime = stream.EndTime; - return; - } - - if (!(hitObject is Fruit)) - return; - float offsetPosition = hitObject.X; double startTime = hitObject.StartTime; @@ -111,7 +112,9 @@ namespace osu.Game.Rulesets.Catch.Beatmaps } float positionDiff = offsetPosition - lastPosition.Value; - double timeDiff = startTime - lastStartTime; + + // Todo: BUG!! Stable calculated time deltas as ints, which affects randomisation. This should be changed to a double. + int timeDiff = (int)(startTime - lastStartTime); if (timeDiff > 1000) { @@ -127,7 +130,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps return; } - if (Math.Abs(positionDiff * CatchPlayfield.BASE_WIDTH) < timeDiff / 3d) + // ReSharper disable once PossibleLossOfFraction + if (Math.Abs(positionDiff) < timeDiff / 3) applyOffset(ref offsetPosition, positionDiff); hitObject.XOffset = offsetPosition - hitObject.X; @@ -145,12 +149,12 @@ namespace osu.Game.Rulesets.Catch.Beatmaps private static void applyRandomOffset(ref float position, double maxOffset, FastRandom rng) { bool right = rng.NextBool(); - float rand = Math.Min(20, (float)rng.Next(0, Math.Max(0, maxOffset))) / CatchPlayfield.BASE_WIDTH; + float rand = Math.Min(20, (float)rng.Next(0, Math.Max(0, maxOffset))); if (right) { // Clamp to the right bound - if (position + rand <= 1) + if (position + rand <= CatchPlayfield.WIDTH) position += rand; else position -= rand; @@ -186,14 +190,14 @@ namespace osu.Game.Rulesets.Catch.Beatmaps } } - private void initialiseHyperDash(List objects) + private static void initialiseHyperDash(IBeatmap beatmap) { List objectWithDroplets = new List(); - foreach (var currentObject in objects) + foreach (var currentObject in beatmap.HitObjects) { - if (currentObject is Fruit) - objectWithDroplets.Add(currentObject); + if (currentObject is Fruit fruitObject) + objectWithDroplets.Add(fruitObject); if (currentObject is JuiceStream) { @@ -207,7 +211,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps objectWithDroplets.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); - double halfCatcherWidth = CatcherArea.GetCatcherSize(Beatmap.BeatmapInfo.BaseDifficulty) / 2; + double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) / 2; int lastDirection = 0; double lastExcess = halfCatcherWidth; @@ -216,10 +220,14 @@ namespace osu.Game.Rulesets.Catch.Beatmaps CatchHitObject currentObject = objectWithDroplets[i]; CatchHitObject nextObject = objectWithDroplets[i + 1]; + // Reset variables in-case values have changed (e.g. after applying HR) + currentObject.HyperDashTarget = null; + currentObject.DistanceToHyperDash = 0; + int thisDirection = nextObject.X > currentObject.X ? 1 : -1; double timeToNext = nextObject.StartTime - currentObject.StartTime - 1000f / 60f / 4; // 1/4th of a frame of grace time, taken from osu-stable double distanceToNext = Math.Abs(nextObject.X - currentObject.X) - (lastDirection == thisDirection ? lastExcess : halfCatcherWidth); - float distanceToHyper = (float)(timeToNext * CatcherArea.Catcher.BASE_SPEED - distanceToNext); + float distanceToHyper = (float)(timeToNext * Catcher.BASE_SPEED - distanceToNext); if (distanceToHyper < 0) { diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index dc8df28e6a..9437023c70 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -21,9 +21,13 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; +using osu.Framework.Testing; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { + [ExcludeFromDynamicCompile] public class CatchRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); @@ -46,7 +50,7 @@ namespace osu.Game.Rulesets.Catch new KeyBinding(InputKey.Shift, CatchAction.Dash), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new CatchModNightcore(); @@ -135,10 +139,14 @@ namespace osu.Game.Rulesets.Catch public override string ShortName => SHORT_NAME; + public override string PlayingVerb => "Catching fruit"; + public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetCatch }; public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new CatchLegacySkinTransformer(source); + public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new CatchPerformanceCalculator(this, beatmap, score); public int LegacyID => 2; diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs index 7e482d4045..80390705fe 100644 --- a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs +++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs @@ -5,5 +5,14 @@ namespace osu.Game.Rulesets.Catch { public enum CatchSkinComponents { + FruitBananas, + FruitApple, + FruitGrapes, + FruitOrange, + FruitPear, + Droplet, + CatcherIdle, + CatcherFail, + CatcherKiai } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 44e1a8e5cc..a317ef252d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -19,10 +19,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty { public class CatchDifficultyCalculator : DifficultyCalculator { - private const double star_scaling_factor = 0.145; + private const double star_scaling_factor = 0.153; protected override int SectionLength => 750; + private float halfCatcherWidth; + public CatchDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -48,14 +50,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - float halfCatchWidth; - - using (var catcher = new CatcherArea.Catcher(beatmap.BeatmapInfo.BaseDifficulty)) - { - halfCatchWidth = catcher.CatchWidth * 0.5f; - halfCatchWidth *= 0.8f; // We're only using 80% of the catcher's width to simulate imperfect gameplay. - } - CatchHitObject lastObject = null; // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. @@ -69,16 +63,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty continue; if (lastObject != null) - yield return new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatchWidth); + yield return new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth); lastObject = hitObject; } } - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap) { - new Movement(), - }; + halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5.5f) * 0.0625f); + + return new Skill[] + { + new Movement(halfCatcherWidth), + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index a6283eb7c4..2ee7cea645 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Scoring.Legacy; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -34,11 +34,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty { mods = Score.Mods; - fruitsHit = Score?.GetCount300() ?? Score.Statistics[HitResult.Perfect]; - ticksHit = Score?.GetCount100() ?? 0; - tinyTicksHit = Score?.GetCount50() ?? 0; - tinyTicksMissed = Score?.GetCountKatu() ?? 0; - misses = Score.Statistics[HitResult.Miss]; + fruitsHit = Score.Statistics.GetOrDefault(HitResult.Perfect); + ticksHit = Score.Statistics.GetOrDefault(HitResult.LargeTickHit); + tinyTicksHit = Score.Statistics.GetOrDefault(HitResult.SmallTickHit); + tinyTicksMissed = Score.Statistics.GetOrDefault(HitResult.SmallTickMiss); + misses = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) @@ -52,8 +52,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty // Longer maps are worth more double lengthBonus = - 0.95 + 0.4 * Math.Min(1.0, numTotalHits / 3000.0) + - (numTotalHits > 3000 ? Math.Log10(numTotalHits / 3000.0) * 0.5 : 0.0); + 0.95 + 0.3 * Math.Min(1.0, numTotalHits / 2500.0) + + (numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0); // Longer maps are worth more value *= lengthBonus; @@ -63,19 +63,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty // Combo scaling if (Attributes.MaxCombo > 0) - value *= Math.Min(Math.Pow(Attributes.MaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + value *= Math.Min(Math.Pow(Score.MaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + double approachRate = Attributes.ApproachRate; double approachRateFactor = 1.0; - if (Attributes.ApproachRate > 9.0) - approachRateFactor += 0.1 * (Attributes.ApproachRate - 9.0); // 10% for each AR above 9 - else if (Attributes.ApproachRate < 8.0) - approachRateFactor += 0.025 * (8.0 - Attributes.ApproachRate); // 2.5% for each AR below 8 + if (approachRate > 9.0) + approachRateFactor += 0.1 * (approachRate - 9.0); // 10% for each AR above 9 + if (approachRate > 10.0) + approachRateFactor += 0.1 * (approachRate - 10.0); // Additional 10% at AR 11, 30% total + else if (approachRate < 8.0) + approachRateFactor += 0.025 * (8.0 - approachRate); // 2.5% for each AR below 8 value *= approachRateFactor; if (mods.Any(m => m is ModHidden)) - // Hiddens gives nothing on max approach rate, and more the lower it is + { value *= 1.05 + 0.075 * (10.0 - Math.Min(10.0, Attributes.ApproachRate)); // 7.5% for each AR below 10 + // Hiddens gives almost nothing on max approach rate, and more the lower it is + if (approachRate <= 10.0) + value *= 1.05 + 0.075 * (10.0 - approachRate); // 7.5% for each AR below 10 + else if (approachRate > 10.0) + value *= 1.01 + 0.04 * (11.0 - Math.Min(11.0, approachRate)); // 5% at AR 10, 1% at AR 11 + } if (mods.Any(m => m is ModFlashlight)) // Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps. @@ -91,7 +100,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty return value; } - private float accuracy() => totalHits() == 0 ? 0 : Math.Clamp((float)totalSuccessfulHits() / totalHits(), 0, 1); + private double accuracy() => totalHits() == 0 ? 0 : Math.Clamp((double)totalSuccessfulHits() / totalHits(), 0, 1); private int totalHits() => tinyTicksHit + ticksHit + fruitsHit + misses + tinyTicksMissed; private int totalSuccessfulHits() => tinyTicksHit + ticksHit + fruitsHit; private int totalComboHits() => misses + ticksHit + fruitsHit; diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 24e526ed19..3e21b8fbaf 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; @@ -21,21 +20,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing public readonly float LastNormalizedPosition; /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// Milliseconds elapsed since the start time of the previous , with a minimum of 40ms. /// public readonly double StrainTime; + public readonly double ClockRate; + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) : base(hitObject, lastObject, clockRate) { // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. var scalingFactor = normalized_hitobject_radius / halfCatcherWidth; - NormalizedPosition = BaseObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; - LastNormalizedPosition = LastObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; + NormalizedPosition = BaseObject.X * scalingFactor; + LastNormalizedPosition = LastObject.X * scalingFactor; - // Every strain interval is hard capped at the equivalent of 600 BPM streaming speed as a safety measure - StrainTime = Math.Max(25, DeltaTime); + // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure + StrainTime = Math.Max(40, DeltaTime); + ClockRate = clockRate; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 7cd569035b..e679231638 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; @@ -13,22 +12,29 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills { private const float absolute_player_positioning_error = 16f; private const float normalized_hitobject_radius = 41.0f; - private const double direction_change_bonus = 12.5; + private const double direction_change_bonus = 21.0; - protected override double SkillMultiplier => 850; + protected override double SkillMultiplier => 900; protected override double StrainDecayBase => 0.2; protected override double DecayWeight => 0.94; + protected readonly float HalfCatcherWidth; + private float? lastPlayerPosition; private float lastDistanceMoved; + private double lastStrainTime; + + public Movement(float halfCatcherWidth) + { + HalfCatcherWidth = halfCatcherWidth; + } protected override double StrainValueOf(DifficultyHitObject current) { var catchCurrent = (CatchDifficultyHitObject)current; - if (lastPlayerPosition == null) - lastPlayerPosition = catchCurrent.LastNormalizedPosition; + lastPlayerPosition ??= catchCurrent.LastNormalizedPosition; float playerPosition = Math.Clamp( lastPlayerPosition.Value, @@ -38,47 +44,47 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills float distanceMoved = playerPosition - lastPlayerPosition.Value; - double distanceAddition = Math.Pow(Math.Abs(distanceMoved), 1.3) / 500; - double sqrtStrain = Math.Sqrt(catchCurrent.StrainTime); + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catchCurrent.ClockRate); - double bonus = 0; + double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); + double sqrtStrain = Math.Sqrt(weightedStrainTime); - // Direction changes give an extra point! + double edgeDashBonus = 0; + + // Direction change bonus. if (Math.Abs(distanceMoved) > 0.1) { if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved)) { - double bonusFactor = Math.Min(absolute_player_positioning_error, Math.Abs(distanceMoved)) / absolute_player_positioning_error; + double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50; + double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38); - distanceAddition += direction_change_bonus / sqrtStrain * bonusFactor; - - // Bonus for tougher direction switches and "almost" hyperdashes at this point - if (catchCurrent.LastObject.DistanceToHyperDash <= 10 / CatchPlayfield.BASE_WIDTH) - bonus = 0.3 * bonusFactor; + distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); } // Base bonus for every movement, giving some weight to streams. - distanceAddition += 7.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; + distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; } - // Bonus for "almost" hyperdashes at corner points - if (catchCurrent.LastObject.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH) + // Bonus for edge dashes. + if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) { if (!catchCurrent.LastObject.HyperDash) - bonus += 1.0; + edgeDashBonus += 5.7; else { // After a hyperdash we ARE in the correct position. Always! playerPosition = catchCurrent.NormalizedPosition; } - distanceAddition *= 1.0 + bonus * ((10 - catchCurrent.LastObject.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 10); + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catchCurrent.ClockRate, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } lastPlayerPosition = playerPosition; lastDistanceMoved = distanceMoved; + lastStrainTime = catchCurrent.StrainTime; - return distanceAddition / catchCurrent.StrainTime; + return distanceAddition / weightedStrainTime; } } } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs index 374dd50c11..a7449ba4e1 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Judgements return 0; case HitResult.Perfect: - return 0.008; + return DEFAULT_MAX_HEALTH_INCREASE * 0.75; } } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs index f1399bb5c0..e87ecba749 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs @@ -18,17 +18,5 @@ namespace osu.Game.Rulesets.Catch.Judgements return 30; } } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return base.HealthIncreaseFor(result); - - case HitResult.Perfect: - return 0.007; - } - } } } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs index 8fd9ac92ba..2149ed9712 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs @@ -23,18 +23,6 @@ namespace osu.Game.Rulesets.Catch.Judgements } } - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return -0.02; - - case HitResult.Perfect: - return 0.01; - } - } - /// /// Whether fruit on the platter should explode or drop. /// Note that this is only checked if the owning object is also diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs index 3829b5e94f..d607b49ea4 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Judgements return 0; case HitResult.Perfect: - return 0.004; + return 0.02; } } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 8377b3786a..acdd0a420c 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModDifficultyAdjust : ModDifficultyAdjust { - [SettingSource("Fruit Size", "Override a beatmap's set CS.")] + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] public BindableNumber CircleSize { get; } = new BindableFloat { Precision = 0.1f, @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Catch.Mods Value = 5, }; - [SettingSource("Approach Rate", "Override a beatmap's set AR.")] + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] public BindableNumber ApproachRate { get; } = new BindableFloat { Precision = 0.1f, @@ -30,6 +31,22 @@ namespace osu.Game.Rulesets.Catch.Mods Value = 5, }; + public override string SettingDescription + { + get + { + string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; + string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + + return string.Join(", ", new[] + { + circleSize, + base.SettingDescription, + approachRate + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + protected override void TransferSettings(BeatmapDifficulty difficulty) { base.TransferSettings(difficulty); diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 606a935229..ee88edbea1 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -3,7 +3,7 @@ using System.Linq; using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs index fb92399102..e3391c47f1 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.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. +using osu.Game.Rulesets.Catch.Judgements; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Mods { public class CatchModPerfect : ModPerfect { + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + => !(result.Judgement is CatchBananaJudgement) + && base.FailCondition(healthProcessor, result); } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index a47efcc10a..c1d24395e4 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -9,22 +9,31 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset + public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"Use the mouse to control the catcher."; + private DrawableRuleset drawableRuleset; + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); + this.drawableRuleset = drawableRuleset; + } + + public void ApplyToPlayer(Player player) + { + if (!drawableRuleset.HasReplayLoaded.Value) + drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); } private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition { - private readonly CatcherArea.Catcher catcher; + private readonly Catcher catcher; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -34,9 +43,12 @@ namespace osu.Game.Rulesets.Catch.Mods RelativeSizeAxes = Axes.Both; } - //disable keyboard controls + // disable keyboard controls public bool OnPressed(CatchAction action) => true; - public bool OnReleased(CatchAction action) => true; + + public void OnReleased(CatchAction action) + { + } protected override bool OnMouseMove(MouseMoveEvent e) { diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 267e6d12c7..04a995c77e 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -1,23 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Objects { - public class BananaShower : CatchHitObject, IHasEndTime + public class BananaShower : CatchHitObject, IHasDuration { public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; public override bool LastInCombo => true; - protected override void CreateNestedHitObjects() + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); - createBananas(); + base.CreateNestedHitObjects(cancellationToken); + createBananas(cancellationToken); } - private void createBananas() + private void createBananas(CancellationToken cancellationToken) { double spacing = Duration; while (spacing > 100) @@ -28,6 +32,8 @@ namespace osu.Game.Rulesets.Catch.Objects for (double i = StartTime; i <= EndTime; i += spacing) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new Banana { Samples = Samples, @@ -36,7 +42,11 @@ namespace osu.Game.Rulesets.Catch.Objects } } - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } public double Duration { get; set; } } diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index e4ad49ea50..04932ecdbb 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -13,10 +14,13 @@ namespace osu.Game.Rulesets.Catch.Objects { public abstract class CatchHitObject : HitObject, IHasXPosition, IHasComboInformation { - public const double OBJECT_RADIUS = 44; + public const float OBJECT_RADIUS = 64; private float x; + /// + /// The horizontal position of the fruit between 0 and . + /// public float X { get => x + XOffset; @@ -32,7 +36,7 @@ namespace osu.Game.Rulesets.Catch.Objects public int IndexInBeatmap { get; set; } - public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(ComboIndex % 4); + public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(IndexInBeatmap % 4); public virtual bool NewCombo { get; set; } @@ -90,7 +94,7 @@ namespace osu.Game.Rulesets.Catch.Objects TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); - Scale = 1.0f - 0.7f * (difficulty.CircleSize - 5) / 5; + Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } protected override HitWindows CreateHitWindows() => HitWindows.Empty; @@ -100,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects { Pear, Grape, - Raspberry, Pineapple, + Raspberry, Banana // banananananannaanana } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs deleted file mode 100644 index 5afdb14888..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableBanana : DrawableFruit - { - public DrawableBanana(Banana h) - : base(h) - { - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs deleted file mode 100644 index 059310d671..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.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 osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osuTK; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableDroplet : PalpableCatchHitObject - { - private Pulp pulp; - - public override bool StaysOnPlate => false; - - public DrawableDroplet(Droplet h) - : base(h) - { - Origin = Anchor.Centre; - Size = new Vector2((float)CatchHitObject.OBJECT_RADIUS) / 4; - Masking = false; - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(pulp = new Pulp { Size = Size }); - - AccentColour.BindValueChanged(colour => { pulp.AccentColour = colour.NewValue; }, true); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs deleted file mode 100644 index 958cd19d50..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableFruit : PalpableCatchHitObject - { - private Circle border; - - private const float drawable_radius = (float)CatchHitObject.OBJECT_RADIUS * radius_adjust; - - /// - /// Because we're adding a border around the fruit, we need to scale down some. - /// - private const float radius_adjust = 1.1f; - - public DrawableFruit(Fruit h) - : base(h) - { - Origin = Anchor.Centre; - - Size = new Vector2(drawable_radius); - Masking = false; - - Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; - } - - [BackgroundDependencyLoader] - private void load() - { - // todo: this should come from the skin. - AccentColour.Value = colourForRepresentation(HitObject.VisualRepresentation); - - AddRangeInternal(new[] - { - createPulp(HitObject.VisualRepresentation), - border = new Circle - { - EdgeEffect = new EdgeEffectParameters - { - Hollow = !HitObject.HyperDash, - Type = EdgeEffectType.Glow, - Radius = 4 * radius_adjust, - Colour = HitObject.HyperDash ? Color4.Red : AccentColour.Value.Darken(1).Opacity(0.6f) - }, - Size = new Vector2(Height), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BorderColour = Color4.White, - BorderThickness = 3f * radius_adjust, - Children = new Framework.Graphics.Drawable[] - { - new Box - { - AlwaysPresent = true, - Colour = AccentColour.Value, - Alpha = 0, - RelativeSizeAxes = Axes.Both - } - } - }, - }); - - if (HitObject.HyperDash) - { - AddInternal(new Pulp - { - RelativePositionAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AccentColour = Color4.Red, - Blending = BlendingParameters.Additive, - Alpha = 0.5f, - Scale = new Vector2(1.333f) - }); - } - } - - private Framework.Graphics.Drawable createPulp(FruitVisualRepresentation representation) - { - const float large_pulp_3 = 8f * radius_adjust; - const float distance_from_centre_3 = 0.15f; - - const float large_pulp_4 = large_pulp_3 * 0.925f; - const float distance_from_centre_4 = distance_from_centre_3 / 0.925f; - - const float small_pulp = large_pulp_3 / 2; - - static Vector2 positionAt(float angle, float distance) => new Vector2( - distance * MathF.Sin(angle * MathF.PI / 180), - distance * MathF.Cos(angle * MathF.PI / 180)); - - switch (representation) - { - default: - return new Container(); - - case FruitVisualRepresentation.Raspberry: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.34f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(0, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(90, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(180, distance_from_centre_4), - }, - new Pulp - { - Size = new Vector2(large_pulp_4), - AccentColour = AccentColour.Value, - Position = positionAt(270, distance_from_centre_4), - }, - } - }; - - case FruitVisualRepresentation.Pineapple: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.3f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(45, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(135, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(225, distance_from_centre_4), - }, - new Pulp - { - Size = new Vector2(large_pulp_4), - AccentColour = AccentColour.Value, - Position = positionAt(315, distance_from_centre_4), - }, - } - }; - - case FruitVisualRepresentation.Pear: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.33f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(60, distance_from_centre_3), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(180, distance_from_centre_3), - }, - new Pulp - { - Size = new Vector2(large_pulp_3), - AccentColour = AccentColour.Value, - Position = positionAt(300, distance_from_centre_3), - }, - } - }; - - case FruitVisualRepresentation.Grape: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.25f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(0, distance_from_centre_3), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(120, distance_from_centre_3), - }, - new Pulp - { - Size = new Vector2(large_pulp_3), - AccentColour = AccentColour.Value, - Position = positionAt(240, distance_from_centre_3), - }, - } - }; - - case FruitVisualRepresentation.Banana: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.3f - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4 * 0.8f, large_pulp_4 * 2.5f), - Y = 0.05f, - }, - } - }; - } - } - - protected override void Update() - { - base.Update(); - - border.Alpha = (float)Math.Clamp((HitObject.StartTime - Time.Current) / 500, 0, 1); - } - - private Color4 colourForRepresentation(FruitVisualRepresentation representation) - { - switch (representation) - { - default: - case FruitVisualRepresentation.Pear: - return new Color4(17, 136, 170, 255); - - case FruitVisualRepresentation.Grape: - return new Color4(204, 102, 0, 255); - - case FruitVisualRepresentation.Raspberry: - return new Color4(121, 9, 13, 255); - - case FruitVisualRepresentation.Pineapple: - return new Color4(102, 136, 0, 255); - - case FruitVisualRepresentation.Banana: - switch (RNG.Next(0, 3)) - { - default: - return new Color4(255, 240, 0, 255); - - case 1: - return new Color4(255, 192, 0, 255); - - case 2: - return new Color4(214, 221, 28, 255); - } - } - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs new file mode 100644 index 0000000000..ebb0bf0f2c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class BananaPiece : PulpFormation + { + public BananaPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.3f + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4 * 0.8f, LARGE_PULP_4 * 2.5f), + Y = 0.05f, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs new file mode 100644 index 0000000000..01b76ceed9 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.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.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableBanana : DrawableFruit + { + public DrawableBanana(Banana h) + : base(h) + { + } + + private Color4? colour; + + protected override Color4 GetComboColour(IReadOnlyList comboColours) + { + // override any external colour changes with banananana + return colour ??= getBananaColour(); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + const float end_scale = 0.6f; + const float random_scale_range = 1.6f; + + ScaleContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RNG.NextSingle())) + .Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt); + + ScaleContainer.RotateTo(getRandomAngle()) + .Then() + .RotateTo(getRandomAngle(), HitObject.TimePreempt); + + float getRandomAngle() => 180 * (RNG.NextSingle() * 2 - 1); + } + + private Color4 getBananaColour() + { + switch (RNG.Next(0, 3)) + { + default: + return new Color4(255, 240, 0, 255); + + case 1: + return new Color4(255, 192, 0, 255); + + case 2: + return new Color4(214, 221, 28, 255); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs similarity index 97% rename from osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs index ea415e18fa..4ce80aceb8 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs @@ -7,7 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -namespace osu.Game.Rulesets.Catch.Objects.Drawable +namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableBananaShower : DrawableCatchHitObject { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs similarity index 66% rename from osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index b7c05392f3..c6345a9df7 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -2,24 +2,52 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; +using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Catch.UI; +using osuTK; +using osuTK.Graphics; -namespace osu.Game.Rulesets.Catch.Objects.Drawable +namespace osu.Game.Rulesets.Catch.Objects.Drawables { public abstract class PalpableCatchHitObject : DrawableCatchHitObject where TObject : CatchHitObject { public override bool CanBePlated => true; + protected Container ScaleContainer { get; private set; } + protected PalpableCatchHitObject(TObject hitObject) : base(hitObject) { - Scale = new Vector2(HitObject.Scale); + Origin = Anchor.Centre; + Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2); + Masking = false; } + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + ScaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + } + }); + + ScaleContainer.Scale = new Vector2(HitObject.Scale); + } + + protected override Color4 GetComboColour(IReadOnlyList comboColours) => + comboColours[(HitObject.IndexInBeatmap + 1) % comboColours.Count]; } public abstract class DrawableCatchHitObject : DrawableCatchHitObject @@ -41,10 +69,13 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable public virtual bool StaysOnPlate => CanBePlated; + public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; + + protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; + protected DrawableCatchHitObject(CatchHitObject hitObject) : base(hitObject) { - RelativePositionAxes = Axes.X; X = hitObject.X; } @@ -62,10 +93,6 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable ApplyResult(r => r.Type = CheckPosition.Invoke(HitObject) ? HitResult.Perfect : HitResult.Miss); } - protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; - - protected override void UpdateInitialTransforms() => this.FadeInFromZero(200); - protected override void UpdateStateTransforms(ArmedState state) { var endTime = HitObject.GetEndTime(); diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs new file mode 100644 index 0000000000..cad8892283 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.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.Graphics; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableDroplet : PalpableCatchHitObject + { + public override bool StaysOnPlate => false; + + public DrawableDroplet(Droplet h) + : base(h) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Child = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.Droplet), _ => new Pulp + { + Size = Size / 4, + AccentColour = { BindTarget = AccentColour } + }); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // roughly matches osu-stable + float startRotation = RNG.NextSingle() * 20; + double duration = HitObject.TimePreempt + 2000; + + ScaleContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs new file mode 100644 index 0000000000..fae5a10d04 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Utils; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableFruit : PalpableCatchHitObject + { + public DrawableFruit(Fruit h) + : base(h) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(getComponent(HitObject.VisualRepresentation)), _ => new FruitPiece()); + + ScaleContainer.Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; + } + + private CatchSkinComponents getComponent(FruitVisualRepresentation hitObjectVisualRepresentation) + { + switch (hitObjectVisualRepresentation) + { + case FruitVisualRepresentation.Pear: + return CatchSkinComponents.FruitPear; + + case FruitVisualRepresentation.Grape: + return CatchSkinComponents.FruitGrapes; + + case FruitVisualRepresentation.Pineapple: + return CatchSkinComponents.FruitApple; + + case FruitVisualRepresentation.Raspberry: + return CatchSkinComponents.FruitOrange; + + case FruitVisualRepresentation.Banana: + return CatchSkinComponents.FruitBananas; + + default: + throw new ArgumentOutOfRangeException(nameof(hitObjectVisualRepresentation), hitObjectVisualRepresentation, null); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs similarity index 77% rename from osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs index a24821b3ce..7bc016d94f 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs @@ -6,19 +6,22 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawable +namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableJuiceStream : DrawableCatchHitObject { private readonly Func> createDrawableRepresentation; private readonly Container dropletContainer; + public override Vector2 OriginPosition => base.OriginPosition - new Vector2(0, CatchHitObject.OBJECT_RADIUS); + public DrawableJuiceStream(JuiceStream s, Func> createDrawableRepresentation = null) : base(s) { this.createDrawableRepresentation = createDrawableRepresentation; - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; Origin = Anchor.BottomLeft; X = 0; @@ -27,6 +30,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable protected override void AddNestedHitObject(DrawableHitObject hitObject) { + hitObject.Origin = Anchor.BottomCentre; + base.AddNestedHitObject(hitObject); dropletContainer.Add(hitObject); } @@ -42,10 +47,11 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable switch (hitObject) { case CatchHitObject catchObject: - return createDrawableRepresentation?.Invoke(catchObject)?.With(o => ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); + return createDrawableRepresentation?.Invoke(catchObject)?.With(o => + ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); } - return base.CreateNestedHitObject(hitObject); + throw new ArgumentException($"{nameof(hitObject)} must be of type {nameof(CatchHitObject)}."); } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs similarity index 60% rename from osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs index d41aea1e7b..ae775684d8 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; +using osu.Framework.Allocation; -namespace osu.Game.Rulesets.Catch.Objects.Drawable +namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableTinyDroplet : DrawableDroplet { public DrawableTinyDroplet(TinyDroplet h) : base(h) { - Size = new Vector2((float)CatchHitObject.OBJECT_RADIUS) / 8; + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Scale /= 2; } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs new file mode 100644 index 0000000000..7ac9f11ad6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.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 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.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + internal class FruitPiece : CompositeDrawable + { + /// + /// Because we're adding a border around the fruit, we need to scale down some. + /// + public const float RADIUS_ADJUST = 1.1f; + + private Circle border; + + private CatchHitObject hitObject; + + private readonly IBindable accentColour = new Bindable(); + + public FruitPiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + hitObject = drawableCatchObject.HitObject; + + accentColour.BindTo(drawableCatchObject.AccentColour); + + AddRangeInternal(new[] + { + getFruitFor(drawableCatchObject.HitObject.VisualRepresentation), + border = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Color4.White, + BorderThickness = 6f * RADIUS_ADJUST, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + } + } + }, + }); + + if (hitObject.HyperDash) + { + AddInternal(new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + BorderThickness = 12f * RADIUS_ADJUST, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + } + } + }); + } + } + + protected override void Update() + { + base.Update(); + border.Alpha = (float)Math.Clamp((hitObject.StartTime - Time.Current) / 500, 0, 1); + } + + private Drawable getFruitFor(FruitVisualRepresentation representation) + { + switch (representation) + { + case FruitVisualRepresentation.Pear: + return new PearPiece(); + + case FruitVisualRepresentation.Grape: + return new GrapePiece(); + + case FruitVisualRepresentation.Pineapple: + return new PineapplePiece(); + + case FruitVisualRepresentation.Banana: + return new BananaPiece(); + + case FruitVisualRepresentation.Raspberry: + return new RaspberryPiece(); + } + + return Empty(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs new file mode 100644 index 0000000000..1d1faf893b --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class GrapePiece : PulpFormation + { + public GrapePiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.25f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(0, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(120, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_3), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(240, DISTANCE_FROM_CENTRE_3), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs new file mode 100644 index 0000000000..7f14217cda --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class PearPiece : PulpFormation + { + public PearPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.33f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(60, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(180, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_3), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(300, DISTANCE_FROM_CENTRE_3), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.cs similarity index 62% rename from osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.cs index 1e9daf18db..1e7506a257 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.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. +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osuTK.Graphics; -namespace osu.Game.Rulesets.Catch.Objects.Drawable.Pieces +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { - public class Pulp : Circle, IHasAccentColour + public class Pulp : Circle { public Pulp() { @@ -22,32 +22,23 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable.Pieces Colour = Color4.White.Opacity(0.9f); } - private Color4 accentColour; + public readonly Bindable AccentColour = new Bindable(); - public Color4 AccentColour + protected override void LoadComplete() { - get => accentColour; - set - { - accentColour = value; - if (IsLoaded) updateAccentColour(); - } + base.LoadComplete(); + + AccentColour.BindValueChanged(updateAccentColour, true); } - private void updateAccentColour() + private void updateAccentColour(ValueChangedEvent colour) { EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, Radius = Size.X / 2, - Colour = accentColour.Darken(0.2f).Opacity(0.75f) + Colour = colour.NewValue.Darken(0.2f).Opacity(0.75f) }; } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateAccentColour(); - } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs new file mode 100644 index 0000000000..c328ba1837 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.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.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class PineapplePiece : PulpFormation + { + public PineapplePiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.3f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(45, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(135, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(225, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_4), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(315, DISTANCE_FROM_CENTRE_4), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs new file mode 100644 index 0000000000..be70c3400c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public abstract class PulpFormation : CompositeDrawable + { + protected readonly IBindable AccentColour = new Bindable(); + + protected const float LARGE_PULP_3 = 16f * FruitPiece.RADIUS_ADJUST; + protected const float DISTANCE_FROM_CENTRE_3 = 0.15f; + + protected const float LARGE_PULP_4 = LARGE_PULP_3 * 0.925f; + protected const float DISTANCE_FROM_CENTRE_4 = DISTANCE_FROM_CENTRE_3 / 0.925f; + + protected const float SMALL_PULP = LARGE_PULP_3 / 2; + + protected PulpFormation() + { + RelativeSizeAxes = Axes.Both; + } + + protected static Vector2 PositionAt(float angle, float distance) => new Vector2( + distance * MathF.Sin(angle * MathF.PI / 180), + distance * MathF.Cos(angle * MathF.PI / 180)); + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + AccentColour.BindTo(drawableCatchObject.AccentColour); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs new file mode 100644 index 0000000000..22ce3ba5b3 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.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.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class RaspberryPiece : PulpFormation + { + public RaspberryPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.34f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(0, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(90, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(180, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_4), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(270, DISTANCE_FROM_CENTRE_4), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index a4ed966abb..6b8b70ed54 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -3,26 +3,29 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; 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; namespace osu.Game.Rulesets.Catch.Objects { - public class JuiceStream : CatchHitObject, IHasCurve + public class JuiceStream : CatchHitObject, IHasPathWithRepeats { /// /// Positional distance that results in a duration of one second, before any speed adjustments. /// private const float base_scoring_distance = 100; + public override Judgement CreateJudgement() => new IgnoreJudgement(); + public int RepeatCount { get; set; } - public double Velocity; - public double TickDistance; + public double Velocity { get; private set; } + public double TickDistance { get; private set; } /// /// The length of one span of this . @@ -42,11 +45,11 @@ namespace osu.Game.Rulesets.Catch.Objects TickDistance = scoringDistance / difficulty.SliderTickRate; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); - var tickSamples = Samples.Select(s => new HitSampleInfo + var dropletSamples = Samples.Select(s => new HitSampleInfo { Bank = s.Bank, Name = @"slidertick", @@ -55,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Objects SliderEventDescriptor? lastEvent = null; - foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset)) + foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) { // generate tiny droplets since the last point if (lastEvent != null) @@ -70,12 +73,13 @@ namespace osu.Game.Rulesets.Catch.Objects for (double t = timeBetweenTiny; t < sinceLastTick; t += timeBetweenTiny) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new TinyDroplet { - Samples = tickSamples, StartTime = t + lastEvent.Value.Time, X = X + Path.PositionAt( - lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X / CatchPlayfield.BASE_WIDTH, + lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X, }); } } @@ -90,9 +94,9 @@ namespace osu.Game.Rulesets.Catch.Objects case SliderEventType.Tick: AddNested(new Droplet { - Samples = tickSamples, + Samples = dropletSamples, StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; @@ -103,18 +107,22 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = Samples, StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; } } } - public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; + public float EndX => X + this.CurvePositionAt(1).X; - public float EndX => X + this.CurvePositionAt(1).X / CatchPlayfield.BASE_WIDTH; + public double Duration + { + get => this.SpanCount() * Path.Distance / Velocity; + set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; private readonly SliderPath path = new SliderPath(); diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index 6c8515eb90..5d11c574b1 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Catch.Beatmaps; @@ -32,9 +32,9 @@ namespace osu.Game.Rulesets.Catch.Replays public override Replay Generate() { // todo: add support for HT DT - const double dash_speed = CatcherArea.Catcher.BASE_SPEED; + const double dash_speed = Catcher.BASE_SPEED; const double movement_speed = dash_speed / 2; - float lastPosition = 0.5f; + float lastPosition = CatchPlayfield.CENTER_X; double lastTime = 0; void moveToNext(CatchHitObject h) @@ -51,11 +51,11 @@ namespace osu.Game.Rulesets.Catch.Replays bool impossibleJump = speedRequired > movement_speed * 2; // todo: get correct catcher size, based on difficulty CS. - const float catcher_width_half = CatcherArea.CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * 0.3f * 0.5f; + const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f; if (lastPosition - catcher_width_half < h.X && lastPosition + catcher_width_half > h.X) { - //we are already in the correct range. + // we are already in the correct range. lastTime = h.StartTime; addFrame(h.StartTime, lastPosition); return; @@ -72,14 +72,14 @@ namespace osu.Game.Rulesets.Catch.Replays } else if (dashRequired) { - //we do a movement in two parts - the dash part then the normal part... + // we do a movement in two parts - the dash part then the normal part... double timeAtNormalSpeed = positionChange / movement_speed; double timeWeNeedToSave = timeAtNormalSpeed - timeAvailable; double timeAtDashSpeed = timeWeNeedToSave / 2; float midPosition = (float)Interpolation.Lerp(lastPosition, h.X, (float)timeAtDashSpeed / timeAvailable); - //dash movement + // dash movement addFrame(h.StartTime - timeAvailable + 1, lastPosition, true); addFrame(h.StartTime - timeAvailable + timeAtDashSpeed, midPosition); addFrame(h.StartTime, h.X); diff --git a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs index 22532bc9ec..f122588a2b 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Input.StateChanges; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Replays; using osu.Game.Rulesets.Replays; diff --git a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs index b41a5e0612..7efd832f62 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -39,9 +38,9 @@ namespace osu.Game.Rulesets.Catch.Replays } } - public void ConvertFrom(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - Position = currentFrame.Position.X / CatchPlayfield.BASE_WIDTH; + Position = currentFrame.Position.X; Dashing = currentFrame.ButtonState == ReplayButtonState.Left1; if (Dashing) @@ -56,5 +55,14 @@ namespace osu.Game.Rulesets.Catch.Replays Actions.Add(CatchAction.MoveLeft); } } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(CatchAction.Dash)) state |= ReplayButtonState.Left1; + + return new LegacyReplayFrame(Time, Position, null, state); + } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs new file mode 100644 index 0000000000..d929da1a29 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Humanizer; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + public class CatchLegacySkinTransformer : LegacySkinTransformer + { + public CatchLegacySkinTransformer(ISkinSource source) + : base(source) + { + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (!(component is CatchSkinComponent catchSkinComponent)) + return null; + + switch (catchSkinComponent.Component) + { + case CatchSkinComponents.FruitApple: + case CatchSkinComponents.FruitBananas: + case CatchSkinComponents.FruitOrange: + case CatchSkinComponents.FruitGrapes: + case CatchSkinComponents.FruitPear: + var lookupName = catchSkinComponent.Component.ToString().Kebaberize(); + if (GetTexture(lookupName) != null) + return new LegacyFruitPiece(lookupName); + + break; + + case CatchSkinComponents.Droplet: + if (GetTexture("fruit-drop") != null) + return new LegacyFruitPiece("fruit-drop") { Scale = new Vector2(0.8f) }; + + break; + + case CatchSkinComponents.CatcherIdle: + return this.GetAnimation("fruit-catcher-idle", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); + + case CatchSkinComponents.CatcherFail: + return this.GetAnimation("fruit-catcher-fail", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); + + case CatchSkinComponents.CatcherKiai: + return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); + } + + return null; + } + + public override IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case CatchSkinColour colour: + return Source.GetConfig(new SkinCustomColourLookup(colour)); + } + + return Source.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs new file mode 100644 index 0000000000..4506111498 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.Skinning +{ + public enum CatchSkinColour + { + /// + /// The colour to be used for the catcher while in hyper-dashing state. + /// + HyperDash, + + /// + /// The colour to be used for fruits that grant the catcher the ability to hyper-dash. + /// + HyperDashFruit, + + /// + /// The colour to be used for the "exploding" catcher sprite on beginning of hyper-dashing. + /// + HyperDashAfterImage, + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs new file mode 100644 index 0000000000..5be54d3882 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.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.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + internal class LegacyFruitPiece : CompositeDrawable + { + private readonly string lookupName; + + private readonly IBindable accentColour = new Bindable(); + private Sprite colouredSprite; + + public LegacyFruitPiece(string lookupName) + { + this.lookupName = lookupName; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject, ISkinSource skin) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + + accentColour.BindTo(drawableCatchObject.AccentColour); + + InternalChildren = new Drawable[] + { + colouredSprite = new Sprite + { + Texture = skin.GetTexture(lookupName), + Colour = drawableObject.AccentColour.Value, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Sprite + { + Texture = skin.GetTexture($"{lookupName}-overlay"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + + if (drawableCatchObject.HitObject.HyperDash) + { + var hyperDash = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Depth = 1, + Alpha = 0.7f, + Scale = new Vector2(1.2f), + Texture = skin.GetTexture(lookupName), + Colour = skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ?? + skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? + Catcher.DEFAULT_HYPER_DASH_COLOUR, + }; + + AddInternal(hyperDash); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + accentColour.BindValueChanged(colour => colouredSprite.Colour = colour.NewValue, true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 589503c35b..154e1576db 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; @@ -16,30 +16,44 @@ namespace osu.Game.Rulesets.Catch.UI { public class CatchPlayfield : ScrollingPlayfield { - public const float BASE_WIDTH = 512; + /// + /// The width of the playfield. + /// The horizontal movement of the catcher is confined in the area of this width. + /// + public const float WIDTH = 512; + + /// + /// The center position of the playfield. + /// + public const float CENTER_X = WIDTH / 2; internal readonly CatcherArea CatcherArea; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || CatcherArea.ReceivePositionalInputAt(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + // only check the X position; handle all vertical space. + base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y)); public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation) { - Container explodingFruitContainer; - - InternalChildren = new Drawable[] + var explodingFruitContainer = new Container { - explodingFruitContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }, - CatcherArea = new CatcherArea(difficulty) - { - CreateDrawableRepresentation = createDrawableRepresentation, - ExplodingFruitTarget = explodingFruitContainer, - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - }, - HitObjectContainer + RelativeSizeAxes = Axes.Both, + }; + + CatcherArea = new CatcherArea(difficulty) + { + CreateDrawableRepresentation = createDrawableRepresentation, + ExplodingFruitTarget = explodingFruitContainer, + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + }; + + InternalChildren = new[] + { + explodingFruitContainer, + CatcherArea.MovableCatcher.CreateProxiedContent(), + HitObjectContainer, + CatcherArea }; } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs index b8d3dc9017..8ee23461ba 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.UI { base.Update(); - Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.BASE_WIDTH); + Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH); Size = Vector2.Divide(Vector2.One, Scale); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs new file mode 100644 index 0000000000..9a4d1f9585 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Catch.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatchReplayRecorder : ReplayRecorder + { + private readonly CatchPlayfield playfield; + + public CatchReplayRecorder(Replay target, CatchPlayfield playfield) + : base(target) + { + this.playfield = playfield; + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new CatchReplayFrame(Time.Current, playfield.CatcherArea.MovableCatcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); + } +} diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs new file mode 100644 index 0000000000..8820dff730 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -0,0 +1,490 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +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.Input.Bindings; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class Catcher : SkinReloadableDrawable, IKeyBindingHandler + { + /// + /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail + /// and end glow/after-image during a hyper-dash. + /// + public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; + + /// + /// The duration between transitioning to hyper-dash state. + /// + public const double HYPER_DASH_TRANSITION_DURATION = 180; + + /// + /// Whether we are hyper-dashing or not. + /// + public bool HyperDashing => hyperDashModifier != 1; + + /// + /// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable. + /// + public const double BASE_SPEED = 1.0; + + public Container ExplodingFruitTarget; + + private Container caughtFruitContainer { get; } = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }; + + [NotNull] + private readonly Container trailsTarget; + + private CatcherTrailDisplay trails; + + public CatcherAnimationState CurrentState { get; private set; } + + /// + /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// + private const float allowed_catch_range = 0.8f; + + /// + /// The drawable catcher for . + /// + internal Drawable CurrentDrawableCatcher => currentCatcher.Drawable; + + private bool dashing; + + public bool Dashing + { + get => dashing; + protected set + { + if (value == dashing) return; + + dashing = value; + + updateTrailVisibility(); + } + } + + /// + /// Width of the area that can be used to attempt catches during gameplay. + /// + private readonly float catchWidth; + + private CatcherSprite catcherIdle; + private CatcherSprite catcherKiai; + private CatcherSprite catcherFail; + + private CatcherSprite currentCatcher; + + private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR; + private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR; + + private int currentDirection; + + private double hyperDashModifier = 1; + private int hyperDashDirection; + private float hyperDashTargetPosition; + private Bindable hitLighting; + + public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + { + this.trailsTarget = trailsTarget; + + Origin = Anchor.TopCentre; + + Size = new Vector2(CatcherArea.CATCHER_SIZE); + if (difficulty != null) + Scale = calculateScale(difficulty); + + catchWidth = CalculateCatchWidth(Scale); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitLighting = config.GetBindable(OsuSetting.HitLighting); + + InternalChildren = new Drawable[] + { + caughtFruitContainer, + catcherIdle = new CatcherSprite(CatcherAnimationState.Idle) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + }, + catcherKiai = new CatcherSprite(CatcherAnimationState.Kiai) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + }, + catcherFail = new CatcherSprite(CatcherAnimationState.Fail) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + } + }; + + trailsTarget.Add(trails = new CatcherTrailDisplay(this)); + + updateCatcher(); + } + + /// + /// Creates proxied content to be displayed beneath hitobjects. + /// + public Drawable CreateProxiedContent() => caughtFruitContainer.CreateProxy(); + + /// + /// Calculates the scale of the catcher based off the provided beatmap difficulty. + /// + private static Vector2 calculateScale(BeatmapDifficulty difficulty) + => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + + /// + /// Calculates the width of the area used for attempting catches in gameplay. + /// + /// The scale of the catcher. + internal static float CalculateCatchWidth(Vector2 scale) + => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * allowed_catch_range; + + /// + /// Calculates the width of the area used for attempting catches in gameplay. + /// + /// The beatmap difficulty. + internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) + => CalculateCatchWidth(calculateScale(difficulty)); + + /// + /// Add a caught fruit to the catcher's stack. + /// + /// The fruit that was caught. + public void PlaceOnPlate(DrawableCatchHitObject fruit) + { + var ourRadius = fruit.DisplayRadius; + float theirRadius = 0; + + const float allowance = 10; + + while (caughtFruitContainer.Any(f => + f.LifetimeEnd == double.MaxValue && + Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) + { + var diff = (ourRadius + theirRadius) / allowance; + fruit.X += (RNG.NextSingle() - 0.5f) * diff * 2; + fruit.Y -= RNG.NextSingle() * diff; + } + + fruit.X = Math.Clamp(fruit.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); + + caughtFruitContainer.Add(fruit); + + if (hitLighting.Value) + { + AddInternal(new HitExplosion(fruit) + { + X = fruit.X, + Scale = new Vector2(fruit.HitObject.Scale) + }); + } + } + + /// + /// Let the catcher attempt to catch a fruit. + /// + /// The fruit to catch. + /// Whether the catch is possible. + public bool AttemptCatch(CatchHitObject fruit) + { + var halfCatchWidth = catchWidth * 0.5f; + + // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. + var catchObjectPosition = fruit.X; + var catcherPosition = Position.X; + + var validCatch = + catchObjectPosition >= catcherPosition - halfCatchWidth && + catchObjectPosition <= catcherPosition + halfCatchWidth; + + // only update hyperdash state if we are catching a fruit. + // exceptions are Droplets and JuiceStreams. + if (!(fruit is Fruit)) return validCatch; + + if (validCatch && fruit.HyperDash) + { + var target = fruit.HyperDashTarget; + var timeDifference = target.StartTime - fruit.StartTime; + double positionDifference = target.X - catcherPosition; + var velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); + + SetHyperDashState(Math.Abs(velocity), target.X); + } + else + SetHyperDashState(); + + if (validCatch) + updateState(fruit.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle); + else if (!(fruit is Banana)) + updateState(CatcherAnimationState.Fail); + + return validCatch; + } + + /// + /// Set hyper-dash state. + /// + /// The speed multiplier. If this is less or equals to 1, this catcher will be non-hyper-dashing state. + /// When this catcher crosses this position, this catcher ends hyper-dashing. + public void SetHyperDashState(double modifier = 1, float targetPosition = -1) + { + var wasHyperDashing = HyperDashing; + + if (modifier <= 1 || X == targetPosition) + { + hyperDashModifier = 1; + hyperDashDirection = 0; + + if (wasHyperDashing) + runHyperDashStateTransition(false); + } + else + { + hyperDashModifier = modifier; + hyperDashDirection = Math.Sign(targetPosition - X); + hyperDashTargetPosition = targetPosition; + + if (!wasHyperDashing) + { + trails.DisplayEndGlow(); + runHyperDashStateTransition(true); + } + } + } + + private void runHyperDashStateTransition(bool hyperDashing) + { + trails.HyperDashTrailsColour = hyperDashColour; + trails.EndGlowSpritesColour = hyperDashEndGlowColour; + updateTrailVisibility(); + + if (hyperDashing) + { + this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + else + { + this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + } + + private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; + + public bool OnPressed(CatchAction action) + { + switch (action) + { + case CatchAction.MoveLeft: + currentDirection--; + return true; + + case CatchAction.MoveRight: + currentDirection++; + return true; + + case CatchAction.Dash: + Dashing = true; + return true; + } + + return false; + } + + public void OnReleased(CatchAction action) + { + switch (action) + { + case CatchAction.MoveLeft: + currentDirection++; + break; + + case CatchAction.MoveRight: + currentDirection--; + break; + + case CatchAction.Dash: + Dashing = false; + break; + } + } + + public void UpdatePosition(float position) + { + position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); + + if (position == X) + return; + + Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); + X = position; + } + + /// + /// Drop any fruit off the plate. + /// + public void Drop() + { + foreach (var f in caughtFruitContainer.ToArray()) + Drop(f); + } + + /// + /// Explode any fruit off the plate. + /// + public void Explode() + { + foreach (var f in caughtFruitContainer.ToArray()) + Explode(f); + } + + public void Drop(DrawableHitObject fruit) + { + removeFromPlateWithTransform(fruit, f => + { + f.MoveToY(f.Y + 75, 750, Easing.InSine); + f.FadeOut(750); + }); + } + + public void Explode(DrawableHitObject fruit) + { + var originalX = fruit.X * Scale.X; + + removeFromPlateWithTransform(fruit, f => + { + f.MoveToY(f.Y - 50, 250, Easing.OutSine).Then().MoveToY(f.Y + 50, 500, Easing.InSine); + f.MoveToX(f.X + originalX * 6, 1000); + f.FadeOut(750); + }); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + hyperDashColour = + skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? + DEFAULT_HYPER_DASH_COLOUR; + + hyperDashEndGlowColour = + skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? + hyperDashColour; + + runHyperDashStateTransition(HyperDashing); + } + + protected override void Update() + { + base.Update(); + + if (currentDirection == 0) return; + + var direction = Math.Sign(currentDirection); + + var dashModifier = Dashing ? 1 : 0.5; + var speed = BASE_SPEED * dashModifier * hyperDashModifier; + + UpdatePosition((float)(X + direction * Clock.ElapsedFrameTime * speed)); + + // Correct overshooting. + if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || + (hyperDashDirection < 0 && hyperDashTargetPosition > X)) + { + X = hyperDashTargetPosition; + SetHyperDashState(); + } + } + + private void updateCatcher() + { + currentCatcher?.Hide(); + + switch (CurrentState) + { + default: + currentCatcher = catcherIdle; + break; + + case CatcherAnimationState.Fail: + currentCatcher = catcherFail; + break; + + case CatcherAnimationState.Kiai: + currentCatcher = catcherKiai; + break; + } + + currentCatcher.Show(); + (currentCatcher.Drawable as IFramedAnimation)?.GotoFrame(0); + } + + private void updateState(CatcherAnimationState state) + { + if (CurrentState == state) + return; + + CurrentState = state; + updateCatcher(); + } + + private void removeFromPlateWithTransform(DrawableHitObject fruit, Action action) + { + if (ExplodingFruitTarget != null) + { + fruit.Anchor = Anchor.TopLeft; + fruit.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); + + if (!caughtFruitContainer.Remove(fruit)) + // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). + // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. + return; + + ExplodingFruitTarget.Add(fruit); + } + + var actionTime = Clock.CurrentTime; + + fruit.ApplyCustomUpdateState += onFruitOnApplyCustomUpdateState; + onFruitOnApplyCustomUpdateState(fruit, fruit.State.Value); + + void onFruitOnApplyCustomUpdateState(DrawableHitObject o, ArmedState state) + { + using (fruit.BeginAbsoluteSequence(actionTime)) + action(fruit); + + fruit.Expire(); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.cs b/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.cs new file mode 100644 index 0000000000..566e9d1911 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.UI +{ + public enum CatcherAnimationState + { + Idle, + Fail, + Kiai + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 2d6ce02e45..4255c3b1af 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -2,22 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Framework.MathUtils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { @@ -25,29 +20,28 @@ namespace osu.Game.Rulesets.Catch.UI { public const float CATCHER_SIZE = 106.75f; - protected internal readonly Catcher MovableCatcher; - public Func> CreateDrawableRepresentation; + public readonly Catcher MovableCatcher; + public Container ExplodingFruitTarget { set => MovableCatcher.ExplodingFruitTarget = value; } + private DrawableCatchHitObject lastPlateableFruit; + public CatcherArea(BeatmapDifficulty difficulty = null) { - RelativeSizeAxes = Axes.X; - Height = CATCHER_SIZE; - Child = MovableCatcher = new Catcher(difficulty) - { - AdditiveTarget = this, - }; + Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); + Child = MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }; } - private DrawableCatchHitObject lastPlateableFruit; - public void OnResult(DrawableCatchHitObject fruit, JudgementResult result) { + if (result.Judgement is IgnoreJudgement) + return; + void runAfterLoaded(Action action) { if (lastPlateableFruit == null) @@ -63,6 +57,7 @@ namespace osu.Game.Rulesets.Catch.UI if (result.IsHit && fruit.CanBePlated) { + // create a new (cloned) fruit to stay on the plate. the original is faded out immediately. var caughtFruit = (DrawableCatchHitObject)CreateDrawableRepresentation?.Invoke(fruit.HitObject); if (caughtFruit == null) return; @@ -73,11 +68,11 @@ namespace osu.Game.Rulesets.Catch.UI caughtFruit.Anchor = Anchor.TopCentre; caughtFruit.Origin = Anchor.Centre; - caughtFruit.Scale *= 0.7f; + caughtFruit.Scale *= 0.5f; caughtFruit.LifetimeStart = caughtFruit.HitObject.StartTime; caughtFruit.LifetimeEnd = double.MaxValue; - MovableCatcher.Add(caughtFruit); + MovableCatcher.PlaceOnPlate(caughtFruit); lastPlateableFruit = caughtFruit; if (!fruit.StaysOnPlate) @@ -86,13 +81,22 @@ namespace osu.Game.Rulesets.Catch.UI if (fruit.HitObject.LastInCombo) { - if (((CatchJudgement)result.Judgement).ShouldExplodeFor(result)) + if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) runAfterLoaded(() => MovableCatcher.Explode()); else MovableCatcher.Drop(); } } + public void OnReleased(CatchAction action) + { + } + + public bool AttemptCatch(CatchHitObject obj) + { + return MovableCatcher.AttemptCatch(obj); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -102,366 +106,5 @@ namespace osu.Game.Rulesets.Catch.UI if (state?.CatcherX != null) MovableCatcher.X = state.CatcherX.Value; } - - public bool OnReleased(CatchAction action) => false; - - public bool AttemptCatch(CatchHitObject obj) => MovableCatcher.AttemptCatch(obj); - - public static float GetCatcherSize(BeatmapDifficulty difficulty) - { - return CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); - } - - public class Catcher : Container, IKeyBindingHandler - { - /// - /// Width of the area that can be used to attempt catches during gameplay. - /// - internal float CatchWidth => CATCHER_SIZE * Math.Abs(Scale.X); - - private Container caughtFruit; - - public Container ExplodingFruitTarget; - - public Container AdditiveTarget; - - public Catcher(BeatmapDifficulty difficulty = null) - { - RelativePositionAxes = Axes.X; - X = 0.5f; - - Origin = Anchor.TopCentre; - Anchor = Anchor.TopLeft; - - Size = new Vector2(CATCHER_SIZE); - if (difficulty != null) - Scale = new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); - } - - [BackgroundDependencyLoader] - private void load() - { - Children = new[] - { - caughtFruit = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }, - createCatcherSprite(), - }; - } - - private int currentDirection; - - private bool dashing; - - protected bool Dashing - { - get => dashing; - set - { - if (value == dashing) return; - - dashing = value; - - Trail |= dashing; - } - } - - private bool trail; - - /// - /// Activate or deactive the trail. Will be automatically deactivated when conditions to keep the trail displayed are no longer met. - /// - protected bool Trail - { - get => trail; - set - { - if (value == trail) return; - - trail = value; - - if (Trail) - beginTrail(); - } - } - - private void beginTrail() - { - Trail &= dashing || HyperDashing; - Trail &= AdditiveTarget != null; - - if (!Trail) return; - - var additive = createCatcherSprite(); - - additive.Anchor = Anchor; - additive.OriginPosition += new Vector2(DrawWidth / 2, 0); // also temporary to align sprite correctly. - additive.Position = Position; - additive.Scale = Scale; - additive.Colour = HyperDashing ? Color4.Red : Color4.White; - additive.RelativePositionAxes = RelativePositionAxes; - additive.Blending = BlendingParameters.Additive; - - AdditiveTarget.Add(additive); - - additive.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); - additive.Expire(true); - - Scheduler.AddDelayed(beginTrail, HyperDashing ? 25 : 50); - } - - private Drawable createCatcherSprite() => new CatcherSprite(); - - /// - /// Add a caught fruit to the catcher's stack. - /// - /// The fruit that was caught. - public void Add(DrawableHitObject fruit) - { - float ourRadius = fruit.DrawSize.X / 2 * fruit.Scale.X; - float theirRadius = 0; - - const float allowance = 6; - - while (caughtFruit.Any(f => - f.LifetimeEnd == double.MaxValue && - Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) - { - float diff = (ourRadius + theirRadius) / allowance; - fruit.X += (RNG.NextSingle() - 0.5f) * 2 * diff; - fruit.Y -= RNG.NextSingle() * diff; - } - - fruit.X = Math.Clamp(fruit.X, -CATCHER_SIZE / 2, CATCHER_SIZE / 2); - - caughtFruit.Add(fruit); - } - - /// - /// Let the catcher attempt to catch a fruit. - /// - /// The fruit to catch. - /// Whether the catch is possible. - public bool AttemptCatch(CatchHitObject fruit) - { - float halfCatchWidth = CatchWidth * 0.5f; - - // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. - var catchObjectPosition = fruit.X * CatchPlayfield.BASE_WIDTH; - var catcherPosition = Position.X * CatchPlayfield.BASE_WIDTH; - - var validCatch = - catchObjectPosition >= catcherPosition - halfCatchWidth && - catchObjectPosition <= catcherPosition + halfCatchWidth; - - if (validCatch && fruit.HyperDash) - { - var target = fruit.HyperDashTarget; - double timeDifference = target.StartTime - fruit.StartTime; - double positionDifference = target.X * CatchPlayfield.BASE_WIDTH - catcherPosition; - double velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); - - SetHyperDashState(Math.Abs(velocity), target.X); - } - else - { - SetHyperDashState(); - } - - return validCatch; - } - - private double hyperDashModifier = 1; - private int hyperDashDirection; - private float hyperDashTargetPosition; - - /// - /// Whether we are hyper-dashing or not. - /// - public bool HyperDashing => hyperDashModifier != 1; - - /// - /// Set hyper-dash state. - /// - /// The speed multiplier. If this is less or equals to 1, this catcher will be non-hyper-dashing state. - /// When this catcher crosses this position, this catcher ends hyper-dashing. - public void SetHyperDashState(double modifier = 1, float targetPosition = -1) - { - const float hyper_dash_transition_length = 180; - - bool previouslyHyperDashing = HyperDashing; - - if (modifier <= 1 || X == targetPosition) - { - hyperDashModifier = 1; - hyperDashDirection = 0; - - if (previouslyHyperDashing) - { - this.FadeColour(Color4.White, hyper_dash_transition_length, Easing.OutQuint); - this.FadeTo(1, hyper_dash_transition_length, Easing.OutQuint); - Trail &= Dashing; - } - } - else - { - hyperDashModifier = modifier; - hyperDashDirection = Math.Sign(targetPosition - X); - hyperDashTargetPosition = targetPosition; - - if (!previouslyHyperDashing) - { - this.FadeColour(Color4.OrangeRed, hyper_dash_transition_length, Easing.OutQuint); - this.FadeTo(0.2f, hyper_dash_transition_length, Easing.OutQuint); - Trail = true; - } - } - } - - public bool OnPressed(CatchAction action) - { - switch (action) - { - case CatchAction.MoveLeft: - currentDirection--; - return true; - - case CatchAction.MoveRight: - currentDirection++; - return true; - - case CatchAction.Dash: - Dashing = true; - return true; - } - - return false; - } - - public bool OnReleased(CatchAction action) - { - switch (action) - { - case CatchAction.MoveLeft: - currentDirection++; - return true; - - case CatchAction.MoveRight: - currentDirection--; - return true; - - case CatchAction.Dash: - Dashing = false; - return true; - } - - return false; - } - - /// - /// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable. - /// - public const double BASE_SPEED = 1.0 / 512; - - protected override void Update() - { - base.Update(); - - if (currentDirection == 0) return; - - var direction = Math.Sign(currentDirection); - - double dashModifier = Dashing ? 1 : 0.5; - double speed = BASE_SPEED * dashModifier * hyperDashModifier; - - UpdatePosition((float)(X + direction * Clock.ElapsedFrameTime * speed)); - - // Correct overshooting. - if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || - (hyperDashDirection < 0 && hyperDashTargetPosition > X)) - { - X = hyperDashTargetPosition; - SetHyperDashState(); - } - } - - /// - /// Drop any fruit off the plate. - /// - public void Drop() - { - var fruit = caughtFruit.ToArray(); - - foreach (var f in fruit) - { - if (ExplodingFruitTarget != null) - { - f.Anchor = Anchor.TopLeft; - f.Position = caughtFruit.ToSpaceOfOtherDrawable(f.DrawPosition, ExplodingFruitTarget); - - caughtFruit.Remove(f); - - ExplodingFruitTarget.Add(f); - } - - f.MoveToY(f.Y + 75, 750, Easing.InSine); - f.FadeOut(750); - - // todo: this shouldn't exist once DrawableHitObject's ClearTransformsAfter overrides are repaired. - f.LifetimeStart = Time.Current; - f.Expire(); - } - } - - /// - /// Explode any fruit off the plate. - /// - public void Explode() - { - foreach (var f in caughtFruit.ToArray()) - Explode(f); - } - - public void Explode(DrawableHitObject fruit) - { - var originalX = fruit.X * Scale.X; - - if (ExplodingFruitTarget != null) - { - fruit.Anchor = Anchor.TopLeft; - fruit.Position = caughtFruit.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); - - if (!caughtFruit.Remove(fruit)) - // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). - // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. - return; - - ExplodingFruitTarget.Add(fruit); - } - - fruit.ClearTransforms(); - fruit.MoveToY(fruit.Y - 50, 250, Easing.OutSine).Then().MoveToY(fruit.Y + 50, 500, Easing.InSine); - fruit.MoveToX(fruit.X + originalX * 6, 1000); - fruit.FadeOut(750); - - // todo: this shouldn't exist once DrawableHitObject's ClearTransformsAfter overrides are repaired. - fruit.LifetimeStart = Time.Current; - fruit.Expire(); - } - - public void UpdatePosition(float position) - { - position = Math.Clamp(position, 0, 1); - - if (position == X) - return; - - Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); - X = position; - } - } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs index 025fa9c56e..ef69e3d2d1 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs @@ -3,31 +3,57 @@ 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.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatcherSprite : CompositeDrawable + public class CatcherSprite : SkinnableDrawable { - public CatcherSprite() + protected override bool ApplySizeRestrictionsToDefault => true; + + public CatcherSprite(CatcherAnimationState state) + : base(new CatchSkinComponent(componentFromState(state)), _ => + new DefaultCatcherSprite(state), confineMode: ConfineMode.ScaleToFit) { + RelativeSizeAxes = Axes.None; Size = new Vector2(CatcherArea.CATCHER_SIZE); // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. - OriginPosition = new Vector2(-0.02f, 0.06f) * CatcherArea.CATCHER_SIZE; + OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE; } - [BackgroundDependencyLoader] - private void load() + private static CatchSkinComponents componentFromState(CatcherAnimationState state) { - InternalChild = new SkinnableSprite("Gameplay/catch/fruit-catcher-idle", confineMode: ConfineMode.ScaleDownToFit) + switch (state) { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; + case CatcherAnimationState.Fail: + return CatchSkinComponents.CatcherFail; + + case CatcherAnimationState.Kiai: + return CatchSkinComponents.CatcherKiai; + + default: + return CatchSkinComponents.CatcherIdle; + } + } + + private class DefaultCatcherSprite : Sprite + { + private readonly CatcherAnimationState state; + + public DefaultCatcherSprite(CatcherAnimationState state) + { + this.state = state; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get($"Gameplay/catch/fruit-catcher-{state.ToString().ToLower()}"); + } } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs new file mode 100644 index 0000000000..bab3cb748b --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// Represents a component responsible for displaying + /// the appropriate catcher trails when requested to. + /// + public class CatcherTrailDisplay : CompositeDrawable + { + private readonly Catcher catcher; + + private readonly Container dashTrails; + private readonly Container hyperDashTrails; + private readonly Container endGlowSprites; + + private Color4 hyperDashTrailsColour; + + public Color4 HyperDashTrailsColour + { + get => hyperDashTrailsColour; + set + { + if (hyperDashTrailsColour == value) + return; + + hyperDashTrailsColour = value; + hyperDashTrails.FadeColour(hyperDashTrailsColour, Catcher.HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + } + + private Color4 endGlowSpritesColour; + + public Color4 EndGlowSpritesColour + { + get => endGlowSpritesColour; + set + { + if (endGlowSpritesColour == value) + return; + + endGlowSpritesColour = value; + endGlowSprites.FadeColour(endGlowSpritesColour, Catcher.HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + } + + private bool trail; + + /// + /// Whether to start displaying trails following the catcher. + /// + public bool DisplayTrail + { + get => trail; + set + { + if (trail == value) + return; + + trail = value; + + if (trail) + displayTrail(); + } + } + + public CatcherTrailDisplay([NotNull] Catcher catcher) + { + this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher)); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new[] + { + dashTrails = new Container { RelativeSizeAxes = Axes.Both }, + hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, + endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, + }; + } + + /// + /// Displays a single end-glow catcher sprite. + /// + public void DisplayEndGlow() + { + var endGlow = createTrailSprite(endGlowSprites); + + endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); + endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In); + endGlow.FadeOut(1200); + endGlow.Expire(true); + } + + private void displayTrail() + { + if (!DisplayTrail) + return; + + var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails); + + sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); + sprite.Expire(true); + + Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50); + } + + private CatcherTrailSprite createTrailSprite(Container target) + { + var texture = (catcher.CurrentDrawableCatcher as TextureAnimation)?.CurrentFrame ?? ((Sprite)catcher.CurrentDrawableCatcher).Texture; + + var sprite = new CatcherTrailSprite(texture) + { + Anchor = catcher.Anchor, + Scale = catcher.Scale, + Blending = BlendingParameters.Additive, + RelativePositionAxes = catcher.RelativePositionAxes, + Position = catcher.Position + }; + + target.Add(sprite); + + return sprite; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs new file mode 100644 index 0000000000..56cb7dbfda --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatcherTrailSprite : Sprite + { + public CatcherTrailSprite(Texture texture) + { + Texture = texture; + + Size = new Vector2(CatcherArea.CATCHER_SIZE); + + // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. + OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index fdd820b891..ebe45aa3ab 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -8,7 +8,7 @@ using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -32,6 +32,8 @@ namespace osu.Game.Rulesets.Catch.UI protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new CatchReplayRecorder(replay, (CatchPlayfield)Playfield); + protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs similarity index 83% rename from osu.Game.Rulesets.Mania/UI/HitExplosion.cs rename to osu.Game.Rulesets.Catch/UI/HitExplosion.cs index ccbff226a9..04a86f83be 100644 --- a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -5,34 +5,30 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Framework.MathUtils; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.UI +namespace osu.Game.Rulesets.Catch.UI { - internal class HitExplosion : CompositeDrawable + public class HitExplosion : CompositeDrawable { - public override bool RemoveWhenNotAlive => true; - private readonly CircularContainer largeFaint; - private readonly CircularContainer mainGlow1; - public HitExplosion(Color4 objectColour, bool isSmall = false) + public HitExplosion(DrawableCatchHitObject fruit) { - RelativeSizeAxes = Axes.X; - Height = NotePiece.NOTE_HEIGHT; + Size = new Vector2(20); + Anchor = Anchor.TopCentre; + Origin = Anchor.BottomCentre; + + Color4 objectColour = fruit.AccentColour.Value; // scale roughly in-line with visual appearance of notes - Scale = new Vector2(1f, 0.6f); - - if (isSmall) - Scale *= 0.5f; const float angle_variangle = 15; // should be less than 45 - const float roundness = 80; + const float roundness = 100; const float initial_height = 10; @@ -57,7 +53,7 @@ namespace osu.Game.Rulesets.Mania.UI Radius = 200, }, }, - mainGlow1 = new CircularContainer + new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -111,17 +107,15 @@ namespace osu.Game.Rulesets.Mania.UI protected override void LoadComplete() { - const double duration = 200; - base.LoadComplete(); + const double duration = 400; + largeFaint .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) .FadeOut(duration * 2); - mainGlow1.ScaleTo(1.4f, duration, Easing.OutQuint); - - this.FadeOut(duration, Easing.Out); + this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out); Expire(true); } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs index 12865385b6..d0ff1fab43 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs new file mode 100644 index 0000000000..c8feb4ae24 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaBeatmapSampleConversionTest : BeatmapConversionTest, SampleConvertValue> + { + protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; + + [TestCase("convert-samples")] + [TestCase("mania-samples")] + [TestCase("slider-convert-samples")] + public void Test(string name) => base.Test(name); + + protected override IEnumerable CreateConvertValue(HitObject hitObject) + { + yield return new SampleConvertValue + { + StartTime = hitObject.StartTime, + EndTime = hitObject.GetEndTime(), + Column = ((ManiaHitObject)hitObject).Column, + Samples = getSampleNames(hitObject.Samples), + NodeSamples = getNodeSampleNames((hitObject as HoldNote)?.NodeSamples) + }; + } + + private IList getSampleNames(IList hitSampleInfo) + => hitSampleInfo.Select(sample => sample.LookupNames.First()).ToList(); + + private IList> getNodeSampleNames(List> hitSampleInfo) + => hitSampleInfo?.Select(getSampleNames) + .ToList(); + + protected override Ruleset CreateRuleset() => new ManiaRuleset(); + } + + public struct SampleConvertValue : IEquatable + { + /// + /// A sane value to account for osu!stable using ints everywhere. + /// + private const float conversion_lenience = 2; + + public double StartTime; + public double EndTime; + public int Column; + public IList Samples; + public IList> NodeSamples; + + public bool Equals(SampleConvertValue other) + => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) + && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) + && samplesEqual(Samples, other.Samples) + && nodeSamplesEqual(NodeSamples, other.NodeSamples); + + private static bool samplesEqual(ICollection firstSampleList, ICollection secondSampleList) + => firstSampleList.SequenceEqual(secondSampleList); + + private static bool nodeSamplesEqual(ICollection> firstSampleList, ICollection> secondSampleList) + { + if (firstSampleList == null && secondSampleList == null) + return true; + + // both items can't be null now, so if any single one is, then they're not equal + if (firstSampleList == null || secondSampleList == null) + return false; + + return firstSampleList.Count == secondSampleList.Count + // cannot use .Zip() without the selector function as it doesn't compile in android test project + && firstSampleList.Zip(secondSampleList, (first, second) => (first, second)) + .All(samples => samples.first.SequenceEqual(samples.second)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs new file mode 100644 index 0000000000..40a6e1fdae --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.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 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 (var i = 0; i < definition.Columns; i++) + yield return definition.GetTypeOfColumn(i); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs index 909d0d45c6..9049bb3a82 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Tests { } - protected override RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new LocalKeyBindingContainer(ruleset, variant, unique); private class LocalKeyBindingContainer : RulesetKeyBindingContainer diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs new file mode 100644 index 0000000000..40bb83aece --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.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.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Replays; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaLegacyReplayTest + { + [TestCase(ManiaAction.Key1)] + [TestCase(ManiaAction.Key1, ManiaAction.Key2)] + [TestCase(ManiaAction.Special1)] + [TestCase(ManiaAction.Key8)] + public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) + { + var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 }); + + var frame = new ManiaReplayFrame(0, actions); + var legacyFrame = frame.ToLegacy(beatmap); + + var decodedFrame = new ManiaReplayFrame(); + decodedFrame.FromLegacy(legacyFrame, beatmap); + + Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions)); + } + + [TestCase(ManiaAction.Key1)] + [TestCase(ManiaAction.Key1, ManiaAction.Key2)] + [TestCase(ManiaAction.Special1)] + [TestCase(ManiaAction.Special2)] + [TestCase(ManiaAction.Special1, ManiaAction.Special2)] + [TestCase(ManiaAction.Special1, ManiaAction.Key5)] + [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 frame = new ManiaReplayFrame(0, actions); + var legacyFrame = frame.ToLegacy(beatmap); + + var decodedFrame = new ManiaReplayFrame(); + decodedFrame.FromLegacy(legacyFrame, beatmap); + + Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs index afde1c9521..0fe4a3c669 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs @@ -7,20 +7,18 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; -using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene, IManiaHitObjectComposer + public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene { private readonly Column column; @@ -41,16 +39,20 @@ namespace osu.Game.Rulesets.Mania.Tests AccentColour = Color4.OrangeRed, Clock = new FramedClock(new StopwatchClock()), // No scroll }); + } - AddStep("change direction", () => ((ScrollingTestContainer)HitObjectContainer).Flip()); + protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint) + { + var time = column.TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position); + var pos = column.ScreenSpacePositionAtTime(time); + + return new SnapResult(pos, time, column); } protected override Container CreateHitObjectContainer() => new ScrollingTestContainer(ScrollingDirection.Down) { RelativeSizeAxes = Axes.Both }; protected override void AddHitObject(DrawableHitObject hitObject) => column.Add((DrawableManiaHitObject)hitObject); - public Column ColumnAt(Vector2 screenSpacePosition) => column; - - public int TotalColumns => 1; + public ManiaPlayfield Playfield => null; } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs index b598893e8c..149f6582ab 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs @@ -4,25 +4,20 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Timing; -using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.UI; using osu.Game.Tests.Visual; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene, IManiaHitObjectComposer + public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene { [Cached(Type = typeof(IAdjustableClock))] private readonly IAdjustableClock clock = new StopwatchClock(); - private readonly Column column; - protected ManiaSelectionBlueprintTestScene() { - Add(column = new Column(0) + Add(new Column(0) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -31,8 +26,6 @@ namespace osu.Game.Rulesets.Mania.Tests }); } - public Column ColumnAt(Vector2 screenSpacePosition) => column; - - public int TotalColumns => 1; + public ManiaPlayfield Playfield => null; } } diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs new file mode 100644 index 0000000000..2e3b21aed7 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.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 NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + public TestSceneManiaModPerfect() + : base(new ManiaModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestNote(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Note { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestHoldNote(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new HoldNote { StartTime = 1000, EndTime = 3000 }), shouldMiss); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..4f8e1b68dd --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 3 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,2,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu new file mode 100644 index 0000000000..f22901e304 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 3 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,1,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png new file mode 100644 index 0000000000..aa681f6f22 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png new file mode 100644 index 0000000000..ca590eaf08 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png new file mode 100644 index 0000000000..aa681f6f22 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png new file mode 100644 index 0000000000..2e7b9bc34f Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png new file mode 100644 index 0000000000..27ca7f8b42 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png new file mode 100644 index 0000000000..24ad926375 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png new file mode 100644 index 0000000000..098561f980 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png new file mode 100644 index 0000000000..7e6501d1be Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png new file mode 100644 index 0000000000..f17b2b1e73 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png new file mode 100644 index 0000000000..1afec2f4a9 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png new file mode 100644 index 0000000000..03ca371c4e Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png new file mode 100644 index 0000000000..45b7be0255 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.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 new file mode 100644 index 0000000000..36765d61bf --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini @@ -0,0 +1,14 @@ +[General] +Version: 2.5 + +[Mania] +Keys: 4 +ColumnLineWidth: 3,1,3,1,1 +Hit0: mania/hit0 +Hit50: mania/hit50 +Hit100: mania/hit100 +Hit200: mania/hit200 +Hit300: mania/hit300 +Hit300g: mania/hit300g +StageLeft: mania/stage-left +StageRight: mania/stage-right \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/SkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/SkinnableTestScene.cs deleted file mode 100644 index 80b1b3df8e..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/SkinnableTestScene.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 osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Skinning; -using osu.Game.Tests.Visual; - -namespace osu.Game.Rulesets.Mania.Tests -{ - public abstract class SkinnableTestScene : OsuGridTestScene - { - private Skin defaultSkin; - - protected SkinnableTestScene() - : base(1, 2) - { - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio, SkinManager skinManager) - { - defaultSkin = skinManager.GetSkin(DefaultLegacySkin.Info); - } - - public void SetContents(Func creationFunction) - { - Cell(0).Child = createProvider(null, creationFunction); - Cell(1).Child = createProvider(defaultSkin, creationFunction); - } - - private Drawable createProvider(Skin skin, Func creationFunction) - { - var mainProvider = new SkinProvidingContainer(skin); - - return mainProvider - .WithChild(new SkinProvidingContainer(Ruleset.Value.CreateInstance().CreateLegacySkinProvider(mainProvider)) - { - Child = creationFunction() - }); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs new file mode 100644 index 0000000000..ff4865c71d --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +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 + { + protected override Container Content => content; + + private readonly Container content; + + [Cached] + private readonly Column column; + + public ColumnTestContainer(int column, ManiaAction action) + { + this.column = new Column(column) + { + Action = { Value = action }, + AccentColour = Color4.Orange, + ColumnType = column % 2 == 0 ? ColumnType.Even : ColumnType.Odd + }; + + InternalChild = content = new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) + { + RelativeSizeAxes = Axes.Both + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs new file mode 100644 index 0000000000..18eebada00 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + /// + /// A test scene for a mania hitobject. + /// + public abstract class ManiaHitObjectTestScene : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Height = 0.7f, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ColumnTestContainer(0, ManiaAction.Key1) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 80, + Child = new ScrollingHitObjectContainer + { + RelativeSizeAxes = Axes.Both, + }.With(c => + { + c.Add(CreateHitObject().With(h => + { + h.HitObject.StartTime = START_TIME; + h.AccentColour.Value = Color4.Orange; + })); + }) + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 80, + Child = new ScrollingHitObjectContainer + { + RelativeSizeAxes = Axes.Both, + }.With(c => + { + c.Add(CreateHitObject().With(h => + { + h.HitObject.StartTime = START_TIME; + h.AccentColour.Value = Color4.Orange; + })); + }) + }, + } + }); + } + + protected abstract DrawableManiaHitObject CreateHitObject(); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs new file mode 100644 index 0000000000..1d84a2dfcb --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.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 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.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + /// + /// A test scene for skinnable mania components. + /// + public abstract class ManiaSkinnableTestScene : SkinnableTestScene + { + protected const double START_TIME = 1000000000; + + [Cached(Type = typeof(IScrollingInfo))] + private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); + + protected override Ruleset CreateRulesetForSkinProvider() => new ManiaRuleset(); + + protected ManiaSkinnableTestScene() + { + scrollingInfo.Direction.Value = ScrollingDirection.Down; + + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray.Opacity(0.2f), + Depth = 1 + }); + } + + [Test] + public void TestScrollingDown() + { + AddStep("change direction to down", () => scrollingInfo.Direction.Value = ScrollingDirection.Down); + } + + [Test] + public void TestScrollingUp() + { + AddStep("change direction to up", () => scrollingInfo.Direction.Value = ScrollingDirection.Up); + } + + private class TestScrollingInfo : IScrollingInfo + { + public readonly Bindable Direction = new Bindable(); + + IBindable IScrollingInfo.Direction => Direction; + IBindable IScrollingInfo.TimeRange { get; } = new Bindable(1000); + IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ZeroScrollAlgorithm(); + } + + private class ZeroScrollAlgorithm : IScrollAlgorithm + { + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + => double.MinValue; + + public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) + => scrollLength; + + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + => (float)((time - START_TIME) / timeRange) * scrollLength; + + public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) + => 0; + + public void Reset() + { + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs new file mode 100644 index 0000000000..bde323f187 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneColumnBackground : 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.ColumnBackground, 0), _ => new DefaultColumnBackground()) + { + RelativeSizeAxes = Axes.Both + } + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 1), _ => 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 new file mode 100644 index 0000000000..4392666cb7 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneColumnHitObjectArea : 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 ColumnHitObjectArea(0, new HitObjectContainer()) + { + RelativeSizeAxes = Axes.Both + } + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new ColumnHitObjectArea(1, new HitObjectContainer()) + { + RelativeSizeAxes = Axes.Both + } + } + } + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs new file mode 100644 index 0000000000..a4d4ec50f8 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneDrawableJudgement : ManiaSkinnableTestScene + { + public TestSceneDrawableJudgement() + { + var hitWindows = new ManiaHitWindows(); + + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) + { + if (hitWindows.IsHitResultAllowed(result)) + { + AddStep("Show " + result.GetDescription(), () => SetContents(() => + new DrawableManiaJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs new file mode 100644 index 0000000000..a692c0b697 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.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.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + [TestFixture] + public class TestSceneHitExplosion : ManiaSkinnableTestScene + { + public TestSceneHitExplosion() + { + int runcount = 0; + + AddRepeatStep("explode", () => + { + runcount++; + + if (runcount % 15 > 12) + return; + + CreatedDrawables.OfType().ForEach(c => + { + c.Add(new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, 0), + _ => new DefaultHitExplosion((runcount / 15) % 2 == 0 ? new Color4(94, 0, 57, 255) : new Color4(6, 84, 0, 255), runcount % 6 != 0) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + }); + }, 100); + } + + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new ColumnTestContainer(0, ManiaAction.Key1) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Y, + Y = -0.25f, + Size = new Vector2(Column.COLUMN_WIDTH, DefaultNotePiece.NOTE_HEIGHT), + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs new file mode 100644 index 0000000000..95e86de884 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.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 osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneHoldNote : ManiaHitObjectTestScene + { + public TestSceneHoldNote() + { + AddToggleStep("toggle hitting", v => + { + foreach (var holdNote in CreatedDrawables.SelectMany(d => d.ChildrenOfType())) + { + ((Bindable)holdNote.IsHitting).Value = v; + } + }); + } + + protected override DrawableManiaHitObject CreateHitObject() + { + var note = new HoldNote { Duration = 1000 }; + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return new DrawableHoldNote(note); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs new file mode 100644 index 0000000000..7e80419944 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.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, 0), _ => new DefaultKeyArea()) + { + RelativeSizeAxes = Axes.Both + }, + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 1), _ => 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 new file mode 100644 index 0000000000..bc3bdf0bcb --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneNote : ManiaHitObjectTestScene + { + protected override DrawableManiaHitObject CreateHitObject() + { + var note = new Note(); + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return new DrawableNote(note); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs new file mode 100644 index 0000000000..161eda650e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestScenePlayfield : ManiaSkinnableTestScene + { + private List stageDefinitions = new List(); + + [Test] + public void TestSingleStage() + { + AddStep("create stage", () => + { + stageDefinitions = new List + { + new StageDefinition { Columns = 2 } + }; + + SetContents(() => new ManiaPlayfield(stageDefinitions)); + }); + } + + [Test] + public void TestDualStages() + { + AddStep("create stage", () => + { + stageDefinitions = new List + { + new StageDefinition { Columns = 2 }, + new StageDefinition { Columns = 2 } + }; + + SetContents(() => new ManiaPlayfield(stageDefinitions)); + }); + } + + protected override IBeatmap CreateBeatmapForSkinProvider() + { + var maniaBeatmap = (ManiaBeatmap)base.CreateBeatmapForSkinProvider(); + maniaBeatmap.Stages = stageDefinitions; + return maniaBeatmap; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs new file mode 100644 index 0000000000..37b97a444a --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStage : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => + { + ManiaAction normalAction = ManiaAction.Key1; + ManiaAction specialAction = ManiaAction.Special1; + + return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) + { + Child = new Stage(0, new StageDefinition { Columns = 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 new file mode 100644 index 0000000000..87c84cf89c --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStageBackground : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs new file mode 100644 index 0000000000..4e99068ed5 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.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.Allocation; +using osu.Framework.Graphics; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStageForeground : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs index d94a986dae..d9b1ad22fa 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs @@ -12,7 +12,6 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; @@ -24,14 +23,6 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class TestSceneColumn : ManiaInputTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Column), - typeof(ColumnBackground), - typeof(ColumnKeyArea), - typeof(ColumnHitObjectArea) - }; - [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableJudgement.cs deleted file mode 100644 index eea1a36a19..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableJudgement.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; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Mania.Tests -{ - public class TestSceneDrawableJudgement : SkinnableTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableJudgement), - typeof(DrawableManiaJudgement) - }; - - public TestSceneDrawableJudgement() - { - foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) - { - AddStep("Show " + result.GetDescription(), () => SetContents(() => - new DrawableManiaJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - })); - } - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs index 7ed886be49..3b9c03b86a 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs @@ -15,8 +15,9 @@ namespace osu.Game.Rulesets.Mania.Tests { private readonly Bindable direction = new Bindable(); + protected override Ruleset CreateEditorRuleset() => new ManiaRuleset(); + public TestSceneEditor() - : base(new ManiaRuleset()) { AddStep("upwards scroll", () => direction.Value = ManiaScrollingDirection.Up); AddStep("downwards scroll", () => direction.Value = ManiaScrollingDirection.Down); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHitExplosion.cs deleted file mode 100644 index 26a1b1b1ec..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHitExplosion.cs +++ /dev/null @@ -1,62 +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 NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; -using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.UI.Scrolling; -using osu.Game.Tests.Visual; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Mania.Tests -{ - [TestFixture] - public class TestSceneHitExplosion : OsuTestScene - { - private ScrollingTestContainer scrolling; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableNote), - typeof(DrawableManiaHitObject), - }; - - protected override void LoadComplete() - { - base.LoadComplete(); - - Child = scrolling = new ScrollingTestContainer(ScrollingDirection.Down) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Y, - Y = -0.25f, - Size = new Vector2(Column.COLUMN_WIDTH, NotePiece.NOTE_HEIGHT), - }; - - int runcount = 0; - - AddRepeatStep("explode", () => - { - runcount++; - - if (runcount % 15 > 12) - return; - - scrolling.AddRange(new Drawable[] - { - new HitExplosion((runcount / 15) % 2 == 0 ? new Color4(94, 0, 57, 255) : new Color4(6, 84, 0, 255), runcount % 6 != 0) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }); - }, 100); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 7b0cf40d45..0d13b85901 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Mania.Tests private const double time_after_tail = 5250; private List judgementResults; - private bool allJudgedFired; /// /// -----[ ]----- @@ -283,20 +282,15 @@ namespace osu.Game.Rulesets.Mania.Tests { if (currentPlayer == p) judgementResults.Add(result); }; - p.ScoreProcessor.AllJudged += () => - { - if (currentPlayer == p) allJudgedFired = true; - }; }; LoadScreen(currentPlayer = p); - allJudgedFired = false; judgementResults = new List(); }); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); - AddUntilStep("Wait for all judged", () => allJudgedFired); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } private class ScoreAccessibleReplayPlayer : ReplayPlayer diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs new file mode 100644 index 0000000000..639be0bc11 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.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.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public 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 readonly ManiaBeatSnapGrid beatSnapGrid; + + public TestSceneManiaBeatSnapGrid() + { + editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 200 }); + editorBeatmap.ControlPointInfo.Add(10000, new TimingControlPoint { BeatLength = 200 }); + + BeatDivisor.Value = 3; + + // Some sane defaults + scrollingInfo.Algorithm.Algorithm = ScrollVisualisationMethod.Constant; + scrollingInfo.Direction.Value = ScrollingDirection.Up; + scrollingInfo.TimeRange.Value = 1000; + + Children = new Drawable[] + { + Playfield = new ManiaPlayfield(new List + { + new StageDefinition { Columns = 4 }, + new StageDefinition { Columns = 3 } + }) + { + Clock = new FramedClock(new StopwatchClock()) + }, + new TestHitObjectComposer(Playfield) + { + Child = beatSnapGrid = new ManiaBeatSnapGrid() + } + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + // We're providing a constant scroll algorithm. + float relativePosition = Playfield.Stages[0].HitObjectContainer.ToLocalSpace(e.ScreenSpaceMousePosition).Y / Playfield.Stages[0].HitObjectContainer.DrawHeight; + double timeValue = scrollingInfo.TimeRange.Value * relativePosition; + + beatSnapGrid.SelectionTimeRange = (timeValue, timeValue); + + return true; + } + + public ManiaPlayfield Playfield { get; } + } + + public class TestHitObjectComposer : HitObjectComposer + { + public override Playfield Playfield { get; } + public override IEnumerable HitObjects => Enumerable.Empty(); + public override bool CursorInPlacementArea => false; + + public TestHitObjectComposer(Playfield playfield) + { + Playfield = playfield; + } + + public Drawable Child + { + set => InternalChild = value; + } + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + throw new System.NotImplementedException(); + } + + public override float GetBeatSnapDistanceAt(double referenceTime) + { + throw new System.NotImplementedException(); + } + + public override float DurationToDistance(double referenceTime, double duration) + { + throw new System.NotImplementedException(); + } + + public override double DistanceToDuration(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + + public override double GetSnappedDurationFromDistance(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + + public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs new file mode 100644 index 0000000000..1a3fa29d4a --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneManiaHitObjectComposer : EditorClockTestScene + { + private TestComposer composer; + + [SetUp] + public void Setup() => Schedule(() => + { + BeatDivisor.Value = 8; + Clock.Seek(0); + + Child = composer = new TestComposer { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void TestDragOffscreenSelectionVerticallyUpScroll() + { + DrawableHitObject lastObject = null; + double originalTime = 0; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Up); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + originalTime = lastObject.HitObject.StartTime; + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse downwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, lastObject.ScreenSpaceDrawQuad.Height * 4)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved downwards", () => lastObject.DrawPosition.Y - originalPosition.Y > 0); + AddAssert("hitobject has moved time", () => lastObject.HitObject.StartTime == originalTime + 125); + } + + [Test] + public void TestDragOffscreenSelectionVerticallyDownScroll() + { + DrawableHitObject lastObject = null; + double originalTime = 0; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Down); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + originalTime = lastObject.HitObject.StartTime; + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse upwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, -lastObject.ScreenSpaceDrawQuad.Height * 4)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved upwards", () => originalPosition.Y - lastObject.DrawPosition.Y > 0); + AddAssert("hitobject has moved time", () => lastObject.HitObject.StartTime == originalTime + 125); + } + + [Test] + public void TestDragOffscreenSelectionHorizontally() + { + DrawableHitObject lastObject = null; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Down); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse right", () => + { + var firstColumn = composer.Composer.Playfield.GetColumn(0); + var secondColumn = composer.Composer.Playfield.GetColumn(1); + + InputManager.MoveMouseTo(lastObject, new Vector2(secondColumn.ScreenSpaceDrawQuad.Centre.X - firstColumn.ScreenSpaceDrawQuad.Centre.X + 1, 0)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 1)); + + // Todo: They'll move vertically by the height of a note since there's no snapping and the selection point is the middle of the note. + AddAssert("hitobjects not moved vertically", () => lastObject.DrawPosition.Y - originalPosition.Y <= DefaultNotePiece.NOTE_HEIGHT); + } + + [Test] + public void TestDragHoldNoteSelectionVertically() + { + setScrollStep(ScrollingDirection.Down); + + AddStep("setup beatmap", () => + { + composer.EditorBeatmap.Clear(); + composer.EditorBeatmap.Add(new HoldNote + { + Column = 1, + EndTime = 200 + }); + }); + + DrawableHoldNote holdNote = null; + + AddStep("grab hold note", () => + { + holdNote = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(holdNote); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move drag upwards", () => + { + InputManager.MoveMouseTo(holdNote, new Vector2(0, -100)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("head note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.BottomLeft, holdNote.Head.ScreenSpaceDrawQuad.BottomLeft)); + AddAssert("tail note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.TopLeft, holdNote.Tail.ScreenSpaceDrawQuad.BottomLeft)); + + AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); + AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); + } + + private void setScrollStep(ScrollingDirection direction) + => AddStep($"set scroll direction = {direction}", () => ((Bindable)composer.Composer.ScrollingInfo.Direction).Value = direction); + + private class TestComposer : CompositeDrawable + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + public readonly EditorBeatmap EditorBeatmap; + + public readonly ManiaHitObjectComposer Composer; + + public TestComposer() + { + InternalChildren = new Drawable[] + { + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 })) + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } + }, + Composer = new ManiaHitObjectComposer(new ManiaRuleset()) + }; + + for (int i = 0; i < 10; i++) + EditorBeatmap.Add(new Note { StartTime = 125 * i }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs new file mode 100644 index 0000000000..0d726e1a50 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Reflection; +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneManiaHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + protected override IResourceStore Resources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneManiaHitObjectSamples))); + + /// + /// Tests that when a normal sample bank is used, the normal hitsound will be looked up. + /// + [Test] + public void TestManiaHitObjectNormalSampleBank() + { + const string expected_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("mania-hitobject-beatmap-normal-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that when a custom sample bank is used, layered hitsounds are not played + /// (only the sample from the custom bank is looked up). + /// + [Test] + public void TestManiaHitObjectCustomSampleBank() + { + const string expected_sample = "normal-hitwhistle2"; + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, unwanted_sample); + + CreateTestWithBeatmap("mania-hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + AssertNoLookup(unwanted_sample); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs similarity index 62% rename from osu.Game.Rulesets.Mania.Tests/TestScenePlayer.cs rename to osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs index cd25d162d0..a399b90585 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestScenePlayer.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs @@ -5,11 +5,8 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { - public class TestScenePlayer : PlayerTestScene + public class TestSceneManiaPlayer : PlayerTestScene { - public TestScenePlayer() - : base(new ManiaRuleset()) - { - } + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs index d7b539a2a0..2d97e61aa5 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs @@ -1,17 +1,59 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Tests { public class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene { + [SetUp] + public void Setup() => Schedule(() => + { + this.ChildrenOfType().ForEach(c => c.Clear()); + + ResetPlacement(); + + ((ScrollingTestContainer)HitObjectContainer).Direction = ScrollingDirection.Down; + }); + + [Test] + public void TestPlaceBeforeCurrentTimeDownwards() + { + AddStep("move mouse before current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.BottomLeft - new Vector2(0, 10))); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("note start time < 0", () => getNote().StartTime < 0); + } + + [Test] + public void TestPlaceAfterCurrentTimeDownwards() + { + AddStep("move mouse after current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("note start time > 0", () => getNote().StartTime > 0); + } + + private Note getNote() => this.ChildrenOfType().FirstOrDefault()?.HitObject; + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject); protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint(); } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs index 8dae5e6d84..dd5fd93710 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -30,12 +28,6 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class TestSceneNotes : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableNote), - typeof(DrawableHoldNote) - }; - [BackgroundDependencyLoader] private void load() { @@ -164,7 +156,7 @@ namespace osu.Game.Rulesets.Mania.Tests foreach (var obj in content.OfType()) { - if (!(obj.HitObject is IHasEndTime endTime)) + if (!(obj.HitObject is IHasDuration endTime)) continue; foreach (var nested in obj.NestedHitObjects) diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs new file mode 100644 index 0000000000..8698ba3abd --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestScenePlayfieldCoveringContainer : OsuTestScene + { + private readonly ScrollingTestContainer scrollingContainer; + private readonly PlayfieldCoveringWrapper cover; + + public TestScenePlayfieldCoveringContainer() + { + Child = scrollingContainer = new ScrollingTestContainer(ScrollingDirection.Down) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 500), + Child = cover = new PlayfieldCoveringWrapper(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Orange + }) + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + [Test] + public void TestScrollingDownwards() + { + AddStep("set down scroll", () => scrollingContainer.Direction = ScrollingDirection.Down); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + + [Test] + public void TestScrollingUpwards() + { + AddStep("set up scroll", () => scrollingContainer.Direction = ScrollingDirection.Up); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs index d5fd2808b8..7376a90f17 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Tests [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); - private readonly List stages = new List(); + private readonly List stages = new List(); private FillFlowContainer fill; @@ -81,9 +81,9 @@ namespace osu.Game.Rulesets.Mania.Tests AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[1], Anchor.TopCentre)); } - private bool notesInStageAreAnchored(ManiaStage stage, Anchor anchor) => stage.Columns.SelectMany(c => c.AllHitObjects).All(o => o.Anchor == anchor); + private bool notesInStageAreAnchored(Stage stage, Anchor anchor) => stage.Columns.SelectMany(c => c.AllHitObjects).All(o => o.Anchor == anchor); - private bool barsInStageAreAnchored(ManiaStage stage, Anchor anchor) => stage.AllHitObjects.Where(obj => obj is DrawableBarLine).All(o => o.Anchor == anchor); + private bool barsInStageAreAnchored(Stage stage, Anchor anchor) => stage.AllHitObjects.Where(obj => obj is DrawableBarLine).All(o => o.Anchor == anchor); private void createNote() { @@ -133,7 +133,7 @@ namespace osu.Game.Rulesets.Mania.Tests { var specialAction = ManiaAction.Special1; - var stage = new ManiaStage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); + var stage = new Stage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); stages.Add(stage); return new ScrollingTestContainer(direction) 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 dea6e6c0fb..972cbec4a2 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -2,9 +2,9 @@ - + - + diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs new file mode 100644 index 0000000000..8f904530bc --- /dev/null +++ b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.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.Mania.Beatmaps +{ + public enum ColumnType + { + Even, + Odd, + Special + } +} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 1a77a4944b..b025ac7992 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -5,7 +5,8 @@ using osu.Game.Rulesets.Mania.Objects; using System; using System.Linq; using System.Collections.Generic; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -13,7 +14,6 @@ using osu.Game.Rulesets.Mania.Beatmaps.Patterns; using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy; using osuTK; -using osu.Game.Audio; namespace osu.Game.Rulesets.Mania.Beatmaps { @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { TargetColumns = (int)Math.Max(1, roundedCircleSize); - if (TargetColumns >= 10) + if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS) { TargetColumns /= 2; Dual = true; @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps } else { - float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasEndTime) / beatmap.HitObjects.Count; + float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasDuration) / beatmap.HitObjects.Count; if (percentSliderOrSpinner < 0.2) TargetColumns = 7; else if (percentSliderOrSpinner < 0.3 || roundedCircleSize >= 5) @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps } } - public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition || h is ManiaHitObject); + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); protected override Beatmap ConvertBeatmap(IBeatmap original) { @@ -176,7 +176,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps break; } - case IHasEndTime endTimeData: + case IHasDuration endTimeData: { conversion = new EndTimeObjectPatternGenerator(Random, original, beatmap, originalBeatmap); @@ -232,15 +232,15 @@ namespace osu.Game.Rulesets.Mania.Beatmaps var pattern = new Pattern(); - if (HitObject is IHasEndTime endTimeData) + if (HitObject is IHasDuration endTimeData) { pattern.Add(new HoldNote { StartTime = HitObject.StartTime, Duration = endTimeData.Duration, Column = column, - Head = { Samples = sampleInfoListAt(HitObject.StartTime) }, - Tail = { Samples = sampleInfoListAt(endTimeData.EndTime) }, + Samples = HitObject.Samples, + NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? defaultNodeSamples }); } else if (HitObject is IHasXPosition) @@ -256,21 +256,15 @@ namespace osu.Game.Rulesets.Mania.Beatmaps return pattern; } - /// - /// Retrieves the sample info list at a point in time. - /// - /// The time to retrieve the sample info list from. - /// - private IList sampleInfoListAt(double time) - { - if (!(HitObject is IHasCurve curveData)) - return HitObject.Samples; - - double segmentTime = (curveData.EndTime - HitObject.StartTime) / curveData.SpanCount(); - - int index = (int)(segmentTime == 0 ? 0 : (time - HitObject.StartTime) / segmentTime); - return curveData.NodeSamples[index]; - } + /// + /// osu!mania-specific beatmaps in stable only play samples at the start of the hold note. + /// + private List> defaultNodeSamples + => new List> + { + HitObject.Samples, + new List() + }; } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 9565ac8994..d03eb0b3c9 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; @@ -472,15 +472,22 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The time to retrieve the sample info list from. /// - private IList sampleInfoListAt(double time) + private IList sampleInfoListAt(double time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples; + + /// + /// Retrieves the list of node samples that occur at time greater than or equal to . + /// + /// The time to retrieve node samples at. + private List> nodeSamplesAt(double time) { - if (!(HitObject is IHasCurve curveData)) - return HitObject.Samples; + if (!(HitObject is IHasPathWithRepeats curveData)) + return null; - double segmentTime = (EndTime - HitObject.StartTime) / spanCount; + // mathematically speaking this should be a whole number always, but floating-point arithmetic is not so kind + var index = (int)Math.Round(SegmentDuration == 0 ? 0 : (time - HitObject.StartTime) / SegmentDuration, MidpointRounding.AwayFromZero); - int index = (int)(segmentTime == 0 ? 0 : (time - HitObject.StartTime) / segmentTime); - return curveData.NodeSamples[index]; + // avoid slicing the list & creating copies, if at all possible. + return index == 0 ? curveData.NodeSamples : curveData.NodeSamples.Skip(index).ToList(); } /// @@ -505,16 +512,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy } else { - var holdNote = new HoldNote + newObject = new HoldNote { StartTime = startTime, - Column = column, Duration = endTime - startTime, - Head = { Samples = sampleInfoListAt(startTime) }, - Tail = { Samples = sampleInfoListAt(endTime) } + Column = column, + Samples = HitObject.Samples, + NodeSamples = nodeSamplesAt(startTime) }; - - newObject = holdNote; } pattern.Add(newObject); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs index b3be08e1f7..d5286a3779 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy public EndTimeObjectPatternGenerator(FastRandom random, HitObject hitObject, ManiaBeatmap beatmap, IBeatmap originalBeatmap) : base(random, hitObject, beatmap, new Pattern(), originalBeatmap) { - endTime = (HitObject as IHasEndTime)?.EndTime ?? 0; + endTime = (HitObject as IHasDuration)?.EndTime ?? 0; } public override IEnumerable Generate() @@ -64,21 +64,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (holdNote) { - var hold = new HoldNote + newObject = new HoldNote { StartTime = HitObject.StartTime, + Duration = endTime - HitObject.StartTime, Column = column, - Duration = endTime - HitObject.StartTime + Samples = HitObject.Samples, + NodeSamples = (HitObject as IHasRepeats)?.NodeSamples }; - - if (hold.Head.Samples == null) - hold.Head.Samples = new List(); - - hold.Head.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_NORMAL }); - - hold.Tail.Samples = HitObject.Samples; - - newObject = hold; } else { diff --git a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs index dff7cb72ce..2557f2acdf 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Beatmaps @@ -21,5 +22,19 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// The 0-based column index. /// Whether the column is a special column. public 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 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; + } } } diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index f5412dcfc5..7e84f17809 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.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.Configuration.Tracking; using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; @@ -19,13 +20,14 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - Set(ManiaRulesetSetting.ScrollTime, 1500.0, 50.0, 5000.0, 50.0); + Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 1); Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { - new TrackedSetting(ManiaRulesetSetting.ScrollTime, v => new SettingDescription(v, "Scroll Time", $"{v}ms")) + new TrackedSetting(ManiaRulesetSetting.ScrollTime, + v => new SettingDescription(v, "Scroll Speed", $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / v)} ({v}ms)")) }; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index 3f7a2baedd..91383c5548 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -37,12 +38,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty { mods = Score.Mods; scaledScore = Score.TotalScore; - countPerfect = Score.Statistics[HitResult.Perfect]; - countGreat = Score.Statistics[HitResult.Great]; - countGood = Score.Statistics[HitResult.Good]; - countOk = Score.Statistics[HitResult.Ok]; - countMeh = Score.Statistics[HitResult.Meh]; - countMiss = Score.Statistics[HitResult.Miss]; + countPerfect = Score.Statistics.GetOrDefault(HitResult.Perfect); + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countGood = Score.Statistics.GetOrDefault(HitResult.Good); + countOk = Score.Statistics.GetOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); if (mods.Any(m => !m.Ranked)) return 0; diff --git a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs new file mode 100644 index 0000000000..8d39e08b26 --- /dev/null +++ b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class DualStageVariantGenerator + { + private readonly int singleStageVariant; + private readonly InputKey[] stage1LeftKeys; + private readonly InputKey[] stage1RightKeys; + private readonly InputKey[] stage2LeftKeys; + private readonly InputKey[] stage2RightKeys; + + public DualStageVariantGenerator(int singleStageVariant) + { + this.singleStageVariant = singleStageVariant; + + // 10K is special because it expands towards the centre of the keyboard (VM/BN), rather than towards the edges of the keyboard. + if (singleStageVariant == 10) + { + stage1LeftKeys = new[] { InputKey.Q, InputKey.W, InputKey.E, InputKey.R, InputKey.V }; + stage1RightKeys = new[] { InputKey.M, InputKey.I, InputKey.O, InputKey.P, InputKey.BracketLeft }; + + stage2LeftKeys = new[] { InputKey.S, InputKey.D, InputKey.F, InputKey.G, InputKey.B }; + stage2RightKeys = new[] { InputKey.N, InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + else + { + stage1LeftKeys = new[] { InputKey.Q, InputKey.W, InputKey.E, InputKey.R }; + stage1RightKeys = new[] { InputKey.I, InputKey.O, InputKey.P, InputKey.BracketLeft }; + + stage2LeftKeys = new[] { InputKey.S, InputKey.D, InputKey.F, InputKey.G }; + stage2RightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + } + + public IEnumerable GenerateMappings() + { + var stage1Bindings = new VariantMappingGenerator + { + LeftKeys = stage1LeftKeys, + RightKeys = stage1RightKeys, + SpecialKey = InputKey.V, + SpecialAction = ManiaAction.Special1, + NormalActionStart = ManiaAction.Key1 + }.GenerateKeyBindingsFor(singleStageVariant, out var nextNormal); + + var stage2Bindings = new VariantMappingGenerator + { + LeftKeys = stage2LeftKeys, + RightKeys = stage2RightKeys, + SpecialKey = InputKey.B, + SpecialAction = ManiaAction.Special2, + NormalActionStart = nextNormal + }.GenerateKeyBindingsFor(singleStageVariant, out _); + + return stage1Bindings.Concat(stage2Bindings); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs index b99a1157f3..efcfe11dad 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs @@ -7,12 +7,12 @@ using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public class EditBodyPiece : BodyPiece + public class EditBodyPiece : DefaultBodyPiece { [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour = colours.Yellow; + AccentColour.Value = colours.Yellow; Background.Alpha = 0.5f; Foreground.Alpha = 0; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs index 6f85fd9167..8773a39939 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs @@ -12,12 +12,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { public EditNotePiece() { - Height = NotePiece.NOTE_HEIGHT; + Height = DefaultNotePiece.NOTE_HEIGHT; CornerRadius = 5; Masking = true; - InternalChild = new NotePiece(); + InternalChild = new DefaultNotePiece(); } [BackgroundDependencyLoader] diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index bcbc1ee527..b5ec1e1a2a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -2,10 +2,15 @@ // 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.Input.Events; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.UI.Scrolling; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { @@ -15,6 +20,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private readonly EditNotePiece headPiece; private readonly EditNotePiece tailPiece; + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + public HoldNotePlacementBlueprint() : base(new HoldNote()) { @@ -34,8 +42,21 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints if (Column != null) { - headPiece.Y = PositionAt(HitObject.StartTime); - tailPiece.Y = PositionAt(HitObject.EndTime); + headPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y; + tailPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y; + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + headPiece.Y -= headPiece.DrawHeight / 2; + tailPiece.Y -= tailPiece.DrawHeight / 2; + break; + + case ScrollingDirection.Up: + headPiece.Y += headPiece.DrawHeight / 2; + tailPiece.Y += tailPiece.DrawHeight / 2; + break; + } } var topPosition = new Vector2(headPiece.DrawPosition.X, Math.Min(headPiece.DrawPosition.Y, tailPiece.DrawPosition.Y)); @@ -46,25 +67,39 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints bodyPiece.Height = (bottomPosition - topPosition).Y; } + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button != MouseButton.Left) + return; + + base.OnMouseUp(e); + EndPlacement(true); + } + private double originalStartTime; - public override void UpdatePosition(Vector2 screenSpacePosition) + public override void UpdatePosition(SnapResult result) { - base.UpdatePosition(screenSpacePosition); + base.UpdatePosition(result); - if (PlacementBegun) + if (PlacementActive) { - var endTime = TimeAt(screenSpacePosition); - - HitObject.StartTime = endTime < originalStartTime ? endTime : originalStartTime; - HitObject.Duration = Math.Abs(endTime - originalStartTime); + if (result.Time is double endTime) + { + HitObject.StartTime = endTime < originalStartTime ? endTime : originalStartTime; + HitObject.Duration = Math.Abs(endTime - originalStartTime); + } } else { - headPiece.Width = tailPiece.Width = SnappedWidth; - headPiece.X = tailPiece.X = SnappedMousePosition.X; + if (result.Playfield != null) + { + headPiece.Width = tailPiece.Width = result.Playfield.DrawWidth; + headPiece.X = tailPiece.X = ToLocalSpace(result.ScreenSpacePosition).X; + } - originalStartTime = HitObject.StartTime = TimeAt(screenSpacePosition); + if (result.Time is double startTime) + originalStartTime = HitObject.StartTime = startTime; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 56c0b671a0..1737c4d2e5 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -4,13 +4,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.UI.Scrolling; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { @@ -42,11 +42,19 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.Start), new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.End), - new BodyPiece + new Container { - AccentColour = Color4.Transparent, - BorderColour = colours.Yellow - }, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 1, + BorderColour = colours.Yellow, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } }; } @@ -68,5 +76,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints } public override Quad SelectionQuad => ScreenSpaceDrawQuad; + + public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.Head.ScreenSpaceDrawQuad.Centre; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index b28d8bb0e6..27a279e044 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -1,42 +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.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.UI.Scrolling; -using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract class ManiaPlacementBlueprint : PlacementBlueprint, - IRequireHighFrequencyMousePosition // the playfield could be moving behind us + public abstract class ManiaPlacementBlueprint : PlacementBlueprint where T : ManiaHitObject { protected new T HitObject => (T)base.HitObject; - protected Column Column; + private Column column; - /// - /// The current mouse position, snapped to the closest column. - /// - protected Vector2 SnappedMousePosition { get; private set; } + public Column Column + { + get => column; + set + { + if (value == column) + return; - /// - /// The width of the closest column to the current mouse position. - /// - protected float SnappedWidth { get; private set; } - - [Resolved] - private IManiaHitObjectComposer composer { get; set; } - - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } + column = value; + HitObject.Column = column.Index; + } + } protected ManiaPlacementBlueprint(T hitObject) : base(hitObject) @@ -46,112 +38,22 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints protected override bool OnMouseDown(MouseDownEvent e) { - if (Column == null) - return base.OnMouseDown(e); + if (e.Button != MouseButton.Left) + return false; - HitObject.Column = Column.Index; - BeginPlacement(TimeAt(e.ScreenSpaceMousePosition)); + if (Column == null) + return false; + + BeginPlacement(true); return true; } - protected override bool OnMouseUp(MouseUpEvent e) + public override void UpdatePosition(SnapResult result) { - EndPlacement(); - return base.OnMouseUp(e); - } + base.UpdatePosition(result); - public override void UpdatePosition(Vector2 screenSpacePosition) - { - if (!PlacementBegun) - Column = ColumnAt(screenSpacePosition); - - if (Column == null) return; - - SnappedWidth = Column.DrawWidth; - - // Snap to the column - var parentPos = Parent.ToLocalSpace(Column.ToScreenSpace(new Vector2(Column.DrawWidth / 2, 0))); - SnappedMousePosition = new Vector2(parentPos.X, Parent.ToLocalSpace(screenSpacePosition).Y); - } - - protected double TimeAt(Vector2 screenSpacePosition) - { - if (Column == null) - return 0; - - var hitObjectContainer = Column.HitObjectContainer; - - // If we're scrolling downwards, a position of 0 is actually further away from the hit target - // so we need to flip the vertical coordinate in the hitobject container's space - var hitObjectPos = mouseToHitObjectPosition(Column.HitObjectContainer.ToLocalSpace(screenSpacePosition)).Y; - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - hitObjectPos = hitObjectContainer.DrawHeight - hitObjectPos; - - return scrollingInfo.Algorithm.TimeAt(hitObjectPos, - EditorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - hitObjectContainer.DrawHeight); - } - - protected float PositionAt(double time) - { - var pos = scrollingInfo.Algorithm.PositionAt(time, - EditorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - Column.HitObjectContainer.DrawHeight); - - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - pos = Column.HitObjectContainer.DrawHeight - pos; - - return hitObjectToMousePosition(Column.HitObjectContainer.ToSpaceOfOtherDrawable(new Vector2(0, pos), Parent)).Y; - } - - protected Column ColumnAt(Vector2 screenSpacePosition) - => composer.ColumnAt(screenSpacePosition); - - /// - /// Converts a mouse position to a hitobject position. - /// - /// - /// Blueprints are centred on the mouse position, such that the hitobject position is anchored at the top or bottom of the blueprint depending on the scroll direction. - /// - /// The mouse position. - /// The resulting hitobject position, acnhored at the top or bottom of the blueprint depending on the scroll direction. - private Vector2 mouseToHitObjectPosition(Vector2 mousePosition) - { - switch (scrollingInfo.Direction.Value) - { - case ScrollingDirection.Up: - mousePosition.Y -= NotePiece.NOTE_HEIGHT / 2; - break; - - case ScrollingDirection.Down: - mousePosition.Y += NotePiece.NOTE_HEIGHT / 2; - break; - } - - return mousePosition; - } - - /// - /// Converts a hitobject position to a mouse position. - /// - /// The hitobject position. - /// The resulting mouse position, anchored at the centre of the hitobject. - private Vector2 hitObjectToMousePosition(Vector2 hitObjectPosition) - { - switch (scrollingInfo.Direction.Value) - { - case ScrollingDirection.Up: - hitObjectPosition.Y += NotePiece.NOTE_HEIGHT / 2; - break; - - case ScrollingDirection.Down: - hitObjectPosition.Y -= NotePiece.NOTE_HEIGHT / 2; - break; - } - - return hitObjectPosition; + if (!PlacementActive) + Column = result.Playfield as Column; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index 3bd7fb2d49..384f49d9b2 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -3,8 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; @@ -13,33 +11,19 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class ManiaSelectionBlueprint : SelectionBlueprint + public abstract class ManiaSelectionBlueprint : OverlaySelectionBlueprint { - public Vector2 ScreenSpaceDragPosition { get; private set; } - public Vector2 DragPosition { get; private set; } - public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; - protected IClock EditorClock { get; private set; } - [Resolved] private IScrollingInfo scrollingInfo { get; set; } - [Resolved] - private IManiaHitObjectComposer composer { get; set; } - - public ManiaSelectionBlueprint(DrawableHitObject drawableObject) + protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) : base(drawableObject) { RelativeSizeAxes = Axes.None; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) - { - EditorClock = clock; - } - protected override void Update() { base.Update(); @@ -47,24 +31,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero)); } - protected override bool OnMouseDown(MouseDownEvent e) - { - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); - - return base.OnMouseDown(e); - } - - protected override bool OnDrag(DragEvent e) - { - var result = base.OnDrag(e); - - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); - - return result; - } - public override void Show() { DrawableObject.AlwaysAlive = true; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index 32c6a6fd07..684004b558 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -2,29 +2,48 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class NotePlacementBlueprint : ManiaPlacementBlueprint { + private readonly EditNotePiece piece; + public NotePlacementBlueprint() : base(new Note()) { - Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; - AutoSizeAxes = Axes.Y; - - InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; + InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre }; } - protected override void Update() + public override void UpdatePosition(SnapResult result) { - base.Update(); + base.UpdatePosition(result); - Width = SnappedWidth; - Position = SnappedMousePosition; + if (result.Playfield != null) + { + piece.Width = result.Playfield.DrawWidth; + piece.Position = ToLocalSpace(result.ScreenSpacePosition); + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) + return false; + + base.OnMouseDown(e); + + // Place the note immediately. + EndPlacement(true); + + return true; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs deleted file mode 100644 index f64bab1fae..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mania.UI; -using osuTK; - -namespace osu.Game.Rulesets.Mania.Edit -{ - public interface IManiaHitObjectComposer - { - Column ColumnAt(Vector2 screenSpacePosition); - - int TotalColumns { get; } - } -} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs new file mode 100644 index 0000000000..2028cae9a5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -0,0 +1,215 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osuTK.Graphics; + +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 + { + private const double visible_range = 750; + + /// + /// The range of time values of the current selection. + /// + public (double start, double end)? SelectionTimeRange + { + set + { + if (value == selectionTimeRange) + return; + + selectionTimeRange = value; + lineCache.Invalidate(); + } + } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + + [Resolved] + private Bindable working { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + private readonly List grids = new List(); + + private readonly Cached lineCache = new Cached(); + + private (double start, double end)? selectionTimeRange; + + [BackgroundDependencyLoader] + private void load(HitObjectComposer composer) + { + foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages) + { + foreach (var column in stage.Columns) + { + var lineContainer = new ScrollingHitObjectContainer(); + + grids.Add(lineContainer); + column.UnderlayElements.Add(lineContainer); + } + } + + beatDivisor.BindValueChanged(_ => createLines(), true); + } + + protected override void Update() + { + base.Update(); + + if (!lineCache.IsValid) + { + lineCache.Validate(); + createLines(); + } + } + + private readonly Stack availableLines = new Stack(); + + private void createLines() + { + foreach (var grid in grids) + { + foreach (var line in grid.Objects.OfType()) + availableLines.Push(line); + + grid.Clear(false); + } + + if (selectionTimeRange == null) + return; + + var range = selectionTimeRange.Value; + + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); + + double time = timingPoint.Time; + int beat = 0; + + // progress time until in the visible range. + while (time < range.start - visible_range) + { + time += timingPoint.BeatLength / beatDivisor.Value; + beat++; + } + + while (time < range.end + visible_range) + { + var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); + + // switch to the next timing point if we have reached it. + if (nextTimingPoint.Time > timingPoint.Time) + { + beat = 0; + time = nextTimingPoint.Time; + timingPoint = nextTimingPoint; + } + + Color4 colour = BindableBeatDivisor.GetColourFor( + BindableBeatDivisor.GetDivisorForBeatIndex(Math.Max(1, beat), beatDivisor.Value), colours); + + foreach (var grid in grids) + { + if (!availableLines.TryPop(out var line)) + line = new DrawableGridLine(); + + line.HitObject.StartTime = time; + line.Colour = colour; + + grid.Add(line); + } + + beat++; + time += timingPoint.BeatLength / beatDivisor.Value; + } + + foreach (var grid in grids) + { + // required to update ScrollingHitObjectContainer's cache. + grid.UpdateSubTree(); + + foreach (var line in grid.Objects.OfType()) + { + time = line.HitObject.StartTime; + + if (time >= range.start && time <= range.end) + line.Alpha = 1; + else + { + double timeSeparation = time < range.start ? range.start - time : time - range.end; + line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); + } + } + } + } + + private class DrawableGridLine : DrawableHitObject + { + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + + private readonly IBindable direction = new Bindable(); + + public DrawableGridLine() + : base(new HitObject()) + { + RelativeSizeAxes = Axes.X; + Height = 2; + + AddInternal(new Box { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load() + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Origin = Anchor = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopLeft + : Anchor.BottomLeft; + } + + protected override void UpdateInitialTransforms() + { + // don't perform any fading – we are handling that ourselves. + } + + protected override void UpdateStateTransforms(ArmedState state) + { + LifetimeEnd = HitObject.StartTime + visible_range; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs new file mode 100644 index 0000000000..cea27498c3 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Mania.Edit +{ + public class ManiaBlueprintContainer : ComposeBlueprintContainer + { + public ManiaBlueprintContainer(IEnumerable drawableHitObjects) + : base(drawableHitObjects) + { + } + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + { + switch (hitObject) + { + case DrawableNote note: + return new NoteSelectionBlueprint(note); + + case DrawableHoldNote holdNote: + return new HoldNoteSelectionBlueprint(holdNote); + } + + return base.CreateBlueprintFor(hitObject); + } + + protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 1632b6a583..7e2469a794 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -5,42 +5,79 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Objects.Drawables; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; -using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Framework.Input; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Rulesets.Mania.Edit { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public class ManiaHitObjectComposer : HitObjectComposer, IManiaHitObjectComposer + public class ManiaHitObjectComposer : HitObjectComposer { private DrawableManiaEditRuleset drawableRuleset; + private ManiaBeatSnapGrid beatSnapGrid; + private InputManager inputManager; public ManiaHitObjectComposer(Ruleset ruleset) : base(ruleset) { } - /// - /// Retrieves the column that intersects a screen-space position. - /// - /// The screen-space position. - /// The column which intersects with . - public Column ColumnAt(Vector2 screenSpacePosition) => drawableRuleset.GetColumnByPosition(screenSpacePosition); + [BackgroundDependencyLoader] + private void load() + { + AddInternal(beatSnapGrid = new ManiaBeatSnapGrid()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - public int TotalColumns => ((ManiaPlayfield)drawableRuleset.Playfield).TotalColumns; + public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield); + + public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; + + protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => + Playfield.GetColumnByPosition(screenSpacePosition); + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + + switch (ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + result.ScreenSpacePosition -= new Vector2(0, getNoteHeight() / 2); + break; + + case ScrollingDirection.Up: + result.ScreenSpacePosition += new Vector2(0, getNoteHeight() / 2); + break; + } + + return result; + } + + private float getNoteHeight() => + Playfield.GetColumn(0).ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y - + Playfield.GetColumn(0).ToScreenSpace(Vector2.Zero).Y; protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) { @@ -52,26 +89,36 @@ namespace osu.Game.Rulesets.Mania.Edit return drawableRuleset; } + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new ManiaBlueprintContainer(hitObjects); + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new NoteCompositionTool(), new HoldNoteCompositionTool() }; - public override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); - - public override SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + protected override void UpdateAfterChildren() { - switch (hitObject) + base.UpdateAfterChildren(); + + if (BlueprintContainer.CurrentTool is SelectTool) { - case DrawableNote note: - return new NoteSelectionBlueprint(note); - - case DrawableHoldNote holdNote: - return new HoldNoteSelectionBlueprint(holdNote); + if (EditorBeatmap.SelectedHitObjects.Any()) + { + beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); + } + else + beatSnapGrid.SelectionTimeRange = null; + } + else + { + var result = SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); + if (result.Time is double time) + beatSnapGrid.SelectionTimeRange = (time, time); + else + beatSnapGrid.SelectionTimeRange = null; } - - return base.CreateBlueprintFor(hitObject); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 618af3e772..65f40d7d0a 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -4,10 +4,9 @@ using System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Timing; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; @@ -19,88 +18,23 @@ namespace osu.Game.Rulesets.Mania.Edit private IScrollingInfo scrollingInfo { get; set; } [Resolved] - private IManiaHitObjectComposer composer { get; set; } - - private IClock editorClock; - - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) - { - editorClock = clock; - } + private HitObjectComposer composer { get; set; } public override bool HandleMovement(MoveSelectionEvent moveEvent) { var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; - adjustOrigins(maniaBlueprint); - performDragMovement(moveEvent); performColumnMovement(lastColumn, moveEvent); return true; } - /// - /// Ensures that the position of hitobjects remains centred to the mouse position. - /// E.g. The hitobject position will change if the editor scrolls while a hitobject is dragged. - /// - /// The that received the drag event. - private void adjustOrigins(ManiaSelectionBlueprint reference) - { - var referenceParent = (HitObjectContainer)reference.DrawableObject.Parent; - - float offsetFromReferenceOrigin = reference.DragPosition.Y - reference.DrawableObject.OriginPosition.Y; - float targetPosition = referenceParent.ToLocalSpace(reference.ScreenSpaceDragPosition).Y - offsetFromReferenceOrigin; - - // Flip the vertical coordinate space when scrolling downwards - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition -= referenceParent.DrawHeight; - - float movementDelta = targetPosition - reference.DrawableObject.Position.Y; - - foreach (var b in SelectedBlueprints.OfType()) - b.DrawableObject.Y += movementDelta; - } - - private void performDragMovement(MoveSelectionEvent moveEvent) - { - float delta = moveEvent.InstantDelta.Y; - - // When scrolling downwards the anchor position is at the bottom of the screen, however the movement event assumes the anchor is at the top of the screen. - // This causes the delta to assume a positive hitobject position, and which can be corrected for by subtracting the parent height. - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - delta -= moveEvent.Blueprint.DrawableObject.Parent.DrawHeight; - - foreach (var b in SelectedBlueprints) - { - var hitObject = b.DrawableObject; - var objectParent = (HitObjectContainer)hitObject.Parent; - - // StartTime could be used to adjust the position if only one movement event was received per frame. - // However this is not the case and ScrollingHitObjectContainer performs movement in UpdateAfterChildren() so the position must also be updated to be valid for further movement events - hitObject.Y += delta; - - float targetPosition = hitObject.Position.Y; - - // The scrolling algorithm always assumes an anchor at the top of the screen, so the position must be flipped when scrolling downwards to reflect a top anchor - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition = -targetPosition; - - objectParent.Remove(hitObject); - - hitObject.HitObject.StartTime = scrollingInfo.Algorithm.TimeAt(targetPosition, - editorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - objectParent.DrawHeight); - - objectParent.Add(hitObject); - } - } - private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { - var currentColumn = composer.ColumnAt(moveEvent.ScreenSpacePosition); + var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; + + var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.ScreenSpacePosition); if (currentColumn == null) return; @@ -119,7 +53,7 @@ namespace osu.Game.Rulesets.Mania.Edit maxColumn = obj.Column; } - columnDelta = Math.Clamp(columnDelta, -minColumn, composer.TotalColumns - 1 - maxColumn); + columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); foreach (var obj in SelectedHitObjects.OfType()) obj.Column += columnDelta; diff --git a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs deleted file mode 100644 index ff8882124f..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Mania.Edit.Masks -{ - public abstract class ManiaSelectionBlueprint : SelectionBlueprint - { - protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) - : base(drawableObject) - { - RelativeSizeAxes = Axes.None; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs deleted file mode 100644 index e8b48768a1..0000000000 --- a/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Mania.Judgements -{ - public class HoldNoteJudgement : ManiaJudgement - { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) => 0; - } -} diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs index b9c6e3a7f7..294aab1e4e 100644 --- a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs @@ -7,19 +7,17 @@ namespace osu.Game.Rulesets.Mania.Judgements { public class HoldNoteTickJudgement : ManiaJudgement { - public override bool AffectsCombo => false; - protected override int NumericResultFor(HitResult result) => 20; protected override double HealthIncreaseFor(HitResult result) { switch (result) { - case HitResult.Miss: + default: return 0; - default: - return 0.040; + case HitResult.Perfect: + return 0.01; } } } diff --git a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs index 0e4c811945..53db676a54 100644 --- a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs @@ -25,35 +25,10 @@ namespace osu.Game.Rulesets.Mania.Judgements return 200; case HitResult.Great: - case HitResult.Perfect: return 300; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.Miss: - return -0.125; - - case HitResult.Meh: - return 0.005; - - case HitResult.Ok: - return 0.010; - - case HitResult.Good: - return 0.035; - - case HitResult.Great: - return 0.055; case HitResult.Perfect: - return 0.065; - - default: - return 0; + return 320; } } } diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs index 292990fd7e..186fc4b15d 100644 --- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs +++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs @@ -78,5 +78,11 @@ namespace osu.Game.Rulesets.Mania [Description("Key 18")] Key18, + + [Description("Key 19")] + Key19, + + [Description("Key 20")] + Key20, } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index c50f4314a3..68dce8b139 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -12,6 +12,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Replays.Types; @@ -30,15 +31,24 @@ using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Mania { + [ExcludeFromDynamicCompile] public class ManiaRuleset : Ruleset, ILegacyRuleset { + /// + /// The maximum number of supported keys in a single stage. + /// + public const int MAX_STAGE_KEYS = 10; + 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 DrainingHealthProcessor(drainStartTime, 0.2); + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new ManiaPerformanceCalculator(this, beatmap, score); @@ -47,9 +57,9 @@ namespace osu.Game.Rulesets.Mania public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this); - public override ISkin CreateLegacySkinProvider(ISkinSource source) => new ManiaLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new ManiaLegacySkinTransformer(source, beatmap); - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new ManiaModNightcore(); @@ -118,6 +128,59 @@ namespace osu.Game.Rulesets.Mania yield return new ManiaModRandom(); } + public override LegacyMods ConvertToLegacyMods(Mod[] mods) + { + var value = base.ConvertToLegacyMods(mods); + + foreach (var mod in mods) + { + switch (mod) + { + case ManiaModKey1 _: + value |= LegacyMods.Key1; + break; + + case ManiaModKey2 _: + value |= LegacyMods.Key2; + break; + + case ManiaModKey3 _: + value |= LegacyMods.Key3; + break; + + case ManiaModKey4 _: + value |= LegacyMods.Key4; + break; + + case ManiaModKey5 _: + value |= LegacyMods.Key5; + break; + + case ManiaModKey6 _: + value |= LegacyMods.Key6; + break; + + case ManiaModKey7 _: + value |= LegacyMods.Key7; + break; + + case ManiaModKey8 _: + value |= LegacyMods.Key8; + break; + + case ManiaModKey9 _: + value |= LegacyMods.Key9; + break; + + case ManiaModFadeIn _: + value |= LegacyMods.FadeIn; + break; + } + } + + return value; + } + public override IEnumerable GetModsFor(ModType type) { switch (type) @@ -149,6 +212,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModKey7(), new ManiaModKey8(), new ManiaModKey9(), + new ManiaModKey10(), new ManiaModKey1(), new ManiaModKey2(), new ManiaModKey3()), @@ -179,6 +243,8 @@ namespace osu.Game.Rulesets.Mania public override string ShortName => SHORT_NAME; + public override string PlayingVerb => "Smashing keys"; + public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetMania }; public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(this, beatmap); @@ -195,9 +261,9 @@ namespace osu.Game.Rulesets.Mania { get { - for (int i = 1; i <= 9; i++) + for (int i = 1; i <= MAX_STAGE_KEYS; i++) yield return (int)PlayfieldType.Single + i; - for (int i = 2; i <= 18; i += 2) + for (int i = 2; i <= MAX_STAGE_KEYS * 2; i += 2) yield return (int)PlayfieldType.Dual + i; } } @@ -207,73 +273,10 @@ namespace osu.Game.Rulesets.Mania switch (getPlayfieldType(variant)) { case PlayfieldType.Single: - return new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.A, - InputKey.S, - InputKey.D, - InputKey.F - }, - RightKeys = new[] - { - InputKey.J, - InputKey.K, - InputKey.L, - InputKey.Semicolon - }, - SpecialKey = InputKey.Space, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1, - }.GenerateKeyBindingsFor(variant, out _); + return new SingleStageVariantGenerator(variant).GenerateMappings(); case PlayfieldType.Dual: - int keys = getDualStageKeyCount(variant); - - var stage1Bindings = new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.Number1, - InputKey.Number2, - InputKey.Number3, - InputKey.Number4, - }, - RightKeys = new[] - { - InputKey.Z, - InputKey.X, - InputKey.C, - InputKey.V - }, - SpecialKey = InputKey.Tilde, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1 - }.GenerateKeyBindingsFor(keys, out var nextNormal); - - var stage2Bindings = new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.Number7, - InputKey.Number8, - InputKey.Number9, - InputKey.Number0 - }, - RightKeys = new[] - { - InputKey.O, - InputKey.P, - InputKey.BracketLeft, - InputKey.BracketRight - }, - SpecialKey = InputKey.BackSlash, - SpecialAction = ManiaAction.Special2, - NormalActionStart = nextNormal - }.GenerateKeyBindingsFor(keys, out _); - - return stage1Bindings.Concat(stage2Bindings); + return new DualStageVariantGenerator(getDualStageKeyCount(variant)).GenerateMappings(); } return Array.Empty(); @@ -310,58 +313,20 @@ namespace osu.Game.Rulesets.Mania return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast().OrderByDescending(i => i).First(v => variant >= v); } - private class VariantMappingGenerator + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { - /// - /// All the s available to the left hand. - /// - public InputKey[] LeftKeys; - - /// - /// All the s available to the right hand. - /// - public InputKey[] RightKeys; - - /// - /// The for the special key. - /// - public InputKey SpecialKey; - - /// - /// The at which the normal columns should begin. - /// - public ManiaAction NormalActionStart; - - /// - /// The for the special column. - /// - public ManiaAction SpecialAction; - - /// - /// Generates a list of s for a specific number of columns. - /// - /// The number of columns that need to be bound. - /// The next to use for normal columns. - /// The keybindings. - public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction) + new StatisticRow { - ManiaAction currentNormalAction = NormalActionStart; - - var bindings = new List(); - - for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) - bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++)); - - if (columns % 2 == 1) - bindings.Add(new KeyBinding(SpecialKey, SpecialAction)); - - for (int i = 0; i < columns / 2; i++) - bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++)); - - nextNormalAction = currentNormalAction; - return bindings; + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } } - } + }; } public enum PlayfieldType diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs index 69bd4b0ecf..c0c8505f44 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs @@ -1,19 +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.Rulesets.Mania.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { public class ManiaSkinComponent : GameplaySkinComponent { - public ManiaSkinComponent(ManiaSkinComponents component) + /// + /// The intended index for this component. + /// May be null if the component does not exist in a . + /// + public readonly int? TargetColumn; + + /// + /// Creates a new . + /// + /// The component. + /// The intended index for this component. May be null if the component does not exist in a . + public ManiaSkinComponent(ManiaSkinComponents component, int? targetColumn = null) : base(component) { + TargetColumn = targetColumn; } 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/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index 39185e6a57..cbdcd49c5b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,23 +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.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFadeIn : Mod + public class ManiaModFadeIn : ManiaModHidden { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override IconUsage Icon => OsuIcon.ModHidden; - public override ModType Type => ModType.DifficultyIncrease; + public override IconUsage? Icon => OsuIcon.ModHidden; public override string Description => @"Keys appear out of nowhere!"; - public override double ScoreMultiplier => 1; - public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs index 6893e1e73b..86a00271e9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs @@ -3,8 +3,8 @@ using System; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osuTK; @@ -22,21 +22,13 @@ namespace osu.Game.Rulesets.Mania.Mods private class ManiaFlashlight : Flashlight { - private readonly Cached flashlightProperties = new Cached(); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); public ManiaFlashlight() { FlashlightSize = new Vector2(0, default_flashlight_size); - } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - { - flashlightProperties.Invalidate(); - } - - return base.Invalidate(invalidation, source, shallPropagate); + AddLayout(flashlightProperties); } protected override void Update() diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 66b90984b4..4bdb15526f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -2,15 +2,44 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHidden : ModHidden + public class ManiaModHidden : ModHidden, IApplicableToDrawableRuleset { public override string Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + /// + /// The direction in which the cover should expand. + /// + protected virtual CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; + + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; + + foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) + { + HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; + Container hocParent = (Container)hoc.Parent; + + hocParent.Remove(hoc); + hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => + { + c.RelativeSizeAxes = Axes.Both; + c.Direction = ExpandDirection; + c.Coverage = 0.5f; + })); + } + } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs new file mode 100644 index 0000000000..684370fc3d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModKey10 : ManiaKeyMod + { + public override int KeyCount => 10; + public override string Name => "Ten Keys"; + public override string Acronym => "10K"; + public override string Description => @"Play with ten keys."; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index 9275371a61..699c58c373 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -3,24 +3,17 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModRandom : Mod, IApplicableToBeatmap + public class ManiaModRandom : ModRandom, IApplicableToBeatmap { - public override string Name => "Random"; - public override string Acronym => "RD"; - public override ModType Type => ModType.Conversion; - public override IconUsage Icon => OsuIcon.Dice; public override string Description => @"Shuffle around the keys!"; - public override double ScoreMultiplier => 1; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Objects/BarLine.cs b/osu.Game.Rulesets.Mania/Objects/BarLine.cs index 0981b028b2..09a746042b 100644 --- a/osu.Game.Rulesets.Mania/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/BarLine.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Objects @@ -8,5 +9,7 @@ namespace osu.Game.Rulesets.Mania.Objects public class BarLine : ManiaHitObject, IBarLine { public bool Major { get; set; } + + public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 56bc797c7f..08b5b75f9c 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { } - protected override void UpdateStateTransforms(ArmedState state) - { - } + protected override void UpdateStateTransforms(ArmedState state) => this.FadeOut(150); } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 155adb958b..2262bd2b7d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -20,6 +21,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public override bool DisplayResult => false; + public IBindable IsHitting => isHitting; + + private readonly Bindable isHitting = new Bindable(); + public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child; @@ -27,7 +32,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private readonly Container tailContainer; private readonly Container tickContainer; - private readonly BodyPiece bodyPiece; + private readonly Drawable bodyPiece; /// /// Time at which the user started holding this hold note. Null if the user is not holding this hold note. @@ -44,18 +49,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { RelativeSizeAxes = Axes.X; - AddRangeInternal(new Drawable[] + AddRangeInternal(new[] { - bodyPiece = new BodyPiece { RelativeSizeAxes = Axes.X }, + bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody, hitObject.Column), _ => new DefaultBodyPiece + { + RelativeSizeAxes = Axes.Both + }) + { + RelativeSizeAxes = Axes.X + }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, headContainer = new Container { RelativeSizeAxes = Axes.Both }, tailContainer = new Container { RelativeSizeAxes = Axes.Both }, }); - - AccentColour.BindValueChanged(colour => - { - bodyPiece.AccentColour = colour.NewValue; - }, true); } protected override void AddNestedHitObject(DrawableHitObject hitObject) @@ -124,6 +130,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables bodyPiece.Anchor = bodyPiece.Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; } + public override void PlaySamples() + { + // Samples are played by the head/tail notes. + } + protected override void Update() { base.Update(); @@ -168,20 +179,20 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables return; HoldStartTime = Time.Current; - bodyPiece.Hitting = true; + isHitting.Value = true; } - public bool OnReleased(ManiaAction action) + public void OnReleased(ManiaAction action) { if (AllJudged) - return false; + return; if (action != Action.Value) - return false; + return; // Make sure a hold was started if (HoldStartTime == null) - return false; + return; Tail.UpdateResult(); endHold(); @@ -189,14 +200,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // If the key has been released too early, the user should not receive full score for the release if (!Tail.IsHit) HasBroken = true; - - return true; } private void endHold() { HoldStartTime = null; - bodyPiece.Hitting = false; + isHitting.Value = false; } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index a5d03bf765..a73fe259e4 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -8,6 +8,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableHoldNoteHead : DrawableNote { + protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead; + public DrawableHoldNoteHead(DrawableHoldNote holdNote) : base(holdNote.HitObject.Head) { @@ -17,6 +19,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note - public override bool OnReleased(ManiaAction action) => false; // Handled by the hold note + public override void OnReleased(ManiaAction action) + { + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index a660144dd1..31e43d3ee2 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// private const double release_window_lenience = 1.5; + protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; + private readonly DrawableHoldNote holdNote; public DrawableHoldNoteTail(DrawableHoldNote holdNote) @@ -59,6 +61,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note - public override bool OnReleased(ManiaAction action) => false; // Handled by the hold note + public override void OnReleased(ManiaAction action) + { + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 5bfa07bd14..a44d8b09aa 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -7,16 +7,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Objects.Drawables { public abstract class DrawableManiaHitObject : DrawableHitObject { - /// - /// Whether this should always remain alive. - /// - internal bool AlwaysAlive; - /// /// The which causes this to be hit. /// @@ -24,6 +20,20 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); + [Resolved(canBeNull: true)] + private ManiaPlayfield playfield { get; set; } + + protected override float SamplePlaybackPosition + { + get + { + if (playfield == null) + return base.SamplePlaybackPosition; + + return (float)HitObject.Column / playfield.TotalColumns; + } + } + protected DrawableManiaHitObject(ManiaHitObject hitObject) : base(hitObject) { @@ -39,7 +49,62 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Direction.BindValueChanged(OnDirectionChanged, true); } - protected override bool ShouldBeAlive => AlwaysAlive || base.ShouldBeAlive; + private double computedLifetimeStart; + + public override double LifetimeStart + { + get => base.LifetimeStart; + set + { + computedLifetimeStart = value; + + if (!AlwaysAlive) + base.LifetimeStart = value; + } + } + + private double computedLifetimeEnd; + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + computedLifetimeEnd = value; + + if (!AlwaysAlive) + base.LifetimeEnd = value; + } + } + + private bool alwaysAlive; + + /// + /// Whether this should always remain alive. + /// + internal bool AlwaysAlive + { + get => alwaysAlive; + set + { + if (alwaysAlive == value) + return; + + alwaysAlive = value; + + if (value) + { + // Set the base lifetimes directly, to avoid mangling the computed lifetimes + base.LifetimeStart = double.MinValue; + base.LifetimeEnd = double.MaxValue; + } + else + { + LifetimeStart = computedLifetimeStart; + LifetimeEnd = computedLifetimeEnd; + } + } + } protected virtual void OnDirectionChanged(ValueChangedEvent e) { diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index 8f353ae138..9451bc4430 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -3,13 +3,12 @@ using System.Diagnostics; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -18,7 +17,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler { - private readonly NotePiece headPiece; + protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note; + + private readonly Drawable headPiece; public DrawableNote(Note hitObject) : base(hitObject) @@ -26,22 +27,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - CornerRadius = 5; - Masking = true; - - AddInternal(headPiece = new NotePiece()); - - AccentColour.BindValueChanged(colour => + AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece()) { - headPiece.AccentColour = colour.NewValue; - - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour.NewValue.Lighten(1f).Opacity(0.2f), - Radius = 10, - }; - }, true); + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }); } protected override void OnDirectionChanged(ValueChangedEvent e) @@ -77,6 +67,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables return UpdateResult(true); } - public virtual bool OnReleased(ManiaAction action) => false; + public virtual void OnReleased(ManiaAction action) + { + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs similarity index 66% rename from osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs rename to osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs index 31a4857805..bc4a095395 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs @@ -2,33 +2,47 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Caching; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osuTK.Graphics; 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.Game.Graphics; +using osu.Framework.Layout; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces { /// /// Represents length-wise portion of a hold note. /// - public class BodyPiece : Container, IHasAccentColour + public class DefaultBodyPiece : CompositeDrawable { - private readonly Container subtractionLayer; + protected readonly Bindable AccentColour = new Bindable(); - protected readonly Drawable Background; - protected readonly BufferedContainer Foreground; - private readonly BufferedContainer subtractionContainer; + private readonly LayoutValue subtractionCache = new LayoutValue(Invalidation.DrawSize); + private readonly IBindable isHitting = new Bindable(); - public BodyPiece() + protected Drawable Background { get; private set; } + protected BufferedContainer Foreground { get; private set; } + + private BufferedContainer subtractionContainer; + private Container subtractionLayer; + + public DefaultBodyPiece() { Blending = BlendingParameters.Additive; - Children = new[] + AddLayout(subtractionCache); + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] DrawableHitObject drawableObject) + { + InternalChildren = new[] { Background = new Box { RelativeSizeAxes = Axes.Both }, Foreground = new BufferedContainer @@ -65,49 +79,37 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces } } }; - } - protected override void LoadComplete() - { - base.LoadComplete(); - - updateAccentColour(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set + if (drawableObject != null) { - if (accentColour == value) - return; + var holdNote = (DrawableHoldNote)drawableObject; - accentColour = value; - - updateAccentColour(); + AccentColour.BindTo(drawableObject.AccentColour); + isHitting.BindTo(holdNote.IsHitting); } + + AccentColour.BindValueChanged(onAccentChanged, true); + isHitting.BindValueChanged(_ => onAccentChanged(new ValueChangedEvent(AccentColour.Value, AccentColour.Value)), true); } - public bool Hitting + private void onAccentChanged(ValueChangedEvent accent) { - get => hitting; - set + Foreground.Colour = accent.NewValue.Opacity(0.5f); + Background.Colour = accent.NewValue.Opacity(0.7f); + + const float animation_length = 50; + + Foreground.ClearTransforms(false, nameof(Foreground.Colour)); + + if (isHitting.Value) { - hitting = value; - updateAccentColour(); + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + using (Foreground.BeginDelayedSequence(synchronisedOffset)) + Foreground.FadeColour(accent.NewValue.Lighten(0.2f), animation_length).Then().FadeColour(Foreground.Colour, animation_length).Loop(); } - } - private readonly Cached subtractionCache = new Cached(); - - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - subtractionCache.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); + subtractionCache.Invalidate(); } protected override void Update() @@ -131,30 +133,5 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces subtractionCache.Validate(); } } - - private bool hitting; - - private void updateAccentColour() - { - if (!IsLoaded) - return; - - Foreground.Colour = AccentColour.Opacity(0.5f); - Background.Colour = AccentColour.Opacity(0.7f); - - const float animation_length = 50; - - Foreground.ClearTransforms(false, nameof(Foreground.Colour)); - - if (hitting) - { - // wait for the next sync point - double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); - using (Foreground.BeginDelayedSequence(synchronisedOffset)) - Foreground.FadeColour(AccentColour.Lighten(0.2f), animation_length).Then().FadeColour(Foreground.Colour, animation_length).Loop(); - } - - subtractionCache.Invalidate(); - } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.cs new file mode 100644 index 0000000000..29f5217fd8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osuTK.Graphics; +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.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces +{ + /// + /// Represents the static hit markers of notes. + /// + internal class DefaultNotePiece : CompositeDrawable + { + public const float NOTE_HEIGHT = 12; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + + public DefaultNotePiece() + { + RelativeSizeAxes = Axes.X; + Height = NOTE_HEIGHT; + + CornerRadius = 5; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both + }, + colouredBox = new Box + { + RelativeSizeAxes = Axes.X, + Height = NOTE_HEIGHT / 2, + Alpha = 0.1f + } + }; + } + + [BackgroundDependencyLoader(true)] + private void load([NotNull] IScrollingInfo scrollingInfo, [CanBeNull] 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; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = accent.NewValue.Lighten(0.9f); + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = accent.NewValue.Lighten(1f).Opacity(0.2f), + Radius = 10, + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs deleted file mode 100644 index 1d25a0c966..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs +++ /dev/null @@ -1,68 +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.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - public class GlowPiece : CompositeDrawable, IHasAccentColour - { - private const float glow_alpha = 0.7f; - private const float glow_radius = 5; - - public GlowPiece() - { - RelativeSizeAxes = Axes.Both; - Masking = true; - - InternalChild = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateGlow(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateGlow(); - } - } - - private void updateGlow() - { - if (!IsLoaded) - return; - - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = AccentColour.Opacity(glow_alpha), - Radius = glow_radius, - Hollow = true - }; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.cs deleted file mode 100644 index 48c7ea7b7f..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.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 osuTK.Graphics; -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.Graphics; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - public class LaneGlowPiece : CompositeDrawable, IHasAccentColour - { - private const float total_height = 100; - private const float glow_height = 50; - private const float glow_alpha = 0.4f; - private const float edge_alpha = 0.3f; - - public LaneGlowPiece() - { - BypassAutoSizeAxes = Axes.Both; - RelativeSizeAxes = Axes.X; - Height = total_height; - - InternalChildren = new[] - { - new Container - { - Name = "Left edge", - RelativeSizeAxes = Axes.Y, - Width = 1, - Children = createGradient(edge_alpha) - }, - new Container - { - Name = "Right edge", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 1, - Children = createGradient(edge_alpha) - }, - new Container - { - Name = "Glow", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = glow_height, - Children = createGradient(glow_alpha) - } - }; - } - - private Drawable[] createGradient(float alpha) => new Drawable[] - { - new Box - { - Name = "Top", - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Blending = BlendingParameters.Additive, - Colour = ColourInfo.GradientVertical(Color4.Transparent, Color4.White.Opacity(alpha)) - }, - new Box - { - Name = "Bottom", - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Blending = BlendingParameters.Additive, - Colour = ColourInfo.GradientVertical(Color4.White.Opacity(alpha), Color4.Transparent) - } - }; - - public Color4 AccentColour - { - get => Colour; - set => Colour = value; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs deleted file mode 100644 index 4521af7dfb..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osuTK.Graphics; -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.Rulesets.UI.Scrolling; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - /// - /// Represents the static hit markers of notes. - /// - internal class NotePiece : Container, IHasAccentColour - { - public const float NOTE_HEIGHT = 12; - - private readonly IBindable direction = new Bindable(); - - private readonly Box colouredBox; - - public NotePiece() - { - RelativeSizeAxes = Axes.X; - Height = NOTE_HEIGHT; - - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both - }, - colouredBox = new Box - { - RelativeSizeAxes = Axes.X, - Height = NOTE_HEIGHT / 2, - Alpha = 0.1f - } - }; - } - - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) - { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - colouredBox.Anchor = colouredBox.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; - }, true); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - colouredBox.Colour = AccentColour.Lighten(0.9f); - } - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index bdba813eed..a100c9a58e 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -13,9 +15,13 @@ namespace osu.Game.Rulesets.Mania.Objects /// /// Represents a hit object which requires pressing, holding, and releasing a key. /// - public class HoldNote : ManiaHitObject, IHasEndTime + public class HoldNote : ManiaHitObject, IHasDuration { - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } private double duration; @@ -25,7 +31,9 @@ namespace osu.Game.Rulesets.Mania.Objects set { duration = value; - Tail.StartTime = EndTime; + + if (Tail != null) + Tail.StartTime = EndTime; } } @@ -35,8 +43,12 @@ namespace osu.Game.Rulesets.Mania.Objects set { base.StartTime = value; - Head.StartTime = value; - Tail.StartTime = EndTime; + + if (Head != null) + Head.StartTime = value; + + if (Tail != null) + Tail.StartTime = EndTime; } } @@ -46,20 +58,26 @@ namespace osu.Game.Rulesets.Mania.Objects set { base.Column = value; - Head.Column = value; - Tail.Column = value; + + if (Head != null) + Head.Column = value; + + if (Tail != null) + Tail.Column = value; } } + public List> NodeSamples { get; set; } + /// /// The head note of the hold. /// - public readonly Note Head = new Note(); + public Note Head { get; private set; } /// /// The tail note of the hold. /// - public readonly TailNote Tail = new TailNote(); + public TailNote Tail { get; private set; } /// /// The time between ticks of this hold. @@ -74,23 +92,36 @@ namespace osu.Game.Rulesets.Mania.Objects tickSpacing = timingPoint.BeatLength / difficulty.SliderTickRate; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); - createTicks(); + createTicks(cancellationToken); - AddNested(Head); - AddNested(Tail); + AddNested(Head = new Note + { + StartTime = StartTime, + Column = Column, + Samples = getNodeSamples(0), + }); + + AddNested(Tail = new TailNote + { + StartTime = EndTime, + Column = Column, + Samples = getNodeSamples((NodeSamples?.Count - 1) ?? 1), + }); } - private void createTicks() + private void createTicks(CancellationToken cancellationToken) { if (tickSpacing == 0) return; for (double t = StartTime + tickSpacing; t <= EndTime - tickSpacing; t += tickSpacing) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new HoldNoteTick { StartTime = t, @@ -99,8 +130,11 @@ namespace osu.Game.Rulesets.Mania.Objects } } - public override Judgement CreateJudgement() => new HoldNoteJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + private IList getNodeSamples(int nodeIndex) => + nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples; } } diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 995e1516cb..27bf50493d 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -5,11 +5,12 @@ using osu.Framework.Bindables; using osu.Game.Rulesets.Mania.Objects.Types; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects { - public abstract class ManiaHitObject : HitObject, IHasColumn + public abstract class ManiaHitObject : HitObject, IHasColumn, IHasXPosition { public readonly Bindable ColumnBindable = new Bindable(); @@ -20,5 +21,11 @@ namespace osu.Game.Rulesets.Mania.Objects } protected override HitWindows CreateHitWindows() => new ManiaHitWindows(); + + #region LegacyBeatmapEncoder + + float IHasXPosition.X => Column; + + #endregion } } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs index 877a9ee410..dbab54d1d0 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.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.Game.Beatmaps; using osu.Game.Replays.Legacy; @@ -24,15 +25,9 @@ namespace osu.Game.Rulesets.Mania.Replays Actions.AddRange(actions); } - public void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - // We don't need to fully convert, just create the converter - var converter = new ManiaBeatmapConverter(beatmap, new ManiaRuleset()); - - // NB: Via co-op mod, osu-stable can have two stages with floor(col/2) and ceil(col/2) columns. This will need special handling - // elsewhere in the game if we do choose to support the old co-op mod anyway. For now, assume that there is only one stage. - - var stage = new StageDefinition { Columns = converter.TargetColumns }; + var maniaBeatmap = (ManiaBeatmap)beatmap; var normalAction = ManiaAction.Key1; var specialAction = ManiaAction.Special1; @@ -42,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Replays while (activeColumns > 0) { - var isSpecial = stage.IsSpecialColumn(counter); + bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter); if ((activeColumns & 1) > 0) Actions.Add(isSpecial ? specialAction : normalAction); @@ -56,5 +51,94 @@ namespace osu.Game.Rulesets.Mania.Replays activeColumns >>= 1; } } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = 0; + + foreach (var action in Actions) + { + switch (action) + { + case ManiaAction.Special1: + keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0); + break; + + case ManiaAction.Special2: + keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1); + break; + + default: + // the index in lazer, which doesn't include special keys. + int nonSpecialKeyIndex = action - ManiaAction.Key1; + + // the index inclusive of special keys. + int overallIndex = 0; + + // iterate to find the index including special keys. + for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++) + { + // skip over special columns. + if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex)) + continue; + // found a non-special column to use. + if (nonSpecialKeyIndex == 0) + break; + // found a non-special column but not ours. + nonSpecialKeyIndex--; + } + + keys |= 1 << overallIndex; + break; + } + } + + return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None); + } + + /// + /// Find the overall index (across all stages) for a specified special key. + /// + /// The beatmap. + /// The special key offset (0 is S1). + /// The overall index for the special column. + private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset) + { + for (int i = 0; i < maniaBeatmap.TotalColumns; i++) + { + if (isColumnAtIndexSpecial(maniaBeatmap, i)) + { + if (specialOffset == 0) + return i; + + specialOffset--; + } + } + + throw new ArgumentException("Special key index is too high.", nameof(specialOffset)); + } + + /// + /// Check whether the column at an overall index (across all stages) is a special column. + /// + /// The beatmap. + /// The overall index to check. + private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index) + { + foreach (var stage in beatmap.Stages) + { + if (index >= stage.Columns) + { + index -= stage.Columns; + continue; + } + + return stage.IsSpecialColumn(index); + } + + throw new ArgumentException("Column index is too high.", nameof(index)); + } } } diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json new file mode 100644 index 0000000000..fec1360b26 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -0,0 +1,33 @@ +{ + "Mappings": [{ + "StartTime": 1000.0, + "Objects": [{ + "StartTime": 1000.0, + "EndTime": 2750.0, + "Column": 1, + "NodeSamples": [ + ["normal-hitnormal"], + ["soft-hitnormal"], + ["drum-hitnormal"] + ], + "Samples": ["drum-hitnormal"] + }, { + "StartTime": 1875.0, + "EndTime": 2750.0, + "Column": 0, + "NodeSamples": [ + ["soft-hitnormal"], + ["drum-hitnormal"] + ], + "Samples": ["drum-hitnormal"] + }] + }, { + "StartTime": 3750.0, + "Objects": [{ + "StartTime": 3750.0, + "EndTime": 3750.0, + "Column": 3, + "Samples": ["normal-hitnormal"] + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu new file mode 100644 index 0000000000..fea1de6614 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu @@ -0,0 +1,16 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:5 +CircleSize:5 +OverallDifficulty:5 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[TimingPoints] +0,500,4,1,0,100,1,0 + +[HitObjects] +88,99,1000,6,0,L|306:259,2,245,0|0|0,1:0|2:0|3:0,0:0:0:0: +259,118,3750,1,0,1:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json new file mode 100644 index 0000000000..1aca75a796 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json @@ -0,0 +1,27 @@ +{ + "Mappings": [{ + "StartTime": 500.0, + "Objects": [{ + "StartTime": 500.0, + "EndTime": 1500.0, + "Column": 0, + "NodeSamples": [ + ["normal-hitnormal"], + [] + ], + "Samples": ["normal-hitnormal"] + }] + }, { + "StartTime": 2000.0, + "Objects": [{ + "StartTime": 2000.0, + "EndTime": 3000.0, + "Column": 2, + "NodeSamples": [ + ["drum-hitnormal"], + [] + ], + "Samples": ["drum-hitnormal"] + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu new file mode 100644 index 0000000000..7c75b45e5f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu @@ -0,0 +1,19 @@ +osu file format v14 + +[General] +Mode: 3 + +[Difficulty] +HPDrainRate:5 +CircleSize:5 +OverallDifficulty:5 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[TimingPoints] +0,500,4,1,0,100,1,0 + +[HitObjects] +51,192,500,128,0,1500:1:0:0:0: +256,192,2000,128,0,3000:3:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json new file mode 100644 index 0000000000..e3768a90d7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json @@ -0,0 +1,21 @@ +{ + "Mappings": [{ + "StartTime": 8470.0, + "Objects": [{ + "StartTime": 8470.0, + "EndTime": 8470.0, + "Column": 0, + "Samples": ["normal-hitnormal", "normal-hitclap"] + }, { + "StartTime": 8626.470587768974, + "EndTime": 8626.470587768974, + "Column": 1, + "Samples": ["normal-hitnormal"] + }, { + "StartTime": 8782.941175537948, + "EndTime": 8782.941175537948, + "Column": 2, + "Samples": ["normal-hitnormal", "normal-hitclap"] + }] + }] +} diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu new file mode 100644 index 0000000000..08e90ce807 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu @@ -0,0 +1,15 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.5 +SliderMultiplier:2.00000000596047 +SliderTickRate:1 + +[TimingPoints] +0,312.941176470588,4,1,0,100,1,0 + +[HitObjects] +82,216,8470,6,0,P|52:161|99:113,2,100,8|0|8,1:0|1:0|1:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index 549f0f9214..289f8a00ef 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -7,5 +7,20 @@ namespace osu.Game.Rulesets.Mania.Scoring { public class ManiaHitWindows : HitWindows { + public override bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + case HitResult.Great: + case HitResult.Good: + case HitResult.Ok: + case HitResult.Meh: + case HitResult.Miss: + return true; + } + + return false; + } } } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 9b54b48de3..ba84c21845 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Mania.Scoring { internal class ManiaScoreProcessor : ScoreProcessor { + protected override double DefaultAccuracyPortion => 0.8; + + protected override double DefaultComboPortion => 0.2; + public override HitWindows CreateHitWindows() => new ManiaHitWindows(); } } diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs new file mode 100644 index 0000000000..2069329d9a --- /dev/null +++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.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.Collections.Generic; +using osu.Framework.Input.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class SingleStageVariantGenerator + { + private readonly int variant; + private readonly InputKey[] leftKeys; + private readonly InputKey[] rightKeys; + + public SingleStageVariantGenerator(int variant) + { + this.variant = variant; + + // 10K is special because it expands towards the centre of the keyboard (V/N), rather than towards the edges of the keyboard. + if (variant == 10) + { + leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F, InputKey.V }; + rightKeys = new[] { InputKey.N, InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + else + { + leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F }; + rightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + } + + public IEnumerable GenerateMappings() => new VariantMappingGenerator + { + LeftKeys = leftKeys, + RightKeys = rightKeys, + SpecialKey = InputKey.Space, + SpecialAction = ManiaAction.Special1, + NormalActionStart = ManiaAction.Key1, + }.GenerateKeyBindingsFor(variant, out _); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs new file mode 100644 index 0000000000..9f716428c0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyBodyPiece : LegacyManiaColumnElement + { + private readonly IBindable direction = new Bindable(); + private readonly IBindable isHitting = new Bindable(); + + private Drawable sprite; + + public LegacyBodyPiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo, DrawableHitObject drawableObject) + { + string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage)?.Value + ?? $"mania-note{FallbackColumnIndex}L"; + + sprite = skin.GetAnimation(imageName, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true).With(d => + { + if (d == null) + return; + + if (d is TextureAnimation animation) + animation.IsPlaying = false; + + d.Anchor = Anchor.TopCentre; + d.RelativeSizeAxes = Axes.Both; + d.Size = Vector2.One; + d.FillMode = FillMode.Stretch; + // Todo: Wrap + }); + + if (sprite != null) + InternalChild = sprite; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + var holdNote = (DrawableHoldNote)drawableObject; + isHitting.BindTo(holdNote.IsHitting); + isHitting.BindValueChanged(onIsHittingChanged, true); + } + + private void onIsHittingChanged(ValueChangedEvent isHitting) + { + if (!(sprite is TextureAnimation animation)) + return; + + animation.GotoFrame(0); + animation.IsPlaying = isHitting.NewValue; + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (sprite == null) + return; + + if (direction.NewValue == ScrollingDirection.Up) + { + sprite.Origin = Anchor.BottomCentre; + sprite.Scale = new Vector2(1, -1); + } + else + { + sprite.Origin = Anchor.TopCentre; + sprite.Scale = Vector2.One; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.cs new file mode 100644 index 0000000000..64a7641421 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.cs @@ -0,0 +1,143 @@ +// Copyright (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.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyColumnBackground : LegacyManiaColumnElement, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + private readonly bool isLastColumn; + + private Container lightContainer; + private Sprite light; + + public LegacyColumnBackground(bool isLastColumn) + { + this.isLastColumn = isLastColumn; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string lightImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LightImage)?.Value + ?? "mania-stage-light"; + + float leftLineWidth = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.LeftLineWidth) + ?.Value ?? 1; + float rightLineWidth = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.RightLineWidth) + ?.Value ?? 1; + + bool hasLeftLine = leftLineWidth > 0; + bool hasRightLine = rightLineWidth > 0 && skin.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value >= 2.4m + || isLastColumn; + + float lightPosition = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.LightPosition)?.Value + ?? 0; + + Color4 lineColour = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ColumnLineColour)?.Value + ?? Color4.White; + + Color4 backgroundColour = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour)?.Value + ?? Color4.Black; + + Color4 lightColour = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ColumnLightColour)?.Value + ?? Color4.White; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = leftLineWidth, + Scale = new Vector2(0.740f, 1), + Colour = lineColour, + Alpha = hasLeftLine ? 1 : 0 + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = rightLineWidth, + Scale = new Vector2(0.740f, 1), + Colour = lineColour, + Alpha = hasRightLine ? 1 : 0 + }, + lightContainer = new Container + { + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = lightPosition }, + Child = light = new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = lightColour, + Texture = skin.GetTexture(lightImage), + RelativeSizeAxes = Axes.X, + Width = 1, + Alpha = 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + lightContainer.Anchor = Anchor.TopCentre; + lightContainer.Scale = new Vector2(1, -1); + } + else + { + lightContainer.Anchor = Anchor.BottomCentre; + lightContainer.Scale = Vector2.One; + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == Column.Action.Value) + { + light.FadeIn(); + light.ScaleTo(Vector2.One); + } + + return false; + } + + public void OnReleased(ManiaAction action) + { + // Todo: Should be 400 * 100 / CurrentBPM + const double animation_length = 250; + + if (action == Column.Action.Value) + { + light.FadeTo(0, animation_length); + light.ScaleTo(new Vector2(1, 0), animation_length); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs new file mode 100644 index 0000000000..bc93bb2615 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHitExplosion : LegacyManiaColumnElement + { + private readonly IBindable direction = new Bindable(); + + private Drawable explosion; + + public LegacyHitExplosion() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ExplosionImage)?.Value + ?? "lightingN"; + + float explosionScale = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ExplosionScale)?.Value + ?? 1; + + // Create a temporary animation to retrieve the number of frames, in an effort to calculate the intended frame length. + // This animation is discarded and re-queried with the appropriate frame length afterwards. + var tmp = skin.GetAnimation(imageName, true, false); + double frameLength = 0; + if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0) + frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount); + + explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength).With(d => + { + if (d == null) + return; + + d.Origin = Anchor.Centre; + d.Blending = BlendingParameters.Additive; + d.Scale = new Vector2(explosionScale); + }); + + if (explosion != null) + InternalChild = explosion; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (explosion != null) + explosion.Anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + explosion?.FadeInFromZero(80) + .Then().FadeOut(120); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.cs new file mode 100644 index 0000000000..d055ef3480 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.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.Framework.Graphics.Sprites; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHitTarget : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + + public LegacyHitTarget() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string targetImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitTargetImage)?.Value + ?? "mania-stage-hint"; + + bool showJudgementLine = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ShowJudgementLine)?.Value + ?? true; + + Color4 lineColour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.JudgementLineColour)?.Value + ?? Color4.White; + + InternalChild = directionContainer = new Container + { + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture(targetImage), + Scale = new Vector2(1, 0.9f * 1.6025f), + RelativeSizeAxes = Axes.X, + Width = 1 + }, + new Box + { + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = lineColour, + Alpha = showJudgementLine ? 0.9f : 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = Anchor.TopLeft; + directionContainer.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = Anchor.BottomLeft; + directionContainer.Scale = Vector2.One; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.cs new file mode 100644 index 0000000000..c5aa062d0f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Textures; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHoldNoteHeadPiece : LegacyNotePiece + { + protected override Texture GetTexture(ISkinSource skin) + { + // TODO: Should fallback to the head from default legacy skin instead of note. + return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.cs new file mode 100644 index 0000000000..2e8259f10a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.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.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHoldNoteTailPiece : LegacyNotePiece + { + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + // Invert the direction + base.OnDirectionChanged(direction.NewValue == ScrollingDirection.Up + ? new ValueChangedEvent(ScrollingDirection.Down, ScrollingDirection.Down) + : new ValueChangedEvent(ScrollingDirection.Up, ScrollingDirection.Up)); + } + + protected override Texture GetTexture(ISkinSource skin) + { + // TODO: Should fallback to the head from default legacy skin instead of note. + return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteTailImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.cs new file mode 100644 index 0000000000..44f3e7d7b3 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyKeyArea : LegacyManiaColumnElement, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Sprite upSprite; + private Sprite downSprite; + + [Resolved] + private Column column { get; set; } + + public LegacyKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string upImage = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.KeyImage)?.Value + ?? $"mania-key{FallbackColumnIndex}"; + + string downImage = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.KeyImageDown)?.Value + ?? $"mania-key{FallbackColumnIndex}D"; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + upSprite = new Sprite + { + Origin = Anchor.BottomCentre, + Texture = skin.GetTexture(upImage), + RelativeSizeAxes = Axes.X, + Width = 1 + }, + downSprite = new Sprite + { + Origin = Anchor.BottomCentre, + Texture = skin.GetTexture(downImage), + RelativeSizeAxes = Axes.X, + Width = 1, + Alpha = 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = directionContainer.Origin = Anchor.TopCentre; + upSprite.Anchor = downSprite.Anchor = Anchor.TopCentre; + upSprite.Scale = downSprite.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = directionContainer.Origin = Anchor.BottomCentre; + upSprite.Anchor = downSprite.Anchor = Anchor.BottomCentre; + upSprite.Scale = downSprite.Scale = Vector2.One; + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + { + upSprite.FadeTo(0); + downSprite.FadeTo(1); + } + + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + { + upSprite.FadeTo(1); + downSprite.FadeTo(0); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.cs new file mode 100644 index 0000000000..3c0c632c14 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + /// + /// A which is placed somewhere within a . + /// + public class LegacyManiaColumnElement : CompositeDrawable + { + [Resolved] + protected Column Column { get; private set; } + + /// + /// The column type identifier to use for texture lookups, in the case of no user-provided configuration. + /// + protected string FallbackColumnIndex { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + switch (Column.ColumnType) + { + case ColumnType.Special: + FallbackColumnIndex = "S"; + break; + + case ColumnType.Odd: + FallbackColumnIndex = "1"; + break; + + case ColumnType.Even: + FallbackColumnIndex = "2"; + break; + } + } + + protected IBindable GetColumnSkinConfig(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + => skin.GetManiaSkinConfig(lookup, Column.Index); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs new file mode 100644 index 0000000000..283b04373b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs @@ -0,0 +1,99 @@ +// Copyright (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.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyNotePiece : LegacyManiaColumnElement + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Sprite noteSprite; + + private float? minimumColumnWidth; + + public LegacyNotePiece() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + minimumColumnWidth = skin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.MinimumColumnWidth))?.Value; + + InternalChild = directionContainer = new Container + { + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = noteSprite = new Sprite { Texture = GetTexture(skin) } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(OnDirectionChanged, true); + } + + protected override void Update() + { + base.Update(); + + if (noteSprite.Texture != null) + { + // The height is scaled to the minimum column width, if provided. + float minimumWidth = minimumColumnWidth ?? DrawWidth; + + noteSprite.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), noteSprite.Texture.DisplayWidth); + } + } + + protected virtual void OnDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Scale = Vector2.One; + } + } + + protected virtual Texture GetTexture(ISkinSource skin) => GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + + protected Texture GetTextureFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + { + string suffix = string.Empty; + + switch (lookup) + { + case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: + suffix = "H"; + break; + + case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: + suffix = "T"; + break; + } + + string noteImage = GetColumnSkinConfig(skin, lookup)?.Value + ?? $"mania-note{FallbackColumnIndex}{suffix}"; + + return skin.GetTexture(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.cs new file mode 100644 index 0000000000..7f5de601ca --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyStageBackground : CompositeDrawable + { + private Drawable leftSprite; + private Drawable rightSprite; + + public LegacyStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + string leftImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftStageImage)?.Value + ?? "mania-stage-left"; + + string rightImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.RightStageImage)?.Value + ?? "mania-stage-right"; + + InternalChildren = new[] + { + leftSprite = new Sprite + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopRight, + X = 0.05f, + Texture = skin.GetTexture(leftImage), + }, + rightSprite = new Sprite + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopLeft, + X = -0.05f, + Texture = skin.GetTexture(rightImage) + } + }; + } + + protected override void Update() + { + base.Update(); + + if (leftSprite?.Height > 0) + leftSprite.Scale = new Vector2(1, DrawHeight / leftSprite.Height); + + if (rightSprite?.Height > 0) + rightSprite.Scale = new Vector2(1, DrawHeight / rightSprite.Height); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.cs new file mode 100644 index 0000000000..4609fcc849 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyStageForeground : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + private Drawable sprite; + + public LegacyStageForeground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string bottomImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BottomStageImage)?.Value + ?? "mania-stage-bottom"; + + sprite = skin.GetAnimation(bottomImage, true, true)?.With(d => + { + if (d == null) + return; + + d.Scale = new Vector2(1.6f); + }); + + if (sprite != null) + InternalChild = sprite; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (sprite == null) + return; + + if (direction.NewValue == ScrollingDirection.Up) + sprite.Anchor = sprite.Origin = Anchor.TopCentre; + else + sprite.Anchor = sprite.Origin = Anchor.BottomCentre; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs index f3739ce7c2..e167135556 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs @@ -1,67 +1,152 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; -using osu.Framework.Graphics.Textures; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Game.Rulesets.Scoring; -using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Skinning; +using System.Collections.Generic; +using osu.Framework.Audio.Sample; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects.Legacy; namespace osu.Game.Rulesets.Mania.Skinning { - public class ManiaLegacySkinTransformer : ISkin + public class ManiaLegacySkinTransformer : LegacySkinTransformer { - private readonly ISkin source; + private readonly ManiaBeatmap beatmap; - public ManiaLegacySkinTransformer(ISkin source) + /// + /// Mapping of to their corresponding + /// value. + /// + private static readonly IReadOnlyDictionary hitresult_mapping + = new Dictionary + { + { HitResult.Perfect, LegacyManiaSkinConfigurationLookups.Hit300g }, + { HitResult.Great, LegacyManiaSkinConfigurationLookups.Hit300 }, + { HitResult.Good, LegacyManiaSkinConfigurationLookups.Hit200 }, + { HitResult.Ok, LegacyManiaSkinConfigurationLookups.Hit100 }, + { HitResult.Meh, LegacyManiaSkinConfigurationLookups.Hit50 }, + { HitResult.Miss, LegacyManiaSkinConfigurationLookups.Hit0 } + }; + + /// + /// Mapping of to their corresponding + /// default filenames. + /// + private static readonly IReadOnlyDictionary default_hitresult_skin_filenames + = new Dictionary + { + { HitResult.Perfect, "mania-hit300g" }, + { HitResult.Great, "mania-hit300" }, + { HitResult.Good, "mania-hit200" }, + { HitResult.Ok, "mania-hit100" }, + { HitResult.Meh, "mania-hit50" }, + { HitResult.Miss, "mania-hit0" } + }; + + private Lazy isLegacySkin; + + /// + /// Whether texture for the keys exists. + /// Used to determine if the mania ruleset is skinned. + /// + private Lazy hasKeyTexture; + + public ManiaLegacySkinTransformer(ISkinSource source, IBeatmap beatmap) + : base(source) { - this.source = source; + this.beatmap = (ManiaBeatmap)beatmap; + + Source.SourceChanged += sourceChanged; + sourceChanged(); } - public Drawable GetDrawableComponent(ISkinComponent component) + private void sourceChanged() + { + isLegacySkin = new Lazy(() => Source.GetConfig(LegacySkinConfiguration.LegacySetting.Version) != null); + hasKeyTexture = new Lazy(() => Source.GetAnimation( + this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.KeyImage, 0)?.Value + ?? "mania-key1", true, true) != null); + } + + public override Drawable GetDrawableComponent(ISkinComponent component) { switch (component) { case GameplaySkinComponent resultComponent: - return getResult(resultComponent); + return getResult(resultComponent.Component); + + case ManiaSkinComponent maniaComponent: + if (!isLegacySkin.Value || !hasKeyTexture.Value) + return null; + + switch (maniaComponent.Component) + { + case ManiaSkinComponents.ColumnBackground: + return new LegacyColumnBackground(maniaComponent.TargetColumn == beatmap.TotalColumns - 1); + + case ManiaSkinComponents.HitTarget: + return new LegacyHitTarget(); + + case ManiaSkinComponents.KeyArea: + return new LegacyKeyArea(); + + case ManiaSkinComponents.Note: + return new LegacyNotePiece(); + + case ManiaSkinComponents.HoldNoteHead: + return new LegacyHoldNoteHeadPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new LegacyHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteBody: + return new LegacyBodyPiece(); + + case ManiaSkinComponents.HitExplosion: + return new LegacyHitExplosion(); + + case ManiaSkinComponents.StageBackground: + return new LegacyStageBackground(); + + case ManiaSkinComponents.StageForeground: + return new LegacyStageForeground(); + } + + break; } return null; } - private Drawable getResult(GameplaySkinComponent resultComponent) + private Drawable getResult(HitResult result) { - switch (resultComponent.Component) - { - case HitResult.Miss: - return this.GetAnimation("mania-hit0", true, true); + string filename = this.GetManiaSkinConfig(hitresult_mapping[result])?.Value + ?? default_hitresult_skin_filenames[result]; - case HitResult.Meh: - return this.GetAnimation("mania-hit50", true, true); - - case HitResult.Ok: - return this.GetAnimation("mania-hit100", true, true); - - case HitResult.Good: - return this.GetAnimation("mania-hit200", true, true); - - case HitResult.Great: - return this.GetAnimation("mania-hit300", true, true); - - case HitResult.Perfect: - return this.GetAnimation("mania-hit300g", true, true); - } - - return null; + return this.GetAnimation(filename, true, true); } - public Texture GetTexture(string componentName) => source.GetTexture(componentName); + public override SampleChannel GetSample(ISampleInfo sampleInfo) + { + // layered hit sounds never play in mania + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) + return new SampleChannelVirtual(); - public SampleChannel GetSample(ISampleInfo sample) => source.GetSample(sample); + return Source.GetSample(sampleInfo); + } - public IBindable GetConfig(TLookup lookup) => - source.GetConfig(lookup); + public override IBindable GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + return Source.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); + + return Source.GetConfig(lookup); + } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs new file mode 100644 index 0000000000..2e17a6bef1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public static class ManiaSkinConfigExtensions + { + /// + /// Retrieve a per-column-count skin configuration. + /// + /// 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)); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs new file mode 100644 index 0000000000..f07a5518b7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.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.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class ManiaSkinConfigurationLookup + { + /// + /// The configuration lookup value. + /// + public readonly LegacyManiaSkinConfigurationLookups Lookup; + + /// + /// The intended index for the configuration. + /// May be null if the configuration does not apply to a . + /// + public readonly int? TargetColumn; + + /// + /// 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) + { + Lookup = lookup; + TargetColumn = targetColumn; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 3d2a070b0f..642353bd0b 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -12,17 +12,19 @@ using osu.Framework.Bindables; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; using osuTK; +using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.UI { + [Cached] public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasAccentColour { public const float COLUMN_WIDTH = 80; - private const float special_column_width = 70; + public const float SPECIAL_COLUMN_WIDTH = 70; /// /// The index of this column as part of the whole playfield. @@ -31,12 +33,11 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); - private readonly ColumnBackground background; - private readonly ColumnKeyArea keyArea; - private readonly ColumnHitObjectArea hitObjectArea; + public readonly ColumnHitObjectArea HitObjectArea; internal readonly Container TopLevelContainer; - private readonly Container explosionContainer; + + public Container UnderlayElements => HitObjectArea.UnderlayElements; public Column(int index) { @@ -45,95 +46,34 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y; Width = COLUMN_WIDTH; - background = new ColumnBackground { RelativeSizeAxes = Axes.Both }; - - Container hitTargetContainer; + Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, Index), _ => new DefaultColumnBackground()) + { + RelativeSizeAxes = Axes.Both + }; InternalChildren = new[] { // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - hitTargetContainer = new Container + HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea()) { - Name = "Hit target + hit objects", - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - hitObjectArea = new ColumnHitObjectArea(HitObjectContainer) - { - RelativeSizeAxes = Axes.Both, - }, - explosionContainer = new Container - { - Name = "Hit explosions", - RelativeSizeAxes = Axes.Both, - } - } - }, - keyArea = new ColumnKeyArea - { - RelativeSizeAxes = Axes.X, - Height = ManiaStage.HIT_TARGET_POSITION, + RelativeSizeAxes = Axes.Both }, background, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; - TopLevelContainer.Add(explosionContainer.CreateProxy()); - - Direction.BindValueChanged(dir => - { - hitTargetContainer.Padding = new MarginPadding - { - Top = dir.NewValue == ScrollingDirection.Up ? ManiaStage.HIT_TARGET_POSITION : 0, - Bottom = dir.NewValue == ScrollingDirection.Down ? ManiaStage.HIT_TARGET_POSITION : 0, - }; - - explosionContainer.Padding = new MarginPadding - { - Top = dir.NewValue == ScrollingDirection.Up ? NotePiece.NOTE_HEIGHT / 2 : 0, - Bottom = dir.NewValue == ScrollingDirection.Down ? NotePiece.NOTE_HEIGHT / 2 : 0 - }; - - keyArea.Anchor = keyArea.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - }, true); + TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); } public override Axes RelativeSizeAxes => Axes.Y; - private bool isSpecial; + public ColumnType ColumnType { get; set; } - public bool IsSpecial - { - get => isSpecial; - set - { - if (isSpecial == value) - return; + public bool IsSpecial => ColumnType == ColumnType.Special; - isSpecial = value; - - Width = isSpecial ? special_column_width : COLUMN_WIDTH; - } - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - background.AccentColour = value; - keyArea.AccentColour = value; - hitObjectArea.AccentColour = value; - } - } + public Color4 AccentColour { get; set; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -168,11 +108,15 @@ namespace osu.Game.Rulesets.Mania.UI if (!result.IsHit || !judgedObject.DisplayResult || !DisplayJudgements.Value) return; - explosionContainer.Add(new HitExplosion(judgedObject.AccentColour.Value, judgedObject is DrawableHoldNoteTick) + var explosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, Index), _ => + new DefaultHitExplosion(judgedObject.AccentColour.Value, judgedObject is DrawableHoldNoteTick)) { - Anchor = Direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre, - Origin = Anchor.Centre - }); + RelativeSizeAxes = Axes.Both + }; + + HitObjectArea.Explosions.Add(explosion); + + explosion.Delay(200).Expire(true); } public bool OnPressed(ManiaAction action) @@ -191,10 +135,12 @@ namespace osu.Game.Rulesets.Mania.UI return true; } - public bool OnReleased(ManiaAction action) => false; + public void OnReleased(ManiaAction action) + { + } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border - => DrawRectangle.Inflate(new Vector2(ManiaStage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs index 57241da564..75cc351310 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs @@ -98,11 +98,10 @@ namespace osu.Game.Rulesets.Mania.UI.Components return false; } - public bool OnReleased(ManiaAction action) + public void OnReleased(ManiaAction action) { if (action == this.action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); - return false; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index ee2cec1bbd..b365ae45a9 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -1,145 +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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osuTK.Graphics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public class ColumnHitObjectArea : CompositeDrawable, IHasAccentColour + public class ColumnHitObjectArea : HitObjectArea { - private const float hit_target_bar_height = 2; + public readonly Container Explosions; - private readonly IBindable direction = new Bindable(); + public readonly Container UnderlayElements; private readonly Drawable hitTarget; - public ColumnHitObjectArea(HitObjectContainer hitObjectContainer) + public ColumnHitObjectArea(int columnIndex, HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) { - InternalChildren = new[] + AddRangeInternal(new[] { - hitTarget = new DefaultHitTarget + UnderlayElements = new Container + { + RelativeSizeAxes = Axes.Both, + Depth = 2, + }, + hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget()) { RelativeSizeAxes = Axes.X, + Depth = 1 }, - hitObjectContainer - }; - } - - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) - { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - Anchor anchor = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - - hitTarget.Anchor = hitTarget.Origin = anchor; - }, true); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - if (hitTarget is IHasAccentColour colouredHitTarget) - colouredHitTarget.AccentColour = accentColour; - } - } - - private class DefaultHitTarget : CompositeDrawable, IHasAccentColour - { - private readonly IBindable direction = new Bindable(); - - private readonly Container hitTargetLine; - private readonly Drawable hitTargetBar; - - public DefaultHitTarget() - { - InternalChildren = new[] + Explosions = new Container { - hitTargetBar = new Box - { - RelativeSizeAxes = Axes.X, - Height = NotePiece.NOTE_HEIGHT, - Alpha = 0.6f, - Colour = Color4.Black - }, - hitTargetLine = new Container - { - RelativeSizeAxes = Axes.X, - Height = hit_target_bar_height, - Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - }; - } - - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) - { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - Anchor anchor = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - - hitTargetBar.Anchor = hitTargetBar.Origin = anchor; - hitTargetLine.Anchor = hitTargetLine.Origin = anchor; - }, true); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); + RelativeSizeAxes = Axes.Both, + Depth = -1, } - } + }); + } - private void updateColours() - { - if (!IsLoaded) - return; + protected override void UpdateHitPosition() + { + base.UpdateHitPosition(); - hitTargetLine.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = accentColour.Opacity(0.5f), - }; - } + if (Direction.Value == ScrollingDirection.Up) + hitTarget.Anchor = hitTarget.Origin = Anchor.TopLeft; + else + hitTarget.Anchor = hitTarget.Origin = Anchor.BottomLeft; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs deleted file mode 100644 index 85880222d7..0000000000 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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.Input.Bindings; -using osu.Game.Graphics; -using osu.Game.Rulesets.UI.Scrolling; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Mania.UI.Components -{ - public class ColumnKeyArea : CompositeDrawable, IKeyBindingHandler, IHasAccentColour - { - private const float key_icon_size = 10; - private const float key_icon_corner_radius = 3; - - private readonly IBindable action = new Bindable(); - private readonly IBindable direction = new Bindable(); - - private Container keyIcon; - - [BackgroundDependencyLoader] - private void load(IBindable action, IScrollingInfo scrollingInfo) - { - this.action.BindTo(action); - - Drawable gradient; - - InternalChildren = new[] - { - gradient = new Box - { - Name = "Key gradient", - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f - }, - keyIcon = new Container - { - Name = "Key icon", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(key_icon_size), - Masking = true, - CornerRadius = key_icon_corner_radius, - BorderThickness = 2, - BorderColour = Color4.White, // Not true - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } - }; - - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - gradient.Colour = ColourInfo.GradientVertical( - dir.NewValue == ScrollingDirection.Up ? Color4.Black : Color4.Black.Opacity(0), - dir.NewValue == ScrollingDirection.Up ? Color4.Black.Opacity(0) : Color4.Black); - }, true); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); - } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - keyIcon.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = accentColour.Opacity(0.5f), - }; - } - - public bool OnPressed(ManiaAction action) - { - if (action == this.action.Value) - keyIcon.ScaleTo(1.4f, 50, Easing.OutQuint).Then().ScaleTo(1.3f, 250, Easing.OutQuint); - return false; - } - - public bool OnReleased(ManiaAction action) - { - if (action == this.action.Value) - keyIcon.ScaleTo(1f, 125, Easing.OutQuint); - return false; - } - } -} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs new file mode 100644 index 0000000000..4b4bc157d5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.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.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultColumnBackground : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Color4 brightColour; + private Color4 dimColour; + + private Box background; + private Box backgroundOverlay; + + [Resolved] + private Column column { get; set; } + + public DefaultColumnBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new[] + { + background = new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + }, + backgroundOverlay = new Box + { + Name = "Background Gradient Overlay", + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Blending = BlendingParameters.Additive, + Alpha = 0 + } + }; + + background.Colour = column.AccentColour.Darken(5); + brightColour = column.AccentColour.Opacity(0.6f); + dimColour = column.AccentColour.Opacity(0); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + 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); + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs new file mode 100644 index 0000000000..e0b099ab9b --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.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.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultHitTarget : CompositeDrawable + { + private const float hit_target_bar_height = 2; + + private readonly IBindable direction = new Bindable(); + + private Container hitTargetLine; + private Drawable hitTargetBar; + + [Resolved] + private Column column { get; set; } + + public DefaultHitTarget() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new[] + { + hitTargetBar = new Box + { + RelativeSizeAxes = Axes.X, + Height = DefaultNotePiece.NOTE_HEIGHT, + Alpha = 0.6f, + Colour = Color4.Black + }, + hitTargetLine = new Container + { + RelativeSizeAxes = Axes.X, + Height = hit_target_bar_height, + Masking = true, + Child = new Box { RelativeSizeAxes = Axes.Both } + }, + }; + + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = column.AccentColour.Opacity(0.5f), + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + hitTargetBar.Anchor = hitTargetBar.Origin = Anchor.TopLeft; + hitTargetLine.Anchor = hitTargetLine.Origin = Anchor.TopLeft; + } + else + { + hitTargetBar.Anchor = hitTargetBar.Origin = Anchor.BottomLeft; + hitTargetLine.Anchor = hitTargetLine.Origin = Anchor.BottomLeft; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs new file mode 100644 index 0000000000..47cb9bd45a --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.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 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.Input.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultKeyArea : CompositeDrawable, IKeyBindingHandler + { + private const float key_icon_size = 10; + private const float key_icon_corner_radius = 3; + + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Container keyIcon; + private Drawable gradient; + + [Resolved] + private Column column { get; set; } + + public DefaultKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = Stage.HIT_TARGET_POSITION, + Children = new[] + { + gradient = new Box + { + Name = "Key gradient", + RelativeSizeAxes = Axes.Both, + Alpha = 0.5f + }, + keyIcon = new Container + { + Name = "Key icon", + Size = new Vector2(key_icon_size), + Origin = Anchor.Centre, + Masking = true, + CornerRadius = key_icon_corner_radius, + BorderThickness = 2, + BorderColour = Color4.White, // Not true + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + } + }; + + keyIcon.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = column.AccentColour.Opacity(0.5f), + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + keyIcon.Anchor = Anchor.BottomCentre; + keyIcon.Y = -20; + directionContainer.Anchor = directionContainer.Origin = Anchor.TopLeft; + gradient.Colour = ColourInfo.GradientVertical(Color4.Black, Color4.Black.Opacity(0)); + } + else + { + keyIcon.Anchor = Anchor.TopCentre; + keyIcon.Y = 20; + directionContainer.Anchor = directionContainer.Origin = Anchor.BottomLeft; + gradient.Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Color4.Black); + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + keyIcon.ScaleTo(1.4f, 50, Easing.OutQuint).Then().ScaleTo(1.3f, 250, Easing.OutQuint); + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + keyIcon.ScaleTo(1f, 125, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs new file mode 100644 index 0000000000..f5b542d085 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.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.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultStageBackground : CompositeDrawable + { + public DefaultStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs new file mode 100644 index 0000000000..8f7880dafa --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.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.Rulesets.Mania.Skinning; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class HitObjectArea : SkinReloadableDrawable + { + protected readonly IBindable Direction = new Bindable(); + public readonly HitObjectContainer HitObjectContainer; + + public HitObjectArea(HitObjectContainer hitObjectContainer) + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Child = HitObjectContainer = hitObjectContainer + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + Direction.BindTo(scrollingInfo.Direction); + Direction.BindValueChanged(onDirectionChanged, true); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + UpdateHitPosition(); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + UpdateHitPosition(); + } + + protected virtual void UpdateHitPosition() + { + float hitPosition = CurrentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value + ?? Stage.HIT_TARGET_POSITION; + + Padding = Direction.Value == ScrollingDirection.Up + ? new MarginPadding { Top = hitPosition } + : new MarginPadding { Bottom = hitPosition }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs new file mode 100644 index 0000000000..7a047ed121 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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.Utils; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class DefaultHitExplosion : CompositeDrawable + { + public override bool RemoveWhenNotAlive => true; + + private readonly IBindable direction = new Bindable(); + + private readonly CircularContainer largeFaint; + private readonly CircularContainer mainGlow1; + + public DefaultHitExplosion(Color4 objectColour, bool isSmall = false) + { + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.X; + Height = DefaultNotePiece.NOTE_HEIGHT; + + // scale roughly in-line with visual appearance of notes + Scale = new Vector2(1f, 0.6f); + + if (isSmall) + Scale *= 0.5f; + + const float angle_variangle = 15; // should be less than 45 + + const float roundness = 80; + + const float initial_height = 10; + + var colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1); + + InternalChildren = new Drawable[] + { + largeFaint = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + // we want our size to be very small so the glow dominates it. + Size = new Vector2(0.8f), + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }, + }, + mainGlow1 = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + protected override void LoadComplete() + { + const double duration = 200; + + base.LoadComplete(); + + largeFaint + .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) + .FadeOut(duration * 2); + + mainGlow1.ScaleTo(1.4f, duration, Easing.OutQuint); + + this.FadeOut(duration, Easing.Out); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + Anchor = Anchor.TopCentre; + Y = DefaultNotePiece.NOTE_HEIGHT / 2; + } + else + { + Anchor = Anchor.BottomCentre; + Y = -DefaultNotePiece.NOTE_HEIGHT / 2; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 2c497541a8..94b5ee9486 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -2,11 +2,15 @@ // 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.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; @@ -19,12 +23,21 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osuTK; namespace osu.Game.Rulesets.Mania.UI { public class DrawableManiaRuleset : DrawableScrollingRuleset { + /// + /// The minimum time range. This occurs at a of 40. + /// + public const double MIN_TIME_RANGE = 150; + + /// + /// The maximum time range. This occurs at a of 1. + /// + public const double MAX_TIME_RANGE = 6000; + protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap; @@ -36,6 +49,10 @@ namespace osu.Game.Rulesets.Mania.UI protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; private readonly Bindable configDirection = new Bindable(); + private readonly Bindable configTimeRange = new BindableDouble(); + + // Stores the current speed adjustment active in gameplay. + private readonly Track speedAdjustmentTrack = new TrackVirtual(0); public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) @@ -46,20 +63,49 @@ namespace osu.Game.Rulesets.Mania.UI [BackgroundDependencyLoader] private void load() { + foreach (var mod in Mods.OfType()) + mod.ApplyToTrack(speedAdjustmentTrack); + + bool isForCurrentRuleset = Beatmap.BeatmapInfo.Ruleset.Equals(Ruleset.RulesetInfo); + + foreach (var p in ControlPoints) + { + // Mania doesn't care about global velocity + p.Velocity = 1; + p.BaseBeatLength *= Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier; + + // For non-mania beatmap, speed changes should only happen through timing points + if (!isForCurrentRuleset) + p.DifficultyPoint = new DifficultyControlPoint(); + } + BarLines.ForEach(Playfield.Add); Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection); configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); - Config.BindWith(ManiaRulesetSetting.ScrollTime, TimeRange); + Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); } - /// - /// Retrieves the column that intersects a screen-space position. - /// - /// The screen-space position. - /// The column which intersects with . - public Column GetColumnByPosition(Vector2 screenSpacePosition) => Playfield.GetColumnByPosition(screenSpacePosition); + protected override void AdjustScrollSpeed(int amount) + { + this.TransformTo(nameof(relativeTimeRange), relativeTimeRange + amount, 200, Easing.OutQuint); + } + + private double relativeTimeRange + { + get => MAX_TIME_RANGE / configTimeRange.Value; + set => configTimeRange.Value = MAX_TIME_RANGE / value; + } + + protected override void Update() + { + base.Update(); + + updateTimeRange(); + } + + private void updateTimeRange() => TimeRange.Value = configTimeRange.Value * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); @@ -85,5 +131,7 @@ namespace osu.Game.Rulesets.Mania.UI } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay); + + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new ManiaReplayRecorder(replay); } } diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 08f6049782..271e432e8d 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics.Containers; using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -14,9 +15,12 @@ using osuTK; namespace osu.Game.Rulesets.Mania.UI { + [Cached] public class ManiaPlayfield : ScrollingPlayfield { - private readonly List stages = new List(); + public IReadOnlyList Stages => stages; + + private readonly List stages = new List(); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos)); @@ -41,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < stageDefinitions.Count; i++) { - var newStage = new ManiaStage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction); + var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction); playfieldGrid.Content[0][i] = newStage; @@ -71,7 +75,7 @@ namespace osu.Game.Rulesets.Mania.UI { foreach (var column in stage.Columns) { - if (column.ReceivePositionalInputAt(screenSpacePosition)) + if (column.ReceivePositionalInputAt(new Vector2(screenSpacePosition.X, column.ScreenSpaceDrawQuad.Centre.Y))) { found = column; break; @@ -85,12 +89,37 @@ namespace osu.Game.Rulesets.Mania.UI return found; } + /// + /// Retrieves a by index. + /// + /// The index of the column. + /// The corresponding to the given index. + /// If is less than 0 or greater than . + public Column GetColumn(int index) + { + if (index < 0 || index > TotalColumns - 1) + throw new ArgumentOutOfRangeException(nameof(index)); + + foreach (var stage in stages) + { + if (index >= stage.Columns.Count) + { + index -= stage.Columns.Count; + continue; + } + + return stage.Columns[index]; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + /// /// Retrieves the total amount of columns across all stages in this playfield. /// public int TotalColumns => stages.Sum(s => s.Columns.Count); - private ManiaStage getStageByColumn(int column) + private Stage getStageByColumn(int column) { int sum = 0; diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs index d893a3fdde..30e0aafb7d 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.UI; -using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -13,8 +12,6 @@ namespace osu.Game.Rulesets.Mania.UI { Anchor = Anchor.Centre; Origin = Anchor.Centre; - - Size = new Vector2(1, 0.8f); } } } diff --git a/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs new file mode 100644 index 0000000000..18275000a2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class ManiaReplayRecorder : ReplayRecorder + { + public ManiaReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new ManiaReplayFrame(Time.Current, actions.ToArray()); + } +} diff --git a/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs new file mode 100644 index 0000000000..15d216e8c5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +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 + { + /// + /// The complete cover, including gradient and fill. + /// + private readonly Drawable cover; + + /// + /// The gradient portion of the cover. + /// + private readonly Box gradient; + + /// + /// The fully-opaque portion of the cover. + /// + private readonly Box filled; + + private readonly IBindable scrollDirection = new Bindable(); + + public PlayfieldCoveringWrapper(Drawable content) + { + InternalChild = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + content, + cover = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = new BlendingParameters + { + // Don't change the destination colour. + RGBEquation = BlendingEquation.Add, + Source = BlendingType.Zero, + Destination = BlendingType.One, + // Subtract the cover's alpha from the destination (points with alpha 1 should make the destination completely transparent). + AlphaEquation = BlendingEquation.Add, + SourceAlpha = BlendingType.Zero, + DestinationAlpha = BlendingType.OneMinusSrcAlpha + }, + Children = new Drawable[] + { + gradient = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Height = 0.25f, + Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(0f), + Color4.White.Opacity(1f) + ) + }, + filled = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0 + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + scrollDirection.BindTo(scrollingInfo.Direction); + scrollDirection.BindValueChanged(onScrollDirectionChanged, true); + } + + private void onScrollDirectionChanged(ValueChangedEvent direction) + => cover.Rotation = direction.NewValue == ScrollingDirection.Up ? 0 : 180f; + + /// + /// The relative area that should be completely covered. This does not include the fade. + /// + public float Coverage + { + set + { + filled.Height = value; + gradient.Y = -value; + } + } + + /// + /// The direction in which the cover expands. + /// + public CoverExpandDirection Direction + { + set => cover.Scale = value == CoverExpandDirection.AlongScroll ? Vector2.One : new Vector2(1, -1); + } + } + + public enum CoverExpandDirection + { + /// + /// The cover expands along the scrolling direction. + /// + AlongScroll, + + /// + /// The cover expands against the scrolling direction. + /// + AgainstScroll + } +} diff --git a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs similarity index 61% rename from osu.Game.Rulesets.Mania/UI/ManiaStage.cs rename to osu.Game.Rulesets.Mania/UI/Stage.cs index a28de7ea58..faa04dea97 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.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. -using System; 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.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.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -23,30 +24,33 @@ namespace osu.Game.Rulesets.Mania.UI /// /// A collection of s. /// - public class ManiaStage : ScrollingPlayfield + public class Stage : ScrollingPlayfield { public const float COLUMN_SPACING = 1; - public const float HIT_TARGET_POSITION = 50; + public const float HIT_TARGET_POSITION = 110; public IReadOnlyList Columns => columnFlow.Children; private readonly FillFlowContainer columnFlow; - private readonly Container barLineContainer; - public Container Judgements => judgements; private readonly JudgementContainer judgements; + private readonly Drawable barLineContainer; private readonly Container topLevelContainer; - private List normalColumnColours = new List(); - private Color4 specialColumnColour; + 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; - public ManiaStage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) + public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) { this.firstColumnIndex = firstColumnIndex; @@ -67,31 +71,17 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X, Children = new Drawable[] { - new Container + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { - Name = "Columns mask", + RelativeSizeAxes = Axes.Both + }, + columnFlow = new FillFlowContainer + { + Name = "Columns", RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = 5, - Children = new Drawable[] - { - new Box - { - Name = "Background", - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black - }, - columnFlow = new FillFlowContainer - { - Name = "Columns", - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Left = COLUMN_SPACING, Right = COLUMN_SPACING }, - Spacing = new Vector2(COLUMN_SPACING, 0) - }, - } + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = COLUMN_SPACING, Right = COLUMN_SPACING }, }, new Container { @@ -102,22 +92,24 @@ namespace osu.Game.Rulesets.Mania.UI Width = 1366, // Bar lines should only be masked on the vertical axis BypassAutoSizeAxes = Axes.Both, Masking = true, - Child = barLineContainer = new Container + Child = barLineContainer = new HitObjectArea(HitObjectContainer) { Name = "Bar lines", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Y, - Child = HitObjectContainer } }, + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) + { + RelativeSizeAxes = Axes.Both + }, judgements = new JudgementContainer { Anchor = Anchor.TopCentre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Both, Y = HIT_TARGET_POSITION + 150, - BypassAutoSizeAxes = Axes.Both }, topLevelContainer = new Container { RelativeSizeAxes = Axes.Both } } @@ -126,24 +118,52 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < definition.Columns; i++) { - var isSpecial = definition.IsSpecialColumn(i); + var columnType = definition.GetTypeOfColumn(i); var column = new Column(firstColumnIndex + i) { - IsSpecial = isSpecial, - Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } + ColumnType = columnType, + AccentColour = columnColours[columnType], + Action = { Value = columnType == ColumnType.Special ? specialColumnStartAction++ : normalColumnStartAction++ } }; AddColumn(column); } + } - Direction.BindValueChanged(dir => + private ISkin currentSkin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + currentSkin = skin; + skin.SourceChanged += onSkinChanged; + + onSkinChanged(); + } + + private void onSkinChanged() + { + foreach (var col in columnFlow) { - barLineContainer.Padding = new MarginPadding + if (col.Index > 0) { - Top = dir.NewValue == ScrollingDirection.Up ? HIT_TARGET_POSITION : 0, - Bottom = dir.NewValue == ScrollingDirection.Down ? HIT_TARGET_POSITION : 0, - }; - }, true); + float spacing = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, col.Index - 1)) + ?.Value ?? COLUMN_SPACING; + + col.Margin = new MarginPadding { Left = spacing }; + } + + float? width = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, col.Index)) + ?.Value; + + if (width == null) + // only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration) + col.Width = col.IsSpecial ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH; + else + col.Width = width.Value; + } } public void AddColumn(Column c) @@ -196,38 +216,6 @@ namespace osu.Game.Rulesets.Mania.UI }); } - [BackgroundDependencyLoader] - private void load() - { - normalColumnColours = new List - { - new Color4(94, 0, 57, 255), - new Color4(6, 84, 0, 255) - }; - - specialColumnColour = new Color4(0, 48, 63, 255); - - // Set the special column + colour + key - foreach (var column in Columns) - { - if (!column.IsSpecial) - continue; - - column.AccentColour = specialColumnColour; - } - - var nonSpecialColumns = Columns.Where(c => !c.IsSpecial).ToList(); - - // We'll set the colours of the non-special columns in a separate loop, because the non-special - // column colours are mirrored across their centre and special styles mess with this - for (int i = 0; i < Math.Ceiling(nonSpecialColumns.Count / 2f); i++) - { - Color4 colour = normalColumnColours[i % normalColumnColours.Count]; - nonSpecialColumns[i].AccentColour = colour; - nonSpecialColumns[nonSpecialColumns.Count - 1 - i].AccentColour = colour; - } - } - protected override void Update() { // Due to masking differences, it is not possible to get the width of the columns container automatically diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs new file mode 100644 index 0000000000..878d1088a6 --- /dev/null +++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Input.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class VariantMappingGenerator + { + /// + /// All the s available to the left hand. + /// + public InputKey[] LeftKeys; + + /// + /// All the s available to the right hand. + /// + public InputKey[] RightKeys; + + /// + /// The for the special key. + /// + public InputKey SpecialKey; + + /// + /// The at which the normal columns should begin. + /// + public ManiaAction NormalActionStart; + + /// + /// The for the special column. + /// + public ManiaAction SpecialAction; + + /// + /// Generates a list of s for a specific number of columns. + /// + /// The number of columns that need to be bound. + /// The next to use for normal columns. + /// The keybindings. + public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction) + { + ManiaAction currentNormalAction = NormalActionStart; + + var bindings = new List(); + + for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) + bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++)); + + if (columns % 2 == 1) + bindings.Add(new KeyBinding(SpecialKey, SpecialAction)); + + for (int i = 0; i < columns / 2; i++) + bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++)); + + nextNormalAction = currentNormalAction; + return bindings; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs new file mode 100644 index 0000000000..7697f46160 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class OsuModTestScene : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs new file mode 100644 index 0000000000..49c1fe8540 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.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 NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModDifficultyAdjust : OsuModTestScene + { + [Test] + public void TestNoAdjustment() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust(), + Autoplay = true, + PassCondition = checkSomeHit + }); + + [Test] + public void TestCircleSize1() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { CircleSize = { Value = 1 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsScale(0.78f) + }); + + [Test] + public void TestCircleSize10() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { CircleSize = { Value = 10 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsScale(0.15f) + }); + + [Test] + public void TestApproachRate1() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { ApproachRate = { Value = 1 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsPreempt(1680) + }); + + [Test] + public void TestApproachRate10() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { ApproachRate = { Value = 10 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsPreempt(450) + }); + + private bool checkObjectsPreempt(double target) + { + var objects = Player.ChildrenOfType(); + if (!objects.Any()) + return false; + + return objects.All(o => o.HitObject.TimePreempt == target); + } + + private bool checkObjectsScale(float target) + { + var objects = Player.ChildrenOfType(); + if (!objects.Any()) + return false; + + return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Children.OfType().Single().Scale.X, target)); + } + + private bool checkSomeHit() + { + return Player.ScoreProcessor.JudgedHits >= 2; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs new file mode 100644 index 0000000000..335ef31019 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.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 NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModDoubleTime : OsuModTestScene + { + [TestCase(0.5)] + [TestCase(1.01)] + [TestCase(1.5)] + [TestCase(2)] + [TestCase(5)] + public void TestSpeedChangeCustomisation(double rate) + { + var mod = new OsuModDoubleTime { SpeedChange = { Value = rate } }; + + CreateModTest(new ModTestData + { + Mod = mod, + PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 && + Precision.AlmostEquals(Player.GameplayClockContainer.GameplayClock.Rate, mod.SpeedChange.Value) + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs new file mode 100644 index 0000000000..40f1c4a52f --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModHidden : OsuModTestScene + { + [Test] + public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstCircleAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new HitCircle + { + Position = new Vector2(300, 192), + StartTime = 3200, + }, + new HitCircle + { + Position = new Vector2(384, 192), + StartTime = 4200, + } + } + }, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstSliderAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new Slider + { + StartTime = 3200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }, + new Slider + { + StartTime = 5200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + } + } + }, + PassCondition = checkSomeHit + }); + + private bool checkSomeHit() + { + return Player.ScoreProcessor.JudgedHits >= 4; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs new file mode 100644 index 0000000000..985baa8cf5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.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 NUnit.Framework; +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.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + public TestSceneOsuModPerfect() + : base(new OsuModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestHitCircle(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new HitCircle { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestSlider(bool shouldMiss) + { + var slider = new Slider + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }; + + CreateHitObjectTest(new HitObjectTestData(slider), shouldMiss); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSpinner(bool shouldMiss) + { + var spinner = new Spinner + { + StartTime = 1000, + EndTime = 3000, + Position = new Vector2(256, 192) + }; + + CreateHitObjectTest(new HitObjectTestData(spinner), shouldMiss); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 450f7de6d2..cd3daf18a9 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Beatmaps; diff --git a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs new file mode 100644 index 0000000000..a0a38fc47b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public abstract class OsuSkinnableTestScene : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png new file mode 100644 index 0000000000..4d630443cd Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png new file mode 100644 index 0000000000..a824784942 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png new file mode 100644 index 0000000000..316d52c685 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png new file mode 100644 index 0000000000..e6f6b3c239 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/SkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/SkinnableTestScene.cs deleted file mode 100644 index 2fad3bd04f..0000000000 --- a/osu.Game.Rulesets.Osu.Tests/SkinnableTestScene.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.Text.RegularExpressions; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Textures; -using osu.Framework.IO.Stores; -using osu.Game.Skinning; -using osu.Game.Tests.Visual; - -namespace osu.Game.Rulesets.Osu.Tests -{ - public abstract class SkinnableTestScene : OsuGridTestScene - { - private Skin metricsSkin; - private Skin defaultSkin; - private Skin specialSkin; - private Skin oldSkin; - - protected SkinnableTestScene() - : base(2, 3) - { - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio, SkinManager skinManager) - { - var dllStore = new DllResourceStore("osu.Game.Rulesets.Osu.Tests.dll"); - - metricsSkin = new TestLegacySkin(new SkinInfo(), new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), audio, true); - defaultSkin = skinManager.GetSkin(DefaultLegacySkin.Info); - specialSkin = new TestLegacySkin(new SkinInfo(), new NamespacedResourceStore(dllStore, "Resources/special_skin"), audio, true); - oldSkin = new TestLegacySkin(new SkinInfo(), new NamespacedResourceStore(dllStore, "Resources/old_skin"), audio, true); - } - - public void SetContents(Func creationFunction) - { - Cell(0).Child = createProvider(null, creationFunction); - Cell(1).Child = createProvider(metricsSkin, creationFunction); - Cell(2).Child = createProvider(defaultSkin, creationFunction); - Cell(3).Child = createProvider(specialSkin, creationFunction); - Cell(4).Child = createProvider(oldSkin, creationFunction); - } - - private Drawable createProvider(Skin skin, Func creationFunction) - { - var mainProvider = new SkinProvidingContainer(skin); - - return mainProvider - .WithChild(new SkinProvidingContainer(Ruleset.Value.CreateInstance().CreateLegacySkinProvider(mainProvider)) - { - Child = creationFunction() - }); - } - - private class TestLegacySkin : LegacySkin - { - private readonly bool extrapolateAnimations; - - public TestLegacySkin(SkinInfo skin, IResourceStore storage, AudioManager audioManager, bool extrapolateAnimations) - : base(skin, storage, audioManager, "skin.ini") - { - this.extrapolateAnimations = extrapolateAnimations; - } - - public override Texture GetTexture(string componentName) - { - // extrapolate frames to test longer animations - if (extrapolateAnimations) - { - var match = Regex.Match(componentName, "-([0-9]*)"); - - if (match.Length > 0 && int.TryParse(match.Groups[1].Value, out var number) && number < 60) - return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}")); - } - - return base.GetTexture(componentName); - } - } - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs new file mode 100644 index 0000000000..10d9d7ffde --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene + { + private Box background; + private Drawable object1; + private Drawable object2; + private TestAccuracyHeatmap accuracyHeatmap; + private ScheduledDelegate automaticAdditionDelegate; + + [SetUp] + public void Setup() => Schedule(() => + { + automaticAdditionDelegate?.Cancel(); + automaticAdditionDelegate = null; + + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333"), + }, + object1 = new BorderCircle + { + Position = new Vector2(256, 192), + Colour = Color4.Yellow, + }, + object2 = new BorderCircle + { + Position = new Vector2(100, 300), + }, + accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(130) + } + }; + }); + + [Test] + public void TestManyHitPointsAutomatic() + { + AddStep("add scheduled delegate", () => + { + automaticAdditionDelegate = Scheduler.AddDelayed(() => + { + var randomPos = new Vector2( + RNG.NextSingle(object1.DrawPosition.X - object1.DrawSize.X / 2, object1.DrawPosition.X + object1.DrawSize.X / 2), + RNG.NextSingle(object1.DrawPosition.Y - object1.DrawSize.Y / 2, object1.DrawPosition.Y + object1.DrawSize.Y / 2)); + + // The background is used for ToLocalSpace() since we need to go _inside_ the DrawSizePreservingContainer (Content of TestScene). + accuracyHeatmap.AddPoint(object2.Position, object1.Position, randomPos, RNG.NextSingle(10, 500)); + InputManager.MoveMouseTo(background.ToScreenSpace(randomPos)); + }, 1, true); + }); + + AddWaitStep("wait for some hit points", 10); + } + + [Test] + public void TestManualPlacement() + { + AddStep("return user input", () => InputManager.UseParentInput = true); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + accuracyHeatmap.AddPoint(object2.Position, object1.Position, background.ToLocalSpace(e.ScreenSpaceMouseDownPosition), 50); + return true; + } + + private class TestAccuracyHeatmap : AccuracyHeatmap + { + public TestAccuracyHeatmap(ScoreInfo score) + : base(score, new TestBeatmap(new OsuRuleset().RulesetInfo)) + { + } + + public new void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + => base.AddPoint(start, end, hitPoint, radius); + } + + private class BorderCircle : CircularContainer + { + public BorderCircle() + { + Origin = Anchor.Centre; + Size = new Vector2(100); + + Masking = true; + BorderThickness = 2; + BorderColour = Color4.White; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(4), + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 46769f65fe..dde02e873b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -8,6 +8,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.Textures; using osu.Framework.Testing.Input; using osu.Game.Audio; @@ -79,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); - public Texture GetTexture(string componentName) + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { switch (componentName) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index ac627aa23e..f08f994b07 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -13,24 +15,50 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneDrawableJudgement : SkinnableTestScene + public class TestSceneDrawableJudgement : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableJudgement), - typeof(DrawableOsuJudgement) - }; - public TestSceneDrawableJudgement() { + var pools = new List>(); + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) { - AddStep("Show " + result.GetDescription(), () => SetContents(() => - new DrawableOsuJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + AddStep("Show " + result.GetDescription(), () => + { + int poolIndex = 0; + + SetContents(() => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - })); + DrawablePool pool; + + if (poolIndex >= pools.Count) + pools.Add(pool = new DrawablePool(1)); + else + { + pool = pools[poolIndex]; + + // We need to make sure neither the pool nor the judgement get disposed when new content is set, and they both share the same parent. + ((Container)pool.Parent).Clear(false); + } + + var container = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + pool, + pool.Get(j => j.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null)).With(j => + { + j.Anchor = Anchor.Centre; + j.Origin = Anchor.Centre; + }) + } + }; + + poolIndex++; + return container; + }); + }); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs index 4aca34bf64..9239034a53 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs @@ -9,9 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneEditor : EditorTestScene { - public TestSceneEditor() - : base(new OsuRuleset()) - { - } + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs index 94ca2d4cd1..87da7ef417 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Objects; @@ -114,6 +117,22 @@ namespace osu.Game.Rulesets.Osu.Tests assertGroups(); } + [Test] + public void TestStackedObjects() + { + addObjectsStep(() => new OsuHitObject[] + { + new HitCircle { Position = new Vector2(300, 100) }, + new HitCircle + { + Position = new Vector2(300, 300), + StackHeight = 20 + }, + }); + + assertDirections(); + } + private void addMultipleObjectsStep() => addObjectsStep(() => new OsuHitObject[] { new HitCircle { Position = new Vector2(100, 100) }, @@ -207,6 +226,33 @@ namespace osu.Game.Rulesets.Osu.Tests }); } + private void assertDirections() + { + AddAssert("group directions are correct", () => + { + for (int i = 0; i < hitObjectContainer.Count; i++) + { + DrawableOsuHitObject expectedStart = getObject(i); + DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null; + + if (expectedEnd == null) + continue; + + var points = getGroup(i).ChildrenOfType().ToArray(); + if (points.Length == 0) + continue; + + float expectedDirection = MathF.Atan2(expectedStart.Position.Y - expectedEnd.Position.Y, expectedStart.Position.X - expectedEnd.Position.X); + float realDirection = MathF.Atan2(expectedStart.Position.Y - points[^1].Position.Y, expectedStart.Position.X - points[^1].Position.X); + + if (!Precision.AlmostEquals(expectedDirection, realDirection)) + throw new AssertionException($"Expected group {i} in direction {expectedDirection}, but was {realDirection}."); + } + + return true; + }); + } + private DrawableOsuHitObject getObject(int index) => hitObjectContainer[index]; private FollowPointConnection getGroup(int index) => followPointRenderer.Connections[index]; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index aa170eae1e..461779b185 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -2,31 +2,91 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Testing.Input; +using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneGameplayCursor : SkinnableTestScene + public class TestSceneGameplayCursor : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuCursorContainer), - typeof(CursorTrail) - }; + [Cached] + private GameplayBeatmap gameplayBeatmap; - [BackgroundDependencyLoader] - private void load() + private ClickingCursorContainer lastContainer; + + [Resolved] + private OsuConfigManager config { get; set; } + + private Drawable background; + + public TestSceneGameplayCursor() + { + gameplayBeatmap = new GameplayBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + AddStep("change background colour", () => + { + background?.Expire(); + + Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) + }); + }); + + AddSliderStep("circle size", 0f, 10f, 0f, val => + { + config.Set(OsuSetting.AutoCursorSize, true); + gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; + Scheduler.AddOnce(recreate); + }); + + AddStep("test cursor container", recreate); + + void recreate() => SetContents(() => new OsuInputManager(new OsuRuleset().RulesetInfo) { Child = new OsuCursorContainer() }); + } + + [TestCase(1, 1)] + [TestCase(5, 1)] + [TestCase(10, 1)] + [TestCase(1, 1.5f)] + [TestCase(5, 1.5f)] + [TestCase(10, 1.5f)] + public void TestSizing(int circleSize, float userScale) + { + AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize); + AddStep("turn on autosizing", () => config.Set(OsuSetting.AutoCursorSize, true)); + + AddStep("load content", loadContent); + + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize) * userScale); + + AddStep("set user scale to 1", () => config.Set(OsuSetting.GameplayCursorSize, 1f)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize)); + + AddStep("turn off autosizing", () => config.Set(OsuSetting.AutoCursorSize, false)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == 1); + + AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == userScale); + } + + private void loadContent() { SetContents(() => new MovingCursorInputManager { - Child = new ClickingCursorContainer + Child = lastContainer = new ClickingCursorContainer { RelativeSizeAxes = Axes.Both, Masking = true, @@ -36,16 +96,27 @@ namespace osu.Game.Rulesets.Osu.Tests private class ClickingCursorContainer : OsuCursorContainer { + private bool pressed; + + public bool Pressed + { + set + { + if (value == pressed) + return; + + pressed = value; + if (value) + OnPressed(OsuAction.LeftButton); + else + OnReleased(OsuAction.LeftButton); + } + } + protected override void Update() { base.Update(); - - double currentTime = Time.Current; - - if (((int)(currentTime / 1000)) % 2 == 0) - OnPressed(OsuAction.LeftButton); - else - OnReleased(OsuAction.LeftButton); + Pressed = ((int)(Time.Current / 1000)) % 2 == 0; } } @@ -54,6 +125,7 @@ namespace osu.Game.Rulesets.Osu.Tests public MovingCursorInputManager() { UseParentInput = false; + ShowVisualCursorGuide = false; } protected override void Update() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index 098e277fff..37df0d6e37 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -8,8 +8,6 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osuTK; -using System.Collections.Generic; -using System; using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; @@ -18,13 +16,8 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircle : SkinnableTestScene + public class TestSceneHitCircle : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableHitCircle) - }; - private int depthIndex; public TestSceneHitCircle() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs new file mode 100644 index 0000000000..0649989dc0 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneHitCircleArea : OsuManualInputManagerTestScene + { + private HitCircle hitCircle; + private DrawableHitCircle drawableHitCircle; + private DrawableHitCircle.HitReceptor hitAreaReceptor => drawableHitCircle.HitArea; + + [SetUp] + public void SetUp() => Schedule(() => + { + hitCircle = new HitCircle + { + Position = new Vector2(100, 100), + StartTime = Time.Current + 500 + }; + + hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + Child = new SkinProvidingContainer(new DefaultSkin()) + { + RelativeSizeAxes = Axes.Both, + Child = drawableHitCircle = new DrawableHitCircle(hitCircle) + { + Size = new Vector2(100) + } + }; + }); + + [Test] + public void TestCircleHitCentre() + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(hitAreaReceptor.ScreenSpaceDrawQuad.Centre)); + scheduleHit(); + + AddAssert("hit registered", () => hitAreaReceptor.HitAction == OsuAction.LeftButton); + } + + [Test] + public void TestCircleHitLeftEdge() + { + AddStep("move mouse to left edge", () => + { + var drawQuad = hitAreaReceptor.ScreenSpaceDrawQuad; + var mousePosition = new Vector2(drawQuad.TopLeft.X, drawQuad.Centre.Y); + + InputManager.MoveMouseTo(mousePosition); + }); + scheduleHit(); + + AddAssert("hit registered", () => hitAreaReceptor.HitAction == OsuAction.LeftButton); + } + + [TestCase(0.95f, OsuAction.LeftButton)] + [TestCase(1.05f, null)] + public void TestHitsCloseToEdge(float relativeDistanceFromCentre, OsuAction? expectedAction) + { + AddStep("move mouse to top left circle edge", () => + { + var drawQuad = hitAreaReceptor.ScreenSpaceDrawQuad; + // sqrt(2) / 2 = sin(45deg) = cos(45deg) + // draw width halved to get radius + float correction = relativeDistanceFromCentre * (float)Math.Sqrt(2) / 2 * (drawQuad.Width / 2); + var mousePosition = new Vector2(drawQuad.Centre.X - correction, drawQuad.Centre.Y - correction); + + InputManager.MoveMouseTo(mousePosition); + }); + scheduleHit(); + + AddAssert($"hit {(expectedAction == null ? "not " : string.Empty)}registered", () => hitAreaReceptor.HitAction == expectedAction); + } + + [Test] + public void TestCircleMissBoundingBoxCorner() + { + AddStep("move mouse to top left corner of bounding box", () => InputManager.MoveMouseTo(hitAreaReceptor.ScreenSpaceDrawQuad.TopLeft)); + scheduleHit(); + + AddAssert("hit not registered", () => hitAreaReceptor.HitAction == null); + } + + private void scheduleHit() => AddStep("schedule action", () => + { + var delay = hitCircle.StartTime - hitCircle.HitWindows.WindowFor(HitResult.Great) - Time.Current; + Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(OsuAction.LeftButton), delay); + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs index 21ebce8c23..45125204b6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,8 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneHitCircleHidden : TestSceneHitCircle { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - [SetUp] public void SetUp() => Schedule(() => { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs index b99cd523ff..8cf29ddfbf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs @@ -4,19 +4,13 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircleLongCombo : PlayerTestScene + public class TestSceneHitCircleLongCombo : TestSceneOsuPlayer { - public TestSceneHitCircleLongCombo() - : base(new OsuRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs index 4676f14655..3ff37c4147 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -7,12 +7,10 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Graphics; using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Tests.Visual; @@ -21,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneLegacyBeatmapSkin : OsuTestScene + public class TestSceneLegacyBeatmapSkin : ScreenTestScene { [Resolved] private AudioManager audio { get; set; } @@ -65,7 +63,8 @@ namespace osu.Game.Rulesets.Osu.Tests ExposedPlayer player; Beatmap.Value = new CustomSkinWorkingBeatmap(audio, beatmapHasColours); - Child = new OsuScreenStack(player = new ExposedPlayer(userHasCustomColours)) { RelativeSizeAxes = Axes.Both }; + + LoadScreen(player = new ExposedPlayer(userHasCustomColours)); return player; } @@ -90,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Tests public IReadOnlyList UsableComboColours => GameplayClockContainer.ChildrenOfType() .First() - .GetConfig>(GlobalSkinConfiguration.ComboColours)?.Value; + .GetConfig>(GlobalSkinColours.ComboColours)?.Value; } private class CustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs new file mode 100644 index 0000000000..f3221ffe32 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneMissHitWindowJudgements : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + [Test] + public void TestMissViaEarlyHit() + { + var beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }; + + var hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + CreateModTest(new ModTestData + { + Autoplay = false, + Mod = new TestAutoMod(), + Beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }, + PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset < -hitWindows.WindowFor(HitResult.Meh) && Player.Results[0].Type == HitResult.Miss + }); + } + + [Test] + public void TestMissViaNotHitting() + { + var beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }; + + var hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + CreateModTest(new ModTestData + { + Autoplay = false, + Beatmap = beatmap, + PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset >= hitWindows.WindowFor(HitResult.Meh) && Player.Results[0].Type == HitResult.Miss + }); + } + + private class TestAutoMod : OsuModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, + Replay = new MissingAutoGenerator(beatmap).Generate() + }; + } + + private class MissingAutoGenerator : OsuAutoGeneratorBase + { + public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; + + public MissingAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + public override Replay Generate() + { + AddFrameToReplay(new OsuReplayFrame(-100000, new Vector2(256, 500))); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 1500, new Vector2(256, 500))); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 1500, new Vector2(256, 500))); + + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 450, Beatmap.HitObjects[0].StackedPosition)); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 350, Beatmap.HitObjects[0].StackedPosition, OsuAction.LeftButton)); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 325, Beatmap.HitObjects[0].StackedPosition)); + + return Replay; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs index c9b3d08a22..0d0be2953b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs @@ -2,44 +2,37 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneOsuDistanceSnapGrid : ManualInputManagerTestScene + public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene { private const double beat_length = 100; private static readonly Vector2 grid_position = new Vector2(512, 384); - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CircularDistanceSnapGrid) - }; - [Cached(typeof(EditorBeatmap))] private readonly EditorBeatmap editorBeatmap; [Cached] private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); - [Cached(typeof(IDistanceSnapProvider))] + [Cached(typeof(IPositionSnapProvider))] private readonly SnapProvider snapProvider = new SnapProvider(); private TestOsuDistanceSnapGrid grid; @@ -179,9 +172,9 @@ namespace osu.Game.Rulesets.Osu.Tests } } - private class SnapProvider : IDistanceSnapProvider + private class SnapProvider : IPositionSnapProvider { - public (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) => (position, time); + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs index 412effe176..19736a7709 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs @@ -3,13 +3,13 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneOsuFlashlight : TestSceneOsuPlayer { - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { SelectedMods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs index 0a33b09ba8..f5b28b36c0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs @@ -9,9 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneOsuPlayer : PlayerTestScene { - public TestSceneOsuPlayer() - : base(new OsuRuleset()) - { - } + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs new file mode 100644 index 0000000000..c3b4d2625e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs @@ -0,0 +1,447 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +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 +{ + public class TestSceneOutOfOrderHits : 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 + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAtFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], 0); + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], 100); + } + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100 + } + + /// + /// Tests clicking a future circle after a slider's start time, but hitting all slider ticks. + /// + [Test] + public void TestMissSliderHeadAndHitAllSliderTicks() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Miss); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.Great); + } + + /// + /// Tests clicking hitting future slider ticks before a circle. + /// + [Test] + public void TestHitSliderTicksBeforeCircle() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 100, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Great); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.Great); + } + + /// + /// Tests clicking a future circle before a spinner. + /// + [Test] + public void TestHitCircleBeforeSpinner() + { + const double time_spinner = 1500; + const double time_circle = 1800; + Vector2 positionCircle = Vector2.Zero; + + var hitObjects = new List + { + new TestSpinner + { + StartTime = time_spinner, + Position = new Vector2(256, 192), + EndTime = time_spinner + 1000, + }, + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + [Test] + public void TestHitSliderHeadBeforeHitCircle() + { + const double time_circle = 1000; + const double time_slider = 1200; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + } + + private void addJudgementAssert(string name, Func hitObject, HitResult result) + { + AddAssert($"{name} judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject()).Type == result); + } + + private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", + () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); + } + + private ScoreAccessibleReplayPlayer currentPlayer; + private List judgementResults; + + private void performTest(List hitObjects, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, + Ruleset = new OsuRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + 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); + } + + private class TestHitCircle : HitCircle + { + protected override HitWindows CreateHitWindows() => new TestHitWindows(); + } + + private class TestSlider : Slider + { + public TestSlider() + { + DefaultsApplied += _ => + { + HeadCircle.HitWindows = new TestHitWindows(); + TailCircle.HitWindows = new TestHitWindows(); + + HeadCircle.HitWindows.SetDifficulty(0); + TailCircle.HitWindows.SetDifficulty(0); + }; + } + } + + private class TestSpinner : Spinner + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + SpinsRequired = 1; + } + } + + private class TestHitWindows : HitWindows + { + private static readonly DifficultyRange[] ranges = + { + new DifficultyRange(HitResult.Great, 500, 500, 500), + new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), + }; + + public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; + + protected override DifficultyRange[] GetRanges() => ranges; + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.cs new file mode 100644 index 0000000000..21fa283b6d --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestScenePathControlPointVisualiser.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestScenePathControlPointVisualiser : OsuTestScene + { + private Slider slider; + private PathControlPointVisualiser visualiser; + + [SetUp] + public void Setup() => Schedule(() => + { + slider = new Slider(); + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + }); + + [Test] + public void TestAddOverlappingControlPoints() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200)); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + + AddAssert("last connection displayed", () => + { + var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position.Value == new Vector2(300)); + return lastConnection.DrawWidth > 50; + }); + } + + private void createVisualiser(bool allowSelection) => AddStep("create visualiser", () => Child = visualiser = new PathControlPointVisualiser(slider, allowSelection) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + + private void addControlPointStep(Vector2 position) => AddStep($"add control point {position}", () => slider.Path.ControlPoints.Add(new PathControlPoint(position))); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs index 8e73d6152f..a7967c407a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -12,13 +10,8 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneResumeOverlay : ManualInputManagerTestScene + public class TestSceneResumeOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuResumeOverlay), - }; - public TestSceneResumeOverlay() { ManualOsuInputManager osuInputManager; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index 863d0eda09..d692be89b2 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 4da1b1dae0..075bf314bc 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -9,6 +9,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.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Timing; @@ -18,7 +19,6 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Visual; @@ -26,13 +26,12 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSkinFallbacks : PlayerTestScene + public class TestSceneSkinFallbacks : TestSceneOsuPlayer { private readonly TestSource testUserSkin; private readonly TestSource testBeatmapSkin; public TestSceneSkinFallbacks() - : base(new OsuRuleset()) { testUserSkin = new TestSource("user"); testBeatmapSkin = new TestSource("beatmap"); @@ -56,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void checkNextHitObject(string skin) => AddUntilStep($"check skin from {skin}", () => { - var firstObject = ((TestPlayer)Player).DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.OfType().FirstOrDefault(); + var firstObject = Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.OfType().FirstOrDefault(); if (firstObject == null) return false; @@ -75,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Resolved] private AudioManager audio { get; set; } - protected override Player CreatePlayer(Ruleset ruleset) => new SkinProvidingPlayer(testUserSkin); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new SkinProvidingPlayer(testUserSkin); protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new CustomSkinWorkingBeatmap(beatmap, storyboard, Clock, audio, testBeatmapSkin); @@ -133,7 +132,7 @@ namespace osu.Game.Rulesets.Osu.Tests }; } - public Texture GetTexture(string componentName) => null; + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public SampleChannel GetSample(ISampleInfo sampleInfo) => null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index e8386363be..a9404f665a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -21,29 +20,12 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSlider : SkinnableTestScene + public class TestSceneSlider : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Slider), - typeof(SliderTick), - typeof(SliderTailCircle), - typeof(SliderBall), - typeof(SliderBody), - typeof(SnakingSliderBody), - typeof(DrawableSlider), - typeof(DrawableSliderTick), - typeof(DrawableSliderTail), - typeof(DrawableSliderHead), - typeof(DrawableRepeatPoint), - typeof(DrawableOsuHitObject) - }; - private Container content; protected override Container Content @@ -145,7 +127,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("head samples updated", () => assertSamples(((Slider)slider.HitObject).HeadCircle)); AddAssert("tick samples not updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertTickSamples)); - AddAssert("repeat samples updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertSamples)); + AddAssert("repeat samples updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertSamples)); AddAssert("tail has no samples", () => ((Slider)slider.HitObject).TailCircle.Samples.Count == 0); static bool assertTickSamples(SliderTick tick) => tick.Samples.Single().Name == "slidertick"; @@ -180,7 +162,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("head samples not updated", () => assertSamples(((Slider)slider.HitObject).HeadCircle)); AddAssert("tick samples not updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertTickSamples)); - AddAssert("repeat samples not updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertSamples)); + AddAssert("repeat samples not updated", () => ((Slider)slider.HitObject).NestedHitObjects.OfType().All(assertSamples)); AddAssert("tail has no samples", () => ((Slider)slider.HitObject).TailCircle.Samples.Count == 0); static bool assertTickSamples(SliderTick tick) => tick.Samples.Single().Name == "slidertick"; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs index d0ee1bddb5..b2bd727c6a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,8 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSliderHidden : TestSceneSlider { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - [SetUp] public void SetUp() => Schedule(() => { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index b6fc9821a4..b543b6fa94 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -13,8 +12,6 @@ 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.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; @@ -27,26 +24,15 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSliderInput : RateAdjustedBeatmapTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SliderBall), - typeof(DrawableSlider), - typeof(DrawableSliderTick), - typeof(DrawableRepeatPoint), - typeof(DrawableOsuHitObject), - typeof(DrawableSliderHead), - typeof(DrawableSliderTail), - }; - private const double time_before_slider = 250; private const double time_slider_start = 1500; private const double time_during_slide_1 = 2500; private const double time_during_slide_2 = 3000; private const double time_during_slide_3 = 3500; private const double time_during_slide_4 = 3800; + private const double time_slider_end = 4000; private List judgementResults; - private bool allJudgedFired; /// /// Scenario: @@ -284,7 +270,49 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("Tracking acquired", assertMidSliderJudgements); } - private bool assertGreatJudge() => judgementResults.Last().Type == HitResult.Great; + /// + /// Scenario: + /// - Press a key on the slider head + /// - While holding the key, move cursor close to the edge of tracking area + /// - Keep the cursor on the edge of tracking area until the slider ends + /// Expected Result: + /// A passing test case will have the slider track the cursor throughout the whole test. + /// + [Test] + public void TestTrackingAreaEdge() + { + performTest(new List + { + new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(0, OsuHitObject.OBJECT_RADIUS * 1.19f), Actions = { OsuAction.LeftButton }, Time = time_slider_start + 250 }, + new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.199f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, + }); + + AddAssert("Tracking kept", assertGreatJudge); + } + + /// + /// Scenario: + /// - Press a key on the slider head + /// - While holding the key, move cursor just outside the tracking area + /// - Keep the cursor just outside the tracking area until the slider ends + /// Expected Result: + /// A passing test case will have the slider drop the tracking on frame 2. + /// + [Test] + public void TestTrackingAreaOutsideEdge() + { + performTest(new List + { + new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(0, OsuHitObject.OBJECT_RADIUS * 1.21f), Actions = { OsuAction.LeftButton }, Time = time_slider_start + 250 }, + new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.201f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, + }); + + AddAssert("Tracking dropped", assertMidSliderJudgementFail); + } + + private bool assertGreatJudge() => judgementResults.Any() && judgementResults.All(t => t.Type == HitResult.Great); private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.Great && judgementResults.First().Type == HitResult.Miss; @@ -294,6 +322,8 @@ namespace osu.Game.Rulesets.Osu.Tests private ScoreAccessibleReplayPlayer currentPlayer; + private const float slider_path_length = 25; + private void performTest(List frames) { AddStep("load player", () => @@ -309,8 +339,8 @@ namespace osu.Game.Rulesets.Osu.Tests Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, - new Vector2(25, 0), - }, 25), + new Vector2(slider_path_length, 0), + }, slider_path_length), } }, BeatmapInfo = @@ -330,20 +360,15 @@ namespace osu.Game.Rulesets.Osu.Tests { if (currentPlayer == p) judgementResults.Add(result); }; - p.ScoreProcessor.AllJudged += () => - { - if (currentPlayer == p) allJudgedFired = true; - }; }; LoadScreen(currentPlayer = p); - allJudgedFired = false; judgementResults = new List(); }); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); - AddUntilStep("Wait for all judged", () => allJudgedFired); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } private class ScoreAccessibleReplayPlayer : ReplayPlayer diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs index 0522260150..fe9973f4d8 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs @@ -1,18 +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 NUnit.Framework; +using osu.Framework.Utils; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene { + [SetUp] + public void Setup() => Schedule(() => + { + HitObjectContainer.Clear(); + ResetPlacement(); + }); + + [Test] + public void TestBeginPlacementWithoutFinishing() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + assertPlaced(false); + } + + [Test] + public void TestPlaceWithoutMovingMouse() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(0); + assertControlPointType(0, PathType.Linear); + } + + [Test] + public void TestPlaceWithMouseMovement() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 200)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(200); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + } + + [Test] + public void TestPlaceNormalControlPoint() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestPlaceTwoNormalControlPoints() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100, 100)); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlaceSegmentControlPoint() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.Linear); + } + + [Test] + public void TestMoveToPerfectCurveThenPlaceLinear() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + assertLength(100); + } + + [Test] + public void TestMoveToBezierThenPlacePerfectCurve() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestMoveToFourthOrderBezierThenPlaceThirdOrderBezier() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400)); + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlaceLinearSegmentThenPlaceLinearSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.Linear); + } + + [Test] + public void TestPlaceLinearSegmentThenPlacePerfectCurveSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.PerfectCurve); + } + + [Test] + public void TestPlacePerfectCurveSegmentThenPlacePerfectCurveSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(5); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointPosition(3, new Vector2(200, 100)); + assertControlPointPosition(4, new Vector2(200)); + assertControlPointType(0, PathType.PerfectCurve); + assertControlPointType(2, PathType.PerfectCurve); + } + + [Test] + public void TestBeginPlacementWithoutReleasingMouse() + { + addMovementStep(new Vector2(200)); + AddStep("press left button", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(400, 200)); + AddStep("release left button", () => InputManager.ReleaseButton(MouseButton.Left)); + + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(200); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + } + + private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); + + private void addClickStep(MouseButton button) + { + AddStep($"press {button}", () => InputManager.PressButton(button)); + AddStep($"release {button}", () => InputManager.ReleaseButton(button)); + } + + private void assertPlaced(bool expected) => AddAssert($"slider {(expected ? "placed" : "not placed")}", () => (getSlider() != null) == expected); + + private void assertLength(double expected) => AddAssert($"slider length is {expected}", () => Precision.AlmostEquals(expected, getSlider().Distance, 1)); + + private void assertControlPointCount(int expected) => AddAssert($"has {expected} control points", () => getSlider().Path.ControlPoints.Count == expected); + + private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => getSlider().Path.ControlPoints[index].Type.Value == type); + + private void assertControlPointPosition(int index, Vector2 position) => + AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider().Path.ControlPoints[index].Position.Value, 1)); + + private Slider getSlider() => HitObjectContainer.Count > 0 ? (Slider)((DrawableSlider)HitObjectContainer[0]).HitObject : null; + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject); protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs index 013920684c..d5be538d94 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; @@ -22,16 +20,6 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SliderSelectionBlueprint), - typeof(SliderCircleSelectionBlueprint), - typeof(SliderBodyPiece), - typeof(SliderCircle), - typeof(PathControlPointVisualiser), - typeof(PathControlPointPiece) - }; - private Slider slider; private DrawableSlider drawableObject; private TestSliderBlueprint blueprint; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs new file mode 100644 index 0000000000..a69646507a --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.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; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osu.Game.Storyboards; +using osuTK; +using static osu.Game.Tests.Visual.OsuTestScene.ClockBackedTestWorkingBeatmap; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneSliderSnaking : TestSceneOsuPlayer + { + [Resolved] + private AudioManager audioManager { get; set; } + + private TrackVirtualManual track; + + protected override bool Autoplay => autoplay; + private bool autoplay; + + private readonly BindableBool snakingIn = new BindableBool(); + private readonly BindableBool snakingOut = new BindableBool(); + + private const double duration_of_span = 3605; + private const double fade_in_modifier = -1200; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + var working = new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + track = (TrackVirtualManual)working.Track; + return working; + } + + [BackgroundDependencyLoader] + private void load(RulesetConfigCache configCache) + { + var config = (OsuRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + config.BindWith(OsuRulesetSetting.SnakingInSliders, snakingIn); + config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); + } + + private DrawableSlider slider; + + [SetUpSteps] + public override void SetUpSteps() + { + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void TestSnakingEnabled(int sliderIndex) + { + AddStep("enable autoplay", () => autoplay = true); + base.SetUpSteps(); + AddUntilStep("wait for track to start running", () => track.IsRunning); + + double startTime = hitObjects[sliderIndex].StartTime; + retrieveDrawableSlider(sliderIndex); + setSnaking(true); + + ensureSnakingIn(startTime + fade_in_modifier); + + for (int i = 0; i < sliderIndex; i++) + { + // non-final repeats should not snake out + ensureNoSnakingOut(startTime, i); + } + + // final repeat should snake out + ensureSnakingOut(startTime, sliderIndex); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void TestSnakingDisabled(int sliderIndex) + { + AddStep("have autoplay", () => autoplay = true); + base.SetUpSteps(); + AddUntilStep("wait for track to start running", () => track.IsRunning); + + double startTime = hitObjects[sliderIndex].StartTime; + retrieveDrawableSlider(sliderIndex); + setSnaking(false); + + ensureNoSnakingIn(startTime + fade_in_modifier); + + for (int i = 0; i <= sliderIndex; i++) + { + // no snaking out ever, including final repeat + ensureNoSnakingOut(startTime, i); + } + } + + [Test] + public void TestRepeatArrowDoesNotMoveWhenHit() + { + AddStep("enable autoplay", () => autoplay = true); + setSnaking(true); + base.SetUpSteps(); + + // repeat might have a chance to update its position depending on where in the frame its hit, + // so some leniency is allowed here instead of checking strict equality + checkPositionChange(16600, sliderRepeat, positionAlmostSame); + } + + [Test] + public void TestRepeatArrowMovesWhenNotHit() + { + AddStep("disable autoplay", () => autoplay = false); + setSnaking(true); + base.SetUpSteps(); + + checkPositionChange(16600, sliderRepeat, positionDecreased); + } + + private void retrieveDrawableSlider(int index) => + AddStep($"retrieve {(index + 1).ToOrdinalWords()} slider", () => + slider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.ElementAt(index)); + + private void ensureSnakingIn(double startTime) => checkPositionChange(startTime, sliderEnd, positionIncreased); + private void ensureNoSnakingIn(double startTime) => checkPositionChange(startTime, sliderEnd, positionRemainsSame); + + private void ensureSnakingOut(double startTime, int repeatIndex) + { + var repeatTime = timeAtRepeat(startTime, repeatIndex); + + if (repeatIndex % 2 == 0) + checkPositionChange(repeatTime, sliderStart, positionIncreased); + else + checkPositionChange(repeatTime, sliderEnd, positionDecreased); + } + + private void ensureNoSnakingOut(double startTime, int repeatIndex) => + checkPositionChange(timeAtRepeat(startTime, repeatIndex), positionAtRepeat(repeatIndex), positionRemainsSame); + + private double timeAtRepeat(double startTime, int repeatIndex) => startTime + 100 + duration_of_span * repeatIndex; + private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? (Func)sliderStart : sliderEnd; + + private List sliderCurve => ((PlaySliderBody)slider.Body.Drawable).CurrentCurve; + private Vector2 sliderStart() => sliderCurve.First(); + private Vector2 sliderEnd() => sliderCurve.Last(); + + private Vector2 sliderRepeat() + { + var drawable = Player.DrawableRuleset.Playfield.AllHitObjects.ElementAt(1); + var repeat = drawable.ChildrenOfType>().First().Children.First(); + return repeat.Position; + } + + private bool positionRemainsSame(Vector2 previous, Vector2 current) => previous == current; + private bool positionIncreased(Vector2 previous, Vector2 current) => current.X > previous.X && current.Y > previous.Y; + private bool positionDecreased(Vector2 previous, Vector2 current) => current.X < previous.X && current.Y < previous.Y; + private bool positionAlmostSame(Vector2 previous, Vector2 current) => Precision.AlmostEquals(previous, current, 1); + + private void checkPositionChange(double startTime, Func positionToCheck, Func positionAssertion) + { + Vector2 previousPosition = Vector2.Zero; + + string positionDescription = positionToCheck.Method.Name.Humanize(LetterCasing.LowerCase); + string assertionDescription = positionAssertion.Method.Name.Humanize(LetterCasing.LowerCase); + + addSeekStep(startTime); + AddStep($"save {positionDescription} position", () => previousPosition = positionToCheck.Invoke()); + addSeekStep(startTime + 100); + AddAssert($"{positionDescription} {assertionDescription}", () => + { + var currentPosition = positionToCheck.Invoke(); + return positionAssertion.Invoke(previousPosition, currentPosition); + }); + } + + private void setSnaking(bool value) + { + AddStep($"{(value ? "enable" : "disable")} snaking", () => + { + snakingIn.Value = value; + snakingOut.Value = value; + }); + } + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => track.Seek(time)); + + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = hitObjects + }; + + private readonly List hitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + }, + new Slider + { + StartTime = 13000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + RepeatCount = 1, + }, + new Slider + { + StartTime = 23000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + RepeatCount = 2, + }, + new HitCircle + { + StartTime = 199999, + } + }; + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 6533aef8e3..67afc45e32 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +10,6 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests @@ -20,13 +17,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSpinner : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SpinnerDisc), - typeof(DrawableSpinner), - typeof(DrawableOsuHitObject) - }; - private readonly Container content; protected override Container Content => content; @@ -46,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void testSingle(float circleSize, bool auto = false) { - var spinner = new Spinner { StartTime = Time.Current + 1000, EndTime = Time.Current + 4000 }; + var spinner = new Spinner { StartTime = Time.Current + 2000, EndTime = Time.Current + 5000 }; spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); @@ -64,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestDrawableSpinner : DrawableSpinner { - private readonly bool auto; + private bool auto; public TestDrawableSpinner(Spinner s, bool auto) : base(s) @@ -74,8 +64,12 @@ namespace osu.Game.Rulesets.Osu.Tests protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (auto && !userTriggered && Time.Current > Spinner.StartTime) - Disc.RotationAbsolute += Progress >= 1 ? 10 : (float)(Spinner.Duration / 120); + if (auto && !userTriggered && Time.Current > Spinner.StartTime + Spinner.Duration / 2 && Progress < 1) + { + // force completion only once to not break human interaction + Disc.CumulativeRotation = Spinner.SpinsRequired * 360; + auto = false; + } base.CheckForResult(userTriggered, timeOffset); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs index dd863deed2..91b6a05fe3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,8 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSpinnerHidden : TestSceneSpinner { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - [SetUp] public void SetUp() => Schedule(() => { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 47371fb54f..0f1cbcd44c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.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. +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.MathUtils; +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; 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.Storyboards; using osu.Game.Tests.Visual; using osuTK; -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Storyboards; using static osu.Game.Tests.Visual.OsuTestScene.ClockBackedTestWorkingBeatmap; namespace osu.Game.Rulesets.Osu.Tests @@ -31,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool Autoplay => true; - protected override Player CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) { @@ -41,6 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests } private DrawableSpinner drawableSpinner; + private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); [SetUpSteps] public override void SetUpSteps() @@ -48,32 +54,85 @@ namespace osu.Game.Rulesets.Osu.Tests base.SetUpSteps(); AddUntilStep("wait for track to start running", () => track.IsRunning); - AddStep("retrieve spinner", () => drawableSpinner = (DrawableSpinner)((TestPlayer)Player).DrawableRuleset.Playfield.AllHitObjects.First()); + AddStep("retrieve spinner", () => drawableSpinner = (DrawableSpinner)Player.DrawableRuleset.Playfield.AllHitObjects.First()); } [Test] public void TestSpinnerRewindingRotation() { addSeekStep(5000); - AddAssert("is rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, 0, 100)); + AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Disc.Rotation, 0, 100)); + AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Disc.CumulativeRotation, 0, 100)); addSeekStep(0); - AddAssert("is rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, 0, 100)); + AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.Disc.Rotation, 0, 100)); + AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Disc.CumulativeRotation, 0, 100)); } [Test] public void TestSpinnerMiddleRewindingRotation() { - double estimatedRotation = 0; + double finalAbsoluteDiscRotation = 0, finalRelativeDiscRotation = 0, finalSpinnerSymbolRotation = 0; addSeekStep(5000); - AddStep("retrieve rotation", () => estimatedRotation = drawableSpinner.Disc.RotationAbsolute); + AddStep("retrieve disc relative rotation", () => finalRelativeDiscRotation = drawableSpinner.Disc.Rotation); + AddStep("retrieve disc absolute rotation", () => finalAbsoluteDiscRotation = drawableSpinner.Disc.CumulativeRotation); + AddStep("retrieve spinner symbol rotation", () => finalSpinnerSymbolRotation = spinnerSymbol.Rotation); addSeekStep(2500); + AddUntilStep("disc rotation rewound", + // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. + () => Precision.AlmostEquals(drawableSpinner.Disc.Rotation, finalRelativeDiscRotation / 2, 100)); + AddUntilStep("symbol rotation rewound", + () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, 100)); + addSeekStep(5000); - AddAssert("is rotation absolute almost same", () => Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, estimatedRotation, 100)); + AddAssert("is disc rotation almost same", + () => Precision.AlmostEquals(drawableSpinner.Disc.Rotation, finalRelativeDiscRotation, 100)); + AddAssert("is symbol rotation almost same", + () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, 100)); + AddAssert("is disc rotation absolute almost same", + () => Precision.AlmostEquals(drawableSpinner.Disc.CumulativeRotation, finalAbsoluteDiscRotation, 100)); } + [Test] + public void TestRotationDirection([Values(true, false)] bool clockwise) + { + if (clockwise) + { + AddStep("flip replay", () => + { + var drawableRuleset = this.ChildrenOfType().Single(); + var score = drawableRuleset.ReplayScore; + var scoreWithFlippedReplay = new Score + { + ScoreInfo = score.ScoreInfo, + Replay = flipReplay(score.Replay) + }; + drawableRuleset.SetReplayScore(scoreWithFlippedReplay); + }); + } + + addSeekStep(5000); + + AddAssert("disc spin direction correct", () => clockwise ? drawableSpinner.Disc.Rotation > 0 : drawableSpinner.Disc.Rotation < 0); + AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); + } + + private Replay flipReplay(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() + }; + [Test] public void TestSpinnerNormalBonusRewinding() { @@ -83,7 +142,7 @@ namespace osu.Game.Rulesets.Osu.Tests { // multipled by 2 to nullify the score multiplier. (autoplay mod selected) var totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; - return totalScore == (int)(drawableSpinner.Disc.RotationAbsolute / 360) * 100; + return totalScore == (int)(drawableSpinner.Disc.CumulativeRotation / 360) * 100; }); addSeekStep(0); @@ -119,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep($"seek to {time}", () => track.Seek(time)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, ((TestPlayer)Player).DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs index d777ca3610..011463ab14 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; @@ -18,12 +15,6 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSpinnerSelectionBlueprint : SelectionBlueprintTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SpinnerSelectionBlueprint), - typeof(SpinnerPiece) - }; - public TestSceneSpinnerSelectionBlueprint() { var spinner = new Spinner diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSpunOut.cs new file mode 100644 index 0000000000..d1210db6b1 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSpunOut.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneSpinnerSpunOut : OsuTestScene + { + [SetUp] + public void SetUp() => Schedule(() => + { + SelectedMods.Value = new[] { new OsuModSpunOut() }; + }); + + [Test] + public void TestSpunOut() + { + DrawableSpinner spinner = null; + + AddStep("create spinner", () => spinner = createSpinner()); + + AddUntilStep("wait for end", () => Time.Current > spinner.LifetimeEnd); + + AddAssert("spinner is completed", () => spinner.Progress >= 1); + } + + private DrawableSpinner createSpinner() + { + var spinner = new Spinner + { + StartTime = Time.Current + 500, + EndTime = Time.Current + 2500 + }; + spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + var drawableSpinner = new DrawableSpinner(spinner) + { + Anchor = Anchor.Centre + }; + + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObjects(new[] { drawableSpinner }); + + Add(drawableSpinner); + return drawableSpinner; + } + } +} 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 9d4e016eae..d6a68abaf2 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -2,9 +2,9 @@ - + - + diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index 147d74c929..fcad356a1c 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps switch (original) { - case IHasCurve curveData: + case IHasPathWithRepeats curveData: return new Slider { StartTime = original.StartTime, @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps TickDistanceMultiplier = beatmap.BeatmapInfo.BeatmapVersion < 8 ? 1f / beatmap.ControlPointInfo.DifficultyPointAt(original.StartTime).SpeedMultiplier : 1 }.Yield(); - case IHasEndTime endTimeData: + case IHasDuration endTimeData: return new Spinner { StartTime = original.StartTime, diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs index 3a829f72fa..f51f04bf87 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps double stackThreshold = objectN.TimePreempt * beatmap.BeatmapInfo.StackLeniency; if (objectN.StartTime - endTime > stackThreshold) - //We are no longer within stacking range of the next object. + // We are no longer within stacking range of the next object. break; if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < stack_distance @@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps } } - //Reverse pass for stack calculation. + // Reverse pass for stack calculation. int extendedStartIndex = startIndex; for (int i = extendedEndIndex; i > startIndex; i--) @@ -124,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps double endTime = objectN.GetEndTime(); if (objectI.StartTime - endTime > stackThreshold) - //We are no longer within stacking range of the previous object. + // We are no longer within stacking range of the previous object. break; // HitObjects before the specified update range haven't been reset yet @@ -145,20 +145,20 @@ namespace osu.Game.Rulesets.Osu.Beatmaps for (int j = n + 1; j <= i; j++) { - //For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). + // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). OsuHitObject objectJ = beatmap.HitObjects[j]; if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance) objectJ.StackHeight -= offset; } - //We have hit a slider. We should restart calculation using this as the new base. - //Breaking here will mean that the slider still has StackCount of 0, so will be handled in the i-outer-loop. + // We have hit a slider. We should restart calculation using this as the new base. + // Breaking here will mean that the slider still has StackCount of 0, so will be handled in the i-outer-loop. break; } if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < stack_distance) { - //Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out. + // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out. //NOTE: Sliders with start positions stacking are a special case that is also handled here. objectN.StackHeight = objectI.StackHeight + 1; @@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps if (objectN is Spinner) continue; if (objectI.StartTime - objectN.StartTime > stackThreshold) - //We are no longer within stacking range of the previous object. + // We are no longer within stacking range of the previous object. break; if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance) @@ -221,7 +221,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps } else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance) { - //Case for sliders - bump notes down and right, rather than up and left. + // Case for sliders - bump notes down and right, rather than up and left. sliderStack++; beatmap.HitObjects[j].StackHeight -= sliderStack; startTime = beatmap.HitObjects[j].GetEndTime(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index ce8ecf02ac..6f4c0f9cfa 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -45,10 +46,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty mods = Score.Mods; accuracy = Score.Accuracy; scoreMaxCombo = Score.MaxCombo; - countGreat = Score.Statistics[HitResult.Great]; - countGood = Score.Statistics[HitResult.Good]; - countMeh = Score.Statistics[HitResult.Meh]; - countMiss = Score.Statistics[HitResult.Miss]; + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countGood = Score.Statistics.GetOrDefault(HitResult.Good); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) @@ -180,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty int amountHitObjectsWithAccuracy = countHitCircles; if (amountHitObjectsWithAccuracy > 0) - betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countGood * 2 + countMeh) / (amountHitObjectsWithAccuracy * 6); + betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countGood * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); else betterAccuracyPercentage = 0; @@ -203,7 +204,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty return accuracyValue; } - private double totalHits => countGreat + countGood + countMeh + countMiss; - private double totalSuccessfulHits => countGreat + countGood + countMeh; + private int totalHits => countGreat + countGood + countMeh + countMiss; + private int totalSuccessfulHits => countGreat + countGood + countMeh; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index bb47c7e464..3dbbdcc5d0 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -5,7 +5,7 @@ using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; -using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { @@ -28,15 +28,21 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles circlePiece.UpdateFrom(HitObject); } - protected override bool OnClick(ClickEvent e) + protected override bool OnMouseDown(MouseDownEvent e) { - EndPlacement(); - return true; + if (e.Button == MouseButton.Left) + { + EndPlacement(true); + return true; + } + + return base.OnMouseDown(e); } - public override void UpdatePosition(Vector2 screenSpacePosition) + public override void UpdatePosition(SnapResult result) { - HitObject.Position = ToLocalSpace(screenSpacePosition); + base.UpdatePosition(result); + HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index a864257274..8dd550bb96 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -7,10 +7,12 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit.Blueprints { - public abstract class OsuSelectionBlueprint : SelectionBlueprint + public abstract class OsuSelectionBlueprint : OverlaySelectionBlueprint where T : OsuHitObject { - protected T HitObject => (T)DrawableObject.HitObject; + protected new T HitObject => (T)DrawableObject.HitObject; + + protected override bool AlwaysShowWhenSelected => true; protected OsuSelectionBlueprint(DrawableHitObject drawableObject) : base(drawableObject) 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 0fc441fec6..ba1d35c35c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs @@ -16,22 +16,25 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// public class PathControlPointConnectionPiece : CompositeDrawable { - public PathControlPoint ControlPoint; + public readonly PathControlPoint ControlPoint; private readonly Path path; private readonly Slider slider; + private readonly int controlPointIndex; private IBindable sliderPosition; private IBindable pathVersion; - public PathControlPointConnectionPiece(Slider slider, PathControlPoint controlPoint) + public PathControlPointConnectionPiece(Slider slider, int controlPointIndex) { this.slider = slider; - ControlPoint = controlPoint; + this.controlPointIndex = controlPointIndex; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; + ControlPoint = slider.Path.ControlPoints[controlPointIndex]; + InternalChild = path = new SmoothPath { Anchor = Anchor.Centre, @@ -61,13 +64,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components path.ClearVertices(); - int index = slider.Path.ControlPoints.IndexOf(ControlPoint) + 1; - - if (index == 0 || index == slider.Path.ControlPoints.Count) + int nextIndex = controlPointIndex + 1; + if (nextIndex == 0 || nextIndex >= slider.Path.ControlPoints.Count) return; path.AddVertex(Vector2.Zero); - path.AddVertex(slider.Path.ControlPoints[index].Position.Value - ControlPoint.Position.Value); + path.AddVertex(slider.Path.ControlPoints[nextIndex].Position.Value - ControlPoint.Position.Value); 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 6a0730db91..c06904c0c2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -4,6 +4,7 @@ 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; @@ -12,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -26,7 +28,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public Action RequestSelection; public readonly BindableBool IsSelected = new BindableBool(); - public readonly PathControlPoint ControlPoint; private readonly Slider slider; @@ -34,7 +35,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private readonly Drawable markerRing; [Resolved(CanBeNull = true)] - private IDistanceSnapProvider snapProvider { get; set; } + private IEditorChangeHandler changeHandler { get; set; } + + [Resolved(CanBeNull = true)] + private IPositionSnapProvider snapProvider { get; set; } [Resolved] private OsuColour colours { get; set; } @@ -47,6 +51,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components this.slider = slider; ControlPoint = controlPoint; + controlPoint.Type.BindValueChanged(_ => updateMarkerDisplay()); + Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; @@ -135,22 +141,33 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return false; } - protected override bool OnMouseUp(MouseUpEvent e) => RequestSelection != null; - protected override bool OnClick(ClickEvent e) => RequestSelection != null; - protected override bool OnDragStart(DragStartEvent e) => e.Button == MouseButton.Left; + protected override bool OnDragStart(DragStartEvent e) + { + if (RequestSelection == null) + return false; - protected override bool OnDrag(DragEvent e) + if (e.Button == MouseButton.Left) + { + changeHandler?.BeginChange(); + return true; + } + + return false; + } + + protected override void OnDrag(DragEvent e) { if (ControlPoint == slider.Path.ControlPoints[0]) { // Special handling for the head control point - the position of the slider changes which means the snapped position and time have to be taken into account - (Vector2 snappedPosition, double snappedTime) = snapProvider?.GetSnappedPosition(e.MousePosition, slider.StartTime) ?? (e.MousePosition, slider.StartTime); - Vector2 movementDelta = snappedPosition - slider.Position; + var result = snapProvider?.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition); + + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? e.ScreenSpaceMousePosition) - slider.Position; slider.Position += movementDelta; - slider.StartTime = snappedTime; + slider.StartTime = result?.Time ?? slider.StartTime; // Since control points are relative to the position of the slider, they all need to be offset backwards by the delta for (int i = 1; i < slider.Path.ControlPoints.Count; i++) @@ -158,11 +175,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else ControlPoint.Position.Value += e.Delta; - - return true; } - protected override bool OnDragEnd(DragEndEvent e) => true; + protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); /// /// Updates the state of the circular control point marker. @@ -174,8 +189,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components markerRing.Alpha = IsSelected.Value ? 1 : 0; Color4 colour = ControlPoint.Type.Value != null ? colours.Red : colours.Yellow; + if (IsHovered || IsSelected.Value) - colour = Color4.White; + colour = colour.Lighten(1); + marker.Colour = colour; } } 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 6f583d7983..f6354bc612 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Humanizer; using osu.Framework.Bindables; @@ -24,17 +25,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { internal readonly Container Pieces; + internal readonly Container Connections; - private readonly Container connections; - + private readonly IBindableList controlPoints = new BindableList(); private readonly Slider slider; - private readonly bool allowSelection; private InputManager inputManager; - private IBindableList controlPoints; - public Action> RemoveControlPointsRequested; public PathControlPointVisualiser(Slider slider, bool allowSelection) @@ -46,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components InternalChildren = new Drawable[] { - connections = new Container { RelativeSizeAxes = Axes.Both }, + Connections = new Container { RelativeSizeAxes = Axes.Both }, Pieces = new Container { RelativeSizeAxes = Axes.Both } }; } @@ -57,33 +55,38 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components inputManager = GetContainingInputManager(); - controlPoints = slider.Path.ControlPoints.GetBoundCopy(); - controlPoints.ItemsAdded += addControlPoints; - controlPoints.ItemsRemoved += removeControlPoints; - - addControlPoints(controlPoints); + controlPoints.CollectionChanged += onControlPointsChanged; + controlPoints.BindTo(slider.Path.ControlPoints); } - private void addControlPoints(IEnumerable controlPoints) + private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { - foreach (var point in controlPoints) + switch (e.Action) { - Pieces.Add(new PathControlPointPiece(slider, point).With(d => - { - if (allowSelection) - d.RequestSelection = selectPiece; - })); + case NotifyCollectionChangedAction.Add: + for (int i = 0; i < e.NewItems.Count; i++) + { + var point = (PathControlPoint)e.NewItems[i]; - connections.Add(new PathControlPointConnectionPiece(slider, point)); - } - } + Pieces.Add(new PathControlPointPiece(slider, point).With(d => + { + if (allowSelection) + d.RequestSelection = selectPiece; + })); - private void removeControlPoints(IEnumerable controlPoints) - { - foreach (var point in controlPoints) - { - Pieces.RemoveAll(p => p.ControlPoint == point); - connections.RemoveAll(c => c.ControlPoint == point); + Connections.Add(new PathControlPointConnectionPiece(slider, e.NewStartingIndex + i)); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var point in e.OldItems.Cast()) + { + Pieces.RemoveAll(p => p.ControlPoint == point); + Connections.RemoveAll(c => c.ControlPoint == point); + } + + break; } } @@ -108,7 +111,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return false; } - public bool OnReleased(PlatformAction action) => action.ActionMethod == PlatformActionMethod.Delete; + public void OnReleased(PlatformAction action) + { + } private void selectPiece(PathControlPointPiece piece, MouseButtonEvent e) { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs index f09279ed73..a0392fe536 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs @@ -17,6 +17,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders : base(slider) { this.position = position; + InternalChild = CirclePiece = new HitCirclePiece(); Select(); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 2497e428fc..4b99cc23ed 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; @@ -23,6 +26,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private SliderBodyPiece bodyPiece; private HitCirclePiece headCirclePiece; private HitCirclePiece tailCirclePiece; + private PathControlPointVisualiser controlPointVisualiser; private InputManager inputManager; @@ -51,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders bodyPiece = new SliderBodyPiece(), headCirclePiece = new HitCirclePiece(), tailCirclePiece = new HitCirclePiece(), - new PathControlPointVisualiser(HitObject, false) + controlPointVisualiser = new PathControlPointVisualiser(HitObject, false) }; setState(PlacementState.Initial); @@ -63,26 +67,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders inputManager = GetContainingInputManager(); } - public override void UpdatePosition(Vector2 screenSpacePosition) + public override void UpdatePosition(SnapResult result) { + base.UpdatePosition(result); + switch (state) { case PlacementState.Initial: - HitObject.Position = ToLocalSpace(screenSpacePosition); + BeginPlacement(); + HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); break; case PlacementState.Body: - ensureCursor(); - - // The given screen-space position may have been externally snapped, but the unsnapped position from the input manager - // is used instead since snapping control points doesn't make much sense - cursor.Position.Value = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + updateCursor(); break; } } - protected override bool OnClick(ClickEvent e) + protected override bool OnMouseDown(MouseDownEvent e) { + if (e.Button != MouseButton.Left) + return base.OnMouseDown(e); + switch (state) { case PlacementState.Initial: @@ -90,14 +96,21 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders break; case PlacementState.Body: - switch (e.Button) + if (canPlaceNewControlPoint(out var lastPoint)) { - case MouseButton.Left: - ensureCursor(); + // Place a new point by detatching the current cursor. + updateCursor(); + cursor = null; + } + else + { + // Transform the last point into a new segment. + Debug.Assert(lastPoint != null); - // Detatch the cursor - cursor = null; - break; + segmentStart = lastPoint; + segmentStart.Type.Value = PathType.Linear; + + currentSegmentLength = 1; } break; @@ -106,33 +119,23 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (state == PlacementState.Body && e.Button == MouseButton.Right) endCurve(); - return base.OnMouseUp(e); - } - - protected override bool OnDoubleClick(DoubleClickEvent e) - { - // Todo: This should all not occur on double click, but rather if the previous control point is hovered. - segmentStart = HitObject.Path.ControlPoints[^1]; - segmentStart.Type.Value = PathType.Linear; - - currentSegmentLength = 1; - return true; + base.OnMouseUp(e); } private void beginCurve() { - BeginPlacement(); + BeginPlacement(commitStart: true); setState(PlacementState.Body); } private void endCurve() { updateSlider(); - EndPlacement(); + EndPlacement(true); } protected override void Update() @@ -160,17 +163,50 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - private void ensureCursor() + private void updateCursor() { - if (cursor == null) + if (canPlaceNewControlPoint(out _)) { - HitObject.Path.ControlPoints.Add(cursor = new PathControlPoint { Position = { Value = Vector2.Zero } }); - currentSegmentLength++; + // The cursor does not overlap a previous control point, so it can be added if not already existing. + if (cursor == null) + { + HitObject.Path.ControlPoints.Add(cursor = new PathControlPoint { Position = { Value = Vector2.Zero } }); + // The path type should be adjusted in the progression of updatePathType() (Linear -> PC -> Bezier). + currentSegmentLength++; + updatePathType(); + } + + // Update the cursor position. + cursor.Position.Value = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + } + else if (cursor != null) + { + // The cursor overlaps a previous control point, so it's removed. + HitObject.Path.ControlPoints.Remove(cursor); + cursor = null; + + // The path type should be adjusted in the reverse progression of updatePathType() (Bezier -> PC -> Linear). + currentSegmentLength--; updatePathType(); } } + /// + /// Whether a new control point can be placed at the current mouse position. + /// + /// The last-placed control point. May be null, but is not null if false is returned. + /// Whether a new control point can be placed at the current position. + private bool canPlaceNewControlPoint([CanBeNull] out PathControlPoint lastPoint) + { + // We cannot rely on the ordering of drawable pieces, so find the respective drawable piece by searching for the last non-cursor control point. + var last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor); + var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last); + + lastPoint = last; + return lastPiece?.IsHovered != true; + } + private void updateSlider() { HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 3165c441fb..6633136673 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; using osuTK.Input; @@ -34,6 +35,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IPlacementHandler placementHandler { get; set; } + [Resolved(CanBeNull = true)] + private EditorBeatmap editorBeatmap { get; set; } + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + public SliderSelectionBlueprint(DrawableSlider slider) : base(slider) { @@ -88,21 +95,31 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private int? placementControlPointIndex; - protected override bool OnDragStart(DragStartEvent e) => placementControlPointIndex != null; + protected override bool OnDragStart(DragStartEvent e) + { + if (placementControlPointIndex != null) + { + changeHandler?.BeginChange(); + return true; + } - protected override bool OnDrag(DragEvent e) + return false; + } + + protected override void OnDrag(DragEvent e) { Debug.Assert(placementControlPointIndex != null); HitObject.Path.ControlPoints[placementControlPointIndex.Value].Position.Value = e.MousePosition - HitObject.Position; - - return true; } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { - placementControlPointIndex = null; - return true; + if (placementControlPointIndex != null) + { + placementControlPointIndex = null; + changeHandler?.EndChange(); + } } private BindableList controlPoints => HitObject.Path.ControlPoints; @@ -165,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updatePath() { HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; - UpdateHitObject(); + editorBeatmap?.UpdateHitObject(HitObject); } public override MenuItem[] ContextMenuItems => new MenuItem[] @@ -173,7 +190,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)), }; - public override Vector2 SelectionPoint => HeadBlueprint.SelectionPoint; + public override Vector2 ScreenSpaceSelectionPoint => ((DrawableSlider)DrawableObject).HeadCircle.ScreenSpaceDrawQuad.Centre; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => BodyPiece.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index 5525b8936e..cc4ed0eccf 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; -using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { @@ -29,29 +30,34 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { base.Update(); + if (isPlacingEnd) + HitObject.EndTime = Math.Max(HitObject.StartTime, EditorClock.CurrentTime); + piece.UpdateFrom(HitObject); } - protected override bool OnClick(ClickEvent e) + protected override bool OnMouseDown(MouseDownEvent e) { if (isPlacingEnd) { + if (e.Button != MouseButton.Right) + return false; + HitObject.EndTime = EditorClock.CurrentTime; - EndPlacement(); + EndPlacement(true); } else { - isPlacingEnd = true; + if (e.Button != MouseButton.Left) + return false; + + BeginPlacement(commitStart: true); piece.FadeTo(1f, 150, Easing.OutQuint); - BeginPlacement(); + isPlacingEnd = true; } return true; } - - public override void UpdatePosition(Vector2 screenSpacePosition) - { - } } } diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs index 22b4c3e82e..a8719e0aa8 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs @@ -40,6 +40,8 @@ namespace osu.Game.Rulesets.Osu.Edit if (existing == null) return; + hitObject.RemoveTransform(existing); + using (hitObject.BeginAbsoluteSequence(existing.StartTime)) hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); break; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs new file mode 100644 index 0000000000..330f34b85c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.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.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuBlueprintContainer : ComposeBlueprintContainer + { + public OsuBlueprintContainer(IEnumerable drawableHitObjects) + : base(drawableHitObjects) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + { + switch (hitObject) + { + case DrawableHitCircle circle: + return new HitCircleSelectionBlueprint(circle); + + case DrawableSlider slider: + return new SliderSelectionBlueprint(slider); + + case DrawableSpinner spinner: + return new SpinnerSelectionBlueprint(spinner); + } + + return base.CreateBlueprintFor(hitObject); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 49624ea733..37019a7a05 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -4,19 +4,20 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit { @@ -37,31 +38,90 @@ namespace osu.Game.Rulesets.Osu.Edit new SpinnerCompositionTool() }; - public override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); - - public override SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + [BackgroundDependencyLoader] + private void load() { - switch (hitObject) - { - case DrawableHitCircle circle: - return new HitCircleSelectionBlueprint(circle); + LayerBelowRuleset.Add(distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both }); - case DrawableSlider slider: - return new SliderSelectionBlueprint(slider); - - case DrawableSpinner spinner: - return new SpinnerSelectionBlueprint(spinner); - } - - return base.CreateBlueprintFor(hitObject); + EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); + EditorBeatmap.PlacementObject.ValueChanged += _ => updateDistanceSnapGrid(); } - protected override DistanceSnapGrid CreateDistanceSnapGrid(IEnumerable selectedHitObjects) + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new OsuBlueprintContainer(hitObjects); + + private DistanceSnapGrid distanceSnapGrid; + private Container distanceSnapGridContainer; + + private readonly Cached distanceSnapGridCache = new Cached(); + private double? lastDistanceSnapGridTime; + + protected override void Update() { + base.Update(); + + if (!(BlueprintContainer.CurrentTool is SelectTool)) + { + if (EditorClock.CurrentTime != lastDistanceSnapGridTime) + { + distanceSnapGridCache.Invalidate(); + lastDistanceSnapGridTime = EditorClock.CurrentTime; + } + + if (!distanceSnapGridCache.IsValid) + updateDistanceSnapGrid(); + } + } + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + if (distanceSnapGrid == null) + return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } + + private void updateDistanceSnapGrid() + { + distanceSnapGridContainer.Clear(); + distanceSnapGridCache.Invalidate(); + + switch (BlueprintContainer.CurrentTool) + { + case SelectTool _: + if (!EditorBeatmap.SelectedHitObjects.Any()) + return; + + distanceSnapGrid = createDistanceSnapGrid(EditorBeatmap.SelectedHitObjects); + break; + + default: + if (!CursorInPlacementArea) + return; + + distanceSnapGrid = createDistanceSnapGrid(Enumerable.Empty()); + break; + } + + if (distanceSnapGrid != null) + { + distanceSnapGridContainer.Add(distanceSnapGrid); + distanceSnapGridCache.Validate(); + } + } + + private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) + { + if (BlueprintContainer.CurrentTool is SpinnerCompositionTool) + return null; + var objects = selectedHitObjects.ToList(); if (objects.Count == 0) - return createGrid(h => h.StartTime <= EditorClock.CurrentTime); + // use accurate time value to give more instantaneous feedback to the user. + return createGrid(h => h.StartTime <= EditorClock.CurrentTimeAccurate); double minTime = objects.Min(h => h.StartTime); return createGrid(h => h.StartTime < minTime, objects.Count + 1); @@ -111,6 +171,9 @@ namespace osu.Game.Rulesets.Osu.Edit targetIndex++; } + if (sourceObject is Spinner) + return null; + return new OsuDistanceSnapGrid((OsuHitObject)sourceObject, (OsuHitObject)targetObject); } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs new file mode 100644 index 0000000000..9b33e746b3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.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.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuHitCircleJudgementResult : OsuJudgementResult + { + /// + /// The . + /// + public HitCircle HitCircle => (HitCircle)HitObject; + + /// + /// The position of the player's cursor when was hit. + /// + public Vector2? CursorPositionAtHit; + + public OsuHitCircleJudgementResult(HitObject hitObject, Judgement judgement) + : base(hitObject, judgement) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs similarity index 63% rename from osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs rename to osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs index b28b6a0d17..e528f65dca 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs @@ -1,11 +1,11 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Taiko.Judgements +namespace osu.Game.Rulesets.Osu.Judgements { - public class TaikoSwellTickJudgement : TaikoJudgement + public class OsuIgnoreJudgement : OsuJudgement { public override bool AffectsCombo => false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 65d7acc911..d75f4c70d7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -18,13 +18,14 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Autopilot"; public override string Acronym => "AP"; - public override IconUsage Icon => OsuIcon.ModAutopilot; + 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(ModSuddenDeath), typeof(ModNoFail), typeof(ModAutoplay) }; - public bool AllowFail => false; + public bool PerformFail() => false; + public bool RestartOnFail => false; private OsuInputManager inputManager; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 831e4a700f..937473e824 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Description => "Play with blinds on your screen."; public override string Acronym => "BL"; - public override IconUsage Icon => FontAwesome.Solid.Adjust; + public override IconUsage? Icon => FontAwesome.Solid.Adjust; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index 9bf7525d33..73cb483ef0 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "DF"; - public override IconUsage Icon => FontAwesome.Solid.CompressArrowsAlt; + public override IconUsage? Icon => FontAwesome.Solid.CompressArrowsAlt; public override string Description => "Hit them at the right size!"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 7eee71be81..ff995e38ce 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -10,26 +11,42 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModDifficultyAdjust : ModDifficultyAdjust { - [SettingSource("Circle Size", "Override a beatmap's set CS.")] + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] public BindableNumber CircleSize { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, }; - [SettingSource("Approach Rate", "Override a beatmap's set AR.")] + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] public BindableNumber ApproachRate { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, }; + public override string SettingDescription + { + get + { + string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; + string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + + return string.Join(", ", new[] + { + circleSize, + base.SettingDescription, + approachRate + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + protected override void TransferSettings(BeatmapDifficulty difficulty) { base.TransferSettings(difficulty); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 778c2f7d43..ac20407ed2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -8,7 +8,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 76676ce888..f08d4e8f5e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "GR"; - public override IconUsage Icon => FontAwesome.Solid.ArrowsAltV; + public override IconUsage? Icon => FontAwesome.Solid.ArrowsAltV; public override string Description => "Hit them at the right size!"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index bc5f79331f..e0577dd464 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -26,10 +26,13 @@ namespace osu.Game.Rulesets.Osu.Mods return; slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - foreach (var point in slider.Path.ControlPoints) + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); + foreach (var point in controlPoints) point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 91a4e049e3..fdba03f260 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Osu.Mods private const double fade_in_duration_multiplier = 0.4; private const double fade_out_duration_multiplier = 0.3; + protected override bool IsFirstHideableObject(DrawableHitObject hitObject) => !(hitObject is DrawableSpinner); + public override void ApplyToDrawableHitObjects(IEnumerable drawables) { static void adjustFadeIn(OsuHitObject h) => h.TimeFadeIn = h.TimePreempt * fade_in_duration_multiplier; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 649b01c132..5d191119b9 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -9,85 +9,131 @@ 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.Replays; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset + 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.Append(typeof(OsuModAutopilot)).ToArray(); - public void Update(Playfield playfield) - { - bool requiresHold = false; - bool requiresHit = false; + /// + /// How early before a hitobject's start time to trigger a hit. + /// + private const float relax_leniency = 3; - const float relax_leniency = 3; - - foreach (var drawable in playfield.HitObjectContainer.AliveObjects) - { - if (!(drawable is DrawableOsuHitObject osuHit)) - continue; - - double time = osuHit.Clock.CurrentTime; - double relativetime = time - osuHit.HitObject.StartTime; - - if (time < osuHit.HitObject.StartTime - relax_leniency) continue; - - if ((osuHit.HitObject is IHasEndTime hasEnd && time > hasEnd.EndTime) || osuHit.IsHit) - continue; - - if (osuHit is DrawableHitCircle && osuHit.IsHovered) - { - Debug.Assert(osuHit.HitObject.HitWindows != null); - requiresHit |= osuHit.HitObject.HitWindows.CanBeHit(relativetime); - } - - requiresHold |= (osuHit is DrawableSlider slider && (slider.Ball.IsHovered || osuHit.IsHovered)) || osuHit is DrawableSpinner; - } - - if (requiresHit) - { - addAction(false); - addAction(true); - } - - addAction(requiresHold); - } - - private bool wasHit; + private bool isDownState; private bool wasLeft; private OsuInputManager osuInputManager; - private void addAction(bool hitting) - { - if (wasHit == hitting) - return; + private ReplayState state; + private double lastStateChangeTime; - wasHit = hitting; - - var state = new ReplayState - { - PressedActions = new List() - }; - - if (hitting) - { - state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton); - wasLeft = !wasLeft; - } - - state.Apply(osuInputManager.CurrentState, osuInputManager); - } + private bool hasReplay; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { // grab the input manager for future use. osuInputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; + } + + public void ApplyToPlayer(Player player) + { + if (osuInputManager.ReplayInputHandler != null) + { + hasReplay = true; + return; + } + osuInputManager.AllowUserPresses = false; } + + public void Update(Playfield playfield) + { + if (hasReplay) + return; + + bool requiresHold = false; + bool requiresHit = false; + + double time = playfield.Clock.CurrentTime; + + foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType()) + { + // we are not yet close enough to the object. + if (time < h.HitObject.StartTime - relax_leniency) + break; + + // already hit or beyond the hittable end time. + if (h.IsHit || (h.HitObject is IHasDuration hasEnd && time > hasEnd.EndTime)) + continue; + + switch (h) + { + case DrawableHitCircle circle: + handleHitCircle(circle); + break; + + case DrawableSlider slider: + // Handles cases like "2B" beatmaps, where sliders may be overlapping and simply holding is not enough. + if (!slider.HeadCircle.IsHit) + handleHitCircle(slider.HeadCircle); + + requiresHold |= slider.Ball.IsHovered || h.IsHovered; + break; + + case DrawableSpinner _: + requiresHold = true; + break; + } + } + + if (requiresHit) + { + changeState(false); + changeState(true); + } + + if (requiresHold) + changeState(true); + else if (isDownState && time - lastStateChangeTime > AutoGenerator.KEY_UP_DELAY) + changeState(false); + + void handleHitCircle(DrawableHitCircle circle) + { + if (!circle.HitArea.IsHovered) + return; + + Debug.Assert(circle.HitObject.HitWindows != null); + requiresHit |= circle.HitObject.HitWindows.CanBeHit(time - circle.HitObject.StartTime); + } + + void changeState(bool down) + { + if (isDownState == down) + return; + + isDownState = down; + lastStateChangeTime = time; + + state = new ReplayState + { + PressedActions = new List() + }; + + if (down) + { + state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton); + wasLeft = !wasLeft; + } + + state?.Apply(osuInputManager.CurrentState, osuInputManager); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index eae218509e..940c888f3a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Spin In"; public override string Acronym => "SI"; - public override IconUsage Icon => FontAwesome.Solid.Undo; + public override IconUsage? Icon => FontAwesome.Solid.Undo; public override ModType Type => ModType.Fun; public override string Description => "Circles spin in. No approach circles."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index 1cdcddbd33..7b54baa99b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -2,21 +2,46 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModSpunOut : Mod + public class OsuModSpunOut : Mod, IApplicableToDrawableHitObjects { public override string Name => "Spun Out"; public override string Acronym => "SO"; - public override IconUsage Icon => OsuIcon.ModSpunout; - public override ModType Type => ModType.DifficultyReduction; + public override IconUsage? Icon => OsuIcon.ModSpunout; + public override ModType Type => ModType.Automation; public override string Description => @"Spinners will be automatically completed."; public override double ScoreMultiplier => 0.9; public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot) }; + + public void ApplyToDrawableHitObjects(IEnumerable drawables) + { + foreach (var hitObject in drawables) + { + if (hitObject is DrawableSpinner spinner) + { + spinner.HandleUserInput = false; + spinner.OnUpdate += onSpinnerUpdate; + } + } + } + + private void onSpinnerUpdate(Drawable drawable) + { + var spinner = (DrawableSpinner)drawable; + + spinner.Disc.Tracking = true; + spinner.Disc.Rotate(MathUtils.RadiansToDegrees((float)spinner.Clock.ElapsedFrameTime * 0.03f)); + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 8360e2692e..2464308347 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Target"; public override string Acronym => "TP"; public override ModType Type => ModType.Conversion; - public override IconUsage Icon => OsuIcon.ModTarget; + public override IconUsage? Icon => OsuIcon.ModTarget; public override string Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 1; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index dff9a77807..774f9cf58b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Bindables; using System.Collections.Generic; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -19,7 +18,6 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Traceable"; public override string Acronym => "TC"; - public override IconUsage Icon => FontAwesome.Brands.SnapchatGhost; public override ModType Type => ModType.Fun; public override string Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index a9475af638..5e80d08667 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Sprites; 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 osuTK; namespace osu.Game.Rulesets.Osu.Mods @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Transform"; public override string Acronym => "TR"; - public override IconUsage Icon => FontAwesome.Solid.ArrowsAlt; + public override IconUsage? Icon => FontAwesome.Solid.ArrowsAlt; public override ModType Type => ModType.Fun; public override string Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; @@ -27,26 +28,40 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableHitObjects(IEnumerable drawables) { foreach (var drawable in drawables) + drawable.ApplyCustomUpdateState += applyTransform; + } + + private void applyTransform(DrawableHitObject drawable, ArmedState state) + { + switch (drawable) { - var hitObject = (OsuHitObject)drawable.HitObject; + case DrawableSliderHead _: + case DrawableSliderTail _: + case DrawableSliderTick _: + case DrawableSliderRepeat _: + return; - float appearDistance = (float)(hitObject.TimePreempt - hitObject.TimeFadeIn) / 2; + default: + var hitObject = (OsuHitObject)drawable.HitObject; - Vector2 originalPosition = drawable.Position; - Vector2 appearOffset = new Vector2(MathF.Cos(theta), MathF.Sin(theta)) * appearDistance; + float appearDistance = (float)(hitObject.TimePreempt - hitObject.TimeFadeIn) / 2; - //the - 1 and + 1 prevents the hit objects to appear in the wrong position. - double appearTime = hitObject.StartTime - hitObject.TimePreempt - 1; - double moveDuration = hitObject.TimePreempt + 1; + Vector2 originalPosition = drawable.Position; + Vector2 appearOffset = new Vector2(MathF.Cos(theta), MathF.Sin(theta)) * appearDistance; - using (drawable.BeginAbsoluteSequence(appearTime, true)) - { - drawable - .MoveToOffset(appearOffset) - .MoveTo(originalPosition, moveDuration, Easing.InOutSine); - } + // the - 1 and + 1 prevents the hit objects to appear in the wrong position. + double appearTime = hitObject.StartTime - hitObject.TimePreempt - 1; + double moveDuration = hitObject.TimePreempt + 1; - theta += (float)hitObject.TimeFadeIn / 1000; + using (drawable.BeginAbsoluteSequence(appearTime, true)) + { + drawable + .MoveToOffset(appearOffset) + .MoveTo(originalPosition, moveDuration, Easing.InOutSine); + } + + theta += (float)hitObject.TimeFadeIn / 1000; + break; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 1664a37a66..3cad52faeb 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Wiggle"; public override string Acronym => "WG"; - public override IconUsage Icon => FontAwesome.Solid.Certificate; + 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 double ScoreMultiplier => 1; @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Mods // Wiggle the repeat points with the slider instead of independently. // Also fixes an issue with repeat points being positioned incorrectly. - if (osuObject is RepeatPoint) + if (osuObject is SliderRepeat) return; Random objRand = new Random((int)osuObject.StartTime); @@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Mods } // Keep wiggling sliders and spinners for their duration - if (!(osuObject is IHasEndTime endTime)) + if (!(osuObject is IHasDuration endTime)) return; amountWiggles = (int)(endTime.Duration / 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 7e530ca047..a981648444 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// A single follow point positioned between two adjacent s. /// - public class FollowPoint : Container + public class FollowPoint : Container, IAnimationTimeReference { private const float width = 8; @@ -43,7 +43,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections Anchor = Anchor.Centre, Alpha = 0.5f, } - }, confineMode: ConfineMode.NoScaling); + }); } + + public double AnimationStartTime { get; set; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 6c4fbbac17..2c41e6b0e9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -20,10 +20,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections private const int spacing = 32; private const double preempt = 800; + public override bool RemoveWhenNotAlive => false; + /// /// The start time of . /// - public readonly Bindable StartTime = new Bindable(); + public readonly Bindable StartTime = new BindableDouble(); /// /// The which s will exit from. @@ -76,30 +78,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections private void bindEvents(DrawableOsuHitObject drawableObject) { drawableObject.HitObject.PositionBindable.BindValueChanged(_ => scheduleRefresh()); - drawableObject.HitObject.DefaultsApplied += scheduleRefresh; + drawableObject.HitObject.DefaultsApplied += _ => scheduleRefresh(); } - private void scheduleRefresh() => Scheduler.AddOnce(refresh); + private void scheduleRefresh() + { + Scheduler.AddOnce(refresh); + } private void refresh() { - ClearInternal(); - - if (End == null) - return; - OsuHitObject osuStart = Start.HitObject; - OsuHitObject osuEnd = End.HitObject; - - if (osuEnd.NewCombo) - return; - - if (osuStart is Spinner || osuEnd is Spinner) - return; - - Vector2 startPosition = osuStart.EndPosition; - Vector2 endPosition = osuEnd.Position; double startTime = osuStart.GetEndTime(); + + LifetimeStart = startTime; + + OsuHitObject osuEnd = End?.HitObject; + + if (osuEnd == null || osuEnd.NewCombo || osuStart is Spinner || osuEnd is Spinner) + { + // ensure we always set a lifetime for full LifetimeManagementContainer benefits + LifetimeEnd = LifetimeStart; + return; + } + + Vector2 startPosition = osuStart.StackedEndPosition; + Vector2 endPosition = osuEnd.StackedPosition; double endTime = osuEnd.StartTime; Vector2 distanceVector = endPosition - startPosition; @@ -107,6 +111,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI)); double duration = endTime - startTime; + double? firstTransformStartTime = null; + double finalTransformEndTime = startTime; + + int point = 0; + + ClearInternal(); + for (int d = (int)(spacing * 1.5); d < distance - spacing; d += spacing) { float fraction = (float)d / distance; @@ -117,13 +128,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections FollowPoint fp; - AddInternal(fp = new FollowPoint - { - Position = pointStartPosition, - Rotation = rotation, - Alpha = 0, - Scale = new Vector2(1.5f * osuEnd.Scale), - }); + AddInternal(fp = new FollowPoint()); + + fp.Position = pointStartPosition; + fp.Rotation = rotation; + fp.Alpha = 0; + fp.Scale = new Vector2(1.5f * osuEnd.Scale); + + firstTransformStartTime ??= fadeInTime; + + fp.AnimationStartTime = fadeInTime; using (fp.BeginAbsoluteSequence(fadeInTime)) { @@ -131,10 +145,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections fp.ScaleTo(osuEnd.Scale, osuEnd.TimeFadeIn, Easing.Out); fp.MoveTo(pointEndPosition, osuEnd.TimeFadeIn, Easing.Out); fp.Delay(fadeOutTime - fadeInTime).FadeOut(osuEnd.TimeFadeIn); + + finalTransformEndTime = fadeOutTime + osuEnd.TimeFadeIn; } - fp.Expire(true); + point++; } + + int excessPoints = InternalChildren.Count - point; + for (int i = 0; i < excessPoints; i++) + RemoveInternal(InternalChildren[^1]); + + // todo: use Expire() on FollowPoints and take lifetime from them when https://github.com/ppy/osu-framework/issues/3300 is fixed. + LifetimeStart = firstTransformStartTime ?? startTime; + LifetimeEnd = finalTransformEndTime; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index be192080f9..4d73e711bb 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises connections between s. /// - public class FollowPointRenderer : CompositeDrawable + public class FollowPointRenderer : LifetimeManagementContainer { /// /// All the s contained by this . @@ -45,8 +45,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// The index of in . private void addConnection(FollowPointConnection connection) { - AddInternal(connection); - // Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => g1.StartTime.Value.CompareTo(g2.StartTime.Value))); @@ -74,6 +72,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections FollowPointConnection previousConnection = connections[index - 1]; previousConnection.End = connection.Start; } + + AddInternal(connection); } /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index f74f2d7bc5..854fc4c91c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -7,8 +7,11 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; using osuTK; @@ -22,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private readonly IBindable positionBindable = new Bindable(); private readonly IBindable stackHeightBindable = new Bindable(); - private readonly IBindable scaleBindable = new Bindable(); + private readonly IBindable scaleBindable = new BindableFloat(); public OsuAction? HitAction => HitArea.HitAction; @@ -30,6 +33,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public readonly SkinnableDrawable CirclePiece; private readonly Container scaleContainer; + protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; + + private InputManager inputManager; + public DrawableHitCircle(HitCircle h) : base(h) { @@ -57,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return true; }, }, - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.HitCircle), _ => new MainCirclePiece()), + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()), ApproachCircle = new ApproachCircle { Alpha = 0, @@ -84,6 +91,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables AccentColour.BindValueChanged(accent => ApproachCircle.Colour = accent.NewValue, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + public override double LifetimeStart { get => base.LifetimeStart; @@ -118,13 +132,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables var result = HitObject.HitWindows.ResultFor(timeOffset); - if (result == HitResult.None) + if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { Shake(Math.Abs(timeOffset) - HitObject.HitWindows.WindowFor(HitResult.Miss)); return; } - ApplyResult(r => r.Type = result); + ApplyResult(r => + { + var circleResult = (OsuHitCircleJudgementResult)r; + + // Todo: This should also consider misses, but they're a little more interesting to handle, since we don't necessarily know the position at the time of a miss. + if (result != HitResult.Miss) + { + var localMousePosition = ToLocalSpace(inputManager.CurrentState.Mouse.Position); + circleResult.CursorPositionAtHit = HitObject.StackedPosition + (localMousePosition - DrawSize / 2); + } + + circleResult.Type = result; + }); } protected override void UpdateInitialTransforms() @@ -170,7 +196,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public Drawable ProxiedLayer => ApproachCircle; - public class HitReceptor : Drawable, IKeyBindingHandler + protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement); + + public class HitReceptor : CompositeDrawable, IKeyBindingHandler { // IsHovered is used public override bool HandlePositionalInput => true; @@ -185,6 +213,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Anchor = Anchor.Centre; Origin = Anchor.Centre; + + CornerRadius = OsuHitObject.OBJECT_RADIUS; + CornerExponent = 2; } public bool OnPressed(OsuAction action) @@ -205,7 +236,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return false; } - public bool OnReleased(OsuAction action) => false; + public void OnReleased(OsuAction action) + { + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index a677cb6a72..8308c0c576 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.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 osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -16,6 +19,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // Must be set to update IsHovered as it's used in relax mdo to detect osu hit objects. public override bool HandlePositionalInput => true; + protected override float SamplePlaybackPosition => HitObject.X / OsuPlayfield.BASE_SIZE.X; + + /// + /// Whether this can be hit. + /// If non-null, judgements will be ignored (resulting in a shake) whilst the function returns false. + /// + public Func CheckHittable; + protected DrawableOsuHitObject(OsuHitObject hitObject) : base(hitObject) { @@ -54,6 +65,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } + /// + /// Causes this to get missed, disregarding all conditions in implementations of . + /// + public void MissForcefully() => ApplyResult(r => r.Type = HitResult.Miss); + protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(HitObject, judgement); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 022e9ea12b..1493ddfcf3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -24,10 +24,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } + public DrawableOsuJudgement() + { + } + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - if (config.Get(OsuSetting.HitLighting) && Result.Type != HitResult.Miss) + if (config.Get(OsuSetting.HitLighting)) { AddInternal(lighting = new SkinnableSprite("lighting") { @@ -36,11 +40,34 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Blending = BlendingParameters.Additive, Depth = float.MaxValue }); + } + } + + public override void Apply(JudgementResult result, DrawableHitObject judgedObject) + { + base.Apply(result, judgedObject); + + if (judgedObject?.HitObject is OsuHitObject osuObject) + { + Position = osuObject.StackedPosition; + Scale = new Vector2(osuObject.Scale); + } + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + lightingColour?.UnbindAll(); + + if (lighting != null) + { + lighting.ResetAnimation(); if (JudgedObject != null) { lightingColour = JudgedObject.AccentColour.GetBoundCopy(); - lightingColour.BindValueChanged(colour => lighting.Colour = colour.NewValue, true); + lightingColour.BindValueChanged(colour => lighting.Colour = Result.Type == HitResult.Miss ? Color4.Transparent : colour.NewValue, true); } else { @@ -55,13 +82,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { if (lighting != null) { - JudgementBody.Delay(FadeInDuration).FadeOut(400); + JudgementBody.FadeIn().Delay(FadeInDuration).FadeOut(400); lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); } - JudgementText?.TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + JudgementText?.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); base.ApplyHitAnimations(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index cd3c572ba0..72502c02cd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -6,7 +6,6 @@ using osuTK; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -26,18 +25,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public readonly SliderBall Ball; public readonly SkinnableDrawable Body; + public override bool DisplayResult => false; + private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody; private readonly Container headContainer; private readonly Container tailContainer; private readonly Container tickContainer; - private readonly Container repeatContainer; + private readonly Container repeatContainer; private readonly Slider slider; private readonly IBindable positionBindable = new Bindable(); private readonly IBindable stackHeightBindable = new Bindable(); - private readonly IBindable scaleBindable = new Bindable(); + private readonly IBindable scaleBindable = new BindableFloat(); public DrawableSlider(Slider s) : base(s) @@ -50,7 +51,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), tickContainer = new Container { RelativeSizeAxes = Axes.Both }, - repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, Ball = new SliderBall(s, this) { GetInitialHitAction = () => HeadCircle.HitAction, @@ -100,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables tickContainer.Add(tick); break; - case DrawableRepeatPoint repeat: + case DrawableSliderRepeat repeat: repeatContainer.Add(repeat); break; } @@ -123,14 +124,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables case SliderTailCircle tail: return new DrawableSliderTail(slider, tail); - case HitCircle head: - return new DrawableSliderHead(slider, head) { OnShake = Shake }; + case SliderHeadCircle head: + return new DrawableSliderHead(slider, head) + { + OnShake = Shake, + CheckHittable = (d, t) => CheckHittable?.Invoke(d, t) ?? true + }; case SliderTick tick: return new DrawableSliderTick(tick) { Position = tick.Position - slider.Position }; - case RepeatPoint repeat: - return new DrawableRepeatPoint(repeat, this) { Position = repeat.Position - slider.Position }; + case SliderRepeat repeat: + return new DrawableSliderRepeat(repeat, this) { Position = repeat.Position - slider.Position }; } return base.CreateNestedHitObject(hitObject); @@ -185,7 +190,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.ApplySkin(skin, allowFallback); bool allowBallTint = skin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; - Ball.Colour = allowBallTint ? AccentColour.Value : Color4.White; + Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; } protected override void CheckForResult(bool userTriggered, double timeOffset) @@ -193,22 +198,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (userTriggered || Time.Current < slider.EndTime) return; - ApplyResult(r => - { - var judgementsCount = NestedHitObjects.Count; - var judgementsHit = NestedHitObjects.Count(h => h.IsHit); + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } - var hitFraction = (double)judgementsHit / judgementsCount; - - if (hitFraction == 1 && HeadCircle.Result.Type == HitResult.Great) - r.Type = HitResult.Great; - else if (hitFraction >= 0.5 && HeadCircle.Result.Type >= HitResult.Good) - r.Type = HitResult.Good; - else if (hitFraction > 0) - r.Type = HitResult.Meh; - else - r.Type = HitResult.Miss; - }); + public override void PlaySamples() + { + // rather than doing it this way, we should probably attach the sample to the tail circle. + // this can only be done after we stop using LegacyLastTick. + if (TailCircle.Result.Type != HitResult.Miss) + base.PlaySamples(); } protected override void UpdateStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index c5609b01e0..04f563eeec 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -14,9 +14,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private readonly IBindable positionBindable = new Bindable(); private readonly IBindable pathVersion = new Bindable(); + protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; + private readonly Slider slider; - public DrawableSliderHead(Slider slider, HitCircle h) + public DrawableSliderHead(Slider slider, SliderHeadCircle h) : base(h) { this.slider = slider; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs similarity index 71% rename from osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs rename to osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index b81d94a673..720ffcd51c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -6,49 +6,37 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; using osuTK; -using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableRepeatPoint : DrawableOsuHitObject, ITrackSnaking + public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking { - private readonly RepeatPoint repeatPoint; + private readonly SliderRepeat sliderRepeat; private readonly DrawableSlider drawableSlider; private double animDuration; - private readonly SkinnableDrawable scaleContainer; + private readonly Drawable scaleContainer; - public DrawableRepeatPoint(RepeatPoint repeatPoint, DrawableSlider drawableSlider) - : base(repeatPoint) + public DrawableSliderRepeat(SliderRepeat sliderRepeat, DrawableSlider drawableSlider) + : base(sliderRepeat) { - this.repeatPoint = repeatPoint; + this.sliderRepeat = sliderRepeat; this.drawableSlider = drawableSlider; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - Blending = BlendingParameters.Additive; Origin = Anchor.Centre; - InternalChild = scaleContainer = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon - { - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.ChevronRight, - Size = new Vector2(0.35f) - }, confineMode: ConfineMode.NoScaling) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; + InternalChild = scaleContainer = new ReverseArrowPiece(); } - private readonly IBindable scaleBindable = new Bindable(); + private readonly IBindable scaleBindable = new BindableFloat(); [BackgroundDependencyLoader] private void load() @@ -59,17 +47,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (repeatPoint.StartTime <= Time.Current) + if (sliderRepeat.StartTime <= Time.Current) ApplyResult(r => r.Type = drawableSlider.Tracking.Value ? HitResult.Great : HitResult.Miss); } protected override void UpdateInitialTransforms() { - animDuration = Math.Min(150, repeatPoint.SpanDuration / 2); + animDuration = Math.Min(300, sliderRepeat.SpanDuration); this.Animate( d => d.FadeIn(animDuration), - d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 4, Easing.OutElasticHalf) + d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 2, Easing.OutElasticHalf) ); } @@ -88,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables break; case ArmedState.Hit: - this.FadeOut(animDuration, Easing.OutQuint) + this.FadeOut(animDuration, Easing.Out) .ScaleTo(Scale * 1.5f, animDuration, Easing.Out); break; } @@ -98,7 +86,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public void UpdateSnakingPosition(Vector2 start, Vector2 end) { - bool isRepeatAtEnd = repeatPoint.RepeatIndex % 2 == 0; + // When the repeat is hit, the arrow should fade out on spot rather than following the slider + if (IsHit) return; + + bool isRepeatAtEnd = sliderRepeat.RepeatIndex % 2 == 0; List curve = ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; Position = isRepeatAtEnd ? end : start; @@ -121,7 +112,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables break; } - float aimRotation = MathHelper.RadiansToDegrees(MathF.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X)); + float aimRotation = MathUtils.RadiansToDegrees(MathF.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X)); while (Math.Abs(aimRotation - Rotation) > 180) aimRotation += aimRotation < Rotation ? 360 : -360; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 21a3a0d236..29a4929c1b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered && timeOffset >= 0) - ApplyResult(r => r.Type = Tracking ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : HitResult.Miss); } private void updatePosition() => Position = HitObject.Position - slider.Position; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index 9d4d9958a1..66eb60aa28 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }; } - private readonly IBindable scaleBindable = new Bindable(); + private readonly IBindable scaleBindable = new BindableFloat(); [BackgroundDependencyLoader] private void load() @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { if (timeOffset >= 0) - ApplyResult(r => r.Type = Tracking ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : HitResult.Miss); } protected override void UpdateInitialTransforms() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index edcaa947ac..b82b44f35b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -15,6 +15,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Objects; +using osu.Framework.Utils; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Ranking; @@ -40,8 +41,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private readonly SpriteIcon symbol; - private readonly Color4 baseColour = OsuColour.FromHex(@"002c3c"); - private readonly Color4 fillColour = OsuColour.FromHex(@"005b7c"); + private readonly Color4 baseColour = Color4Extensions.FromHex(@"002c3c"); + private readonly Color4 fillColour = Color4Extensions.FromHex(@"005b7c"); private readonly IBindable positionBindable = new Bindable(); @@ -97,7 +98,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { Background = new SpinnerBackground { - Alpha = 0.6f, + Disc = + { + Alpha = 0f, + }, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, @@ -164,10 +168,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void load(OsuColour colours) { normalColour = baseColour; + completeColour = colours.YellowLight; Background.AccentColour = normalColour; - - completeColour = colours.YellowLight.Opacity(0.75f); + Ticks.AccentColour = normalColour; Disc.AccentColour = fillColour; circle.Colour = colours.BlueDark; @@ -177,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables positionBindable.BindTo(HitObject.PositionBindable); } - public float Progress => Math.Clamp(Disc.RotationAbsolute / 360 / Spinner.SpinsRequired, 0, 1); + public float Progress => Math.Clamp(Disc.CumulativeRotation / 360 / Spinner.SpinsRequired, 0, 1); protected override void CheckForResult(bool userTriggered, double timeOffset) { @@ -186,16 +190,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (Progress >= 1 && !Disc.Complete) { Disc.Complete = true; - - const float duration = 200; - - Disc.FadeAccent(completeColour, duration); - - Background.FadeAccent(completeColour, duration); - Background.FadeOut(duration); - - circle.FadeColour(completeColour, duration); - glow.FadeColour(completeColour, duration); + transformFillColour(completeColour, 200); } if (userTriggered || Time.Current < Spinner.EndTime) @@ -216,61 +211,88 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void Update() { - Disc.Tracking = OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false; - if (!SpmCounter.IsPresent && Disc.Tracking) - SpmCounter.FadeIn(HitObject.TimeFadeIn); - base.Update(); + if (HandleUserInput) + Disc.Tracking = OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); + if (!SpmCounter.IsPresent && Disc.Tracking) + SpmCounter.FadeIn(HitObject.TimeFadeIn); + circle.Rotation = Disc.Rotation; Ticks.Rotation = Disc.Rotation; - SpmCounter.SetRotation(Disc.RotationAbsolute); - bonusComponent.UpdateRotation(Disc.RotationAbsolute); + + SpmCounter.SetRotation(Disc.CumulativeRotation); + bonusComponent.UpdateRotation(Disc.CumulativeRotation); float relativeCircleScale = Spinner.Scale * circle.DrawHeight / mainContainer.DrawHeight; - Disc.ScaleTo(relativeCircleScale + (1 - relativeCircleScale) * Progress, 200, Easing.OutQuint); + float targetScale = relativeCircleScale + (1 - relativeCircleScale) * Progress; + Disc.Scale = new Vector2((float)Interpolation.Lerp(Disc.Scale.X, targetScale, Math.Clamp(Math.Abs(Time.Elapsed) / 100, 0, 1))); - symbol.RotateTo(Disc.Rotation / 2, 500, Easing.OutQuint); + symbol.Rotation = (float)Interpolation.Lerp(symbol.Rotation, Disc.Rotation / 2, Math.Clamp(Math.Abs(Time.Elapsed) / 40, 0, 1)); } protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); - circleContainer.ScaleTo(Spinner.Scale * 0.3f); - circleContainer.ScaleTo(Spinner.Scale, HitObject.TimePreempt / 1.4f, Easing.OutQuint); + circleContainer.ScaleTo(0); + mainContainer.ScaleTo(0); - Disc.RotateTo(-720); - symbol.RotateTo(-720); + using (BeginDelayedSequence(HitObject.TimePreempt / 2, true)) + { + float phaseOneScale = Spinner.Scale * 0.7f; - mainContainer - .ScaleTo(0) - .ScaleTo(Spinner.Scale * circle.DrawHeight / DrawHeight * 1.4f, HitObject.TimePreempt - 150, Easing.OutQuint) - .Then() - .ScaleTo(1, 500, Easing.OutQuint); + circleContainer.ScaleTo(phaseOneScale, HitObject.TimePreempt / 4, Easing.OutQuint); + + mainContainer + .ScaleTo(phaseOneScale * circle.DrawHeight / DrawHeight * 1.6f, HitObject.TimePreempt / 4, Easing.OutQuint) + .RotateTo((float)(25 * Spinner.Duration / 2000), HitObject.TimePreempt + Spinner.Duration); + + using (BeginDelayedSequence(HitObject.TimePreempt / 2, true)) + { + circleContainer.ScaleTo(Spinner.Scale, 400, Easing.OutQuint); + mainContainer.ScaleTo(1, 400, Easing.OutQuint); + } + } } protected override void UpdateStateTransforms(ArmedState state) { base.UpdateStateTransforms(state); - var sequence = this.Delay(Spinner.Duration).FadeOut(160); - - switch (state) + using (BeginDelayedSequence(Spinner.Duration, true)) { - case ArmedState.Hit: - sequence.ScaleTo(Scale * 1.2f, 320, Easing.Out); - break; + this.FadeOut(160); - case ArmedState.Miss: - sequence.ScaleTo(Scale * 0.8f, 320, Easing.In); - break; + switch (state) + { + case ArmedState.Hit: + transformFillColour(completeColour, 0); + this.ScaleTo(Scale * 1.2f, 320, Easing.Out); + mainContainer.RotateTo(mainContainer.Rotation + 180, 320); + break; + + case ArmedState.Miss: + this.ScaleTo(Scale * 0.8f, 320, Easing.In); + break; + } } } + + private void transformFillColour(Colour4 colour, double duration) + { + Disc.FadeAccent(colour, duration); + + Background.FadeAccent(colour.Darken(1), duration); + Ticks.FadeAccent(colour, duration); + + circle.FadeColour(colour, duration); + glow.FadeColour(colour, duration); + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs index e364c96426..cb3787a493 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs @@ -81,7 +81,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces using (BeginDelayedSequence(flash_in, true)) { - //after the flash, we can hide some elements that were behind it + // after the flash, we can hide some elements that were behind it ring.FadeOut(); circle.FadeOut(); number.FadeOut(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs new file mode 100644 index 0000000000..ae43006e76 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osuTK; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; +using osu.Framework.Allocation; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class ReverseArrowPiece : BeatSyncedContainer + { + [Resolved] + private DrawableHitObject drawableRepeat { get; set; } + + public ReverseArrowPiece() + { + Divisor = 2; + MinimumBeatLength = 200; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(0.35f) + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!drawableRepeat.IsHit) + Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs index ef7b077480..395c76a233 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs @@ -4,6 +4,7 @@ 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; @@ -15,30 +16,37 @@ using osu.Game.Rulesets.Osu.Skinning; using osuTK.Graphics; using osu.Game.Skinning; using osuTK; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition + public class SliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition, IHasAccentColour { public Func GetInitialHitAction; + public Color4 AccentColour + { + get => ball.Colour; + set => ball.Colour = value; + } + private readonly Slider slider; - public readonly Drawable FollowCircle; + private readonly Drawable followCircle; private readonly DrawableSlider drawableSlider; + private readonly CircularContainer ball; public SliderBall(Slider slider, DrawableSlider drawableSlider = null) { this.drawableSlider = drawableSlider; this.slider = slider; - Blending = BlendingParameters.Additive; Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); Children = new[] { - FollowCircle = new FollowCircleContainer + followCircle = new FollowCircleContainer { Origin = Anchor.Centre, Anchor = Anchor.Centre, @@ -46,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Alpha = 0, Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()), }, - new CircularContainer + ball = new CircularContainer { Masking = true, RelativeSizeAxes = Axes.Both, @@ -95,8 +103,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces tracking = value; - FollowCircle.ScaleTo(tracking ? 2f : 1, 300, Easing.OutQuint); - FollowCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); + followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); } } @@ -149,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces // in valid time range Time.Current >= slider.StartTime && Time.Current < slider.EndTime && // in valid position range - lastScreenSpaceMousePosition.HasValue && FollowCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && + lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); } @@ -214,9 +222,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces 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; @@ -228,17 +240,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Scale = new Vector2(radius / OsuHitObject.OBJECT_RADIUS), Anchor = Anchor.Centre, Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, BorderThickness = 10, BorderColour = Color4.White, Alpha = 1, - Child = new Box + Child = box = new Box { + Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, Colour = Color4.White, - Alpha = 0.4f, + AlwaysPresent = true, + Alpha = 0 } }; + + slider.Tracking.BindValueChanged(trackingChanged, true); } + + private void trackingChanged(ValueChangedEvent tracking) => + box.FadeTo(tracking.NewValue ? 0.6f : 0.05f, 200, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs index 8a8668d6af..e24fa865ad 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs @@ -125,7 +125,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private void setRange(double p0, double p1) { if (p0 > p1) - MathHelper.Swap(ref p0, ref p1); + (p0, p1) = (p1, p0); if (SnakedStart == p0 && SnakedEnd == p1) return; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs index 77228e28af..944354abca 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.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 osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class SpinnerBackground : CircularContainer, IHasAccentColour { - protected Box Disc; + public readonly Box Disc; public Color4 AccentColour { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs index c45e98cc76..35819cd05e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs @@ -8,6 +8,7 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; +using osu.Framework.Utils; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { @@ -72,6 +73,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } } + /// + /// The total rotation performed on the spinner disc, disregarding the spin direction. + /// + /// + /// This value is always non-negative and is monotonically increasing with time + /// (i.e. will only increase if time is passing forward, but can decrease during rewind). + /// + /// + /// If the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise, + /// this property will return the value of 720 (as opposed to 0 for ). + /// + public float CumulativeRotation; + + /// + /// Whether currently in the correct time range to allow spinning. + /// + private bool isSpinnableTime => spinner.StartTime <= Time.Current && spinner.EndTime > Time.Current; + protected override bool OnMouseMove(MouseMoveEvent e) { mousePosition = Parent.ToLocalSpace(e.ScreenSpaceMousePosition); @@ -82,37 +101,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private float lastAngle; private float currentRotation; - public float RotationAbsolute; private int completeTick; - private bool updateCompleteTick() => completeTick != (completeTick = (int)(RotationAbsolute / 360)); + private bool updateCompleteTick() => completeTick != (completeTick = (int)(CumulativeRotation / 360)); private bool rotationTransferred; protected override void Update() { base.Update(); + var thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)); - var thisAngle = -(float)MathHelper.RadiansToDegrees(Math.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)); + var delta = thisAngle - lastAngle; - bool validAndTracking = tracking && spinner.StartTime <= Time.Current && spinner.EndTime > Time.Current; - - if (validAndTracking) - { - if (!rotationTransferred) - { - currentRotation = Rotation * 2; - rotationTransferred = true; - } - - if (thisAngle - lastAngle > 180) - lastAngle += 360; - else if (lastAngle - thisAngle > 180) - lastAngle -= 360; - - currentRotation += thisAngle - lastAngle; - RotationAbsolute += Math.Abs(thisAngle - lastAngle) * Math.Sign(Clock.ElapsedFrameTime); - } + if (tracking) + Rotate(delta); lastAngle = thisAngle; @@ -125,7 +128,40 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces .FadeTo(tracking_alpha, 250, Easing.OutQuint); } - this.RotateTo(currentRotation / 2, validAndTracking ? 500 : 1500, Easing.OutExpo); + Rotation = (float)Interpolation.Lerp(Rotation, currentRotation / 2, Math.Clamp(Math.Abs(Time.Elapsed) / 40, 0, 1)); + } + + /// + /// Rotate the disc by the provided angle (in addition to any existing rotation). + /// + /// + /// Will be a no-op if not a valid time to spin. + /// + /// The delta angle. + public void Rotate(float angle) + { + if (!isSpinnableTime) + return; + + if (!rotationTransferred) + { + currentRotation = Rotation * 2; + rotationTransferred = true; + } + + if (angle > 180) + { + lastAngle += 360; + angle -= 360; + } + else if (-angle > 180) + { + lastAngle -= 360; + angle += 360; + } + + currentRotation += angle; + CumulativeRotation += Math.Abs(angle) * Math.Sign(Clock.ElapsedFrameTime); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs index 97a7b98c5b..80ab03c45c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs index 676cefb236..ba7e8eae6f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,10 +10,11 @@ using osu.Framework.Graphics.Effects; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerTicks : Container + public class SpinnerTicks : Container, IHasAccentColour { public SpinnerTicks() { @@ -20,28 +22,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; RelativeSizeAxes = Axes.Both; - const float count = 18; + const float count = 8; for (float i = 0; i < count; i++) { Add(new Container { - Colour = Color4.Black, Alpha = 0.4f, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 10, - Colour = Color4.Gray.Opacity(0.2f), - }, + Blending = BlendingParameters.Additive, RelativePositionAxes = Axes.Both, Masking = true, CornerRadius = 5, Size = new Vector2(60, 10), Origin = Anchor.Centre, Position = new Vector2( - 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.86f, - 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.86f + 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.83f, + 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.83f ), Rotation = -i / count * 360 + 90, Children = new[] @@ -54,5 +50,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }); } } + + public Color4 AccentColour + { + get => Colour; + set + { + Colour = value; + + foreach (var c in Children.OfType()) + { + c.EdgeEffect = + new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 20, + Colour = value.Opacity(0.8f), + }; + } + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 0ba712a83f..15af141c99 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Objects public double Radius => OBJECT_RADIUS * Scale; - public readonly Bindable ScaleBindable = new Bindable(1); + public readonly Bindable ScaleBindable = new BindableFloat(1); public float Scale { diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index fe65ab78d1..705e88040f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -6,6 +6,7 @@ using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using osu.Game.Rulesets.Objects; using System.Linq; +using System.Threading; using osu.Framework.Caching; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -16,10 +17,15 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class Slider : OsuHitObject, IHasCurve + public class Slider : OsuHitObject, IHasPathWithRepeats { public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; - public double Duration => EndTime - StartTime; + + public double Duration + { + get => EndTime - StartTime; + set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } private readonly Cached endPositionCache = new Cached(); @@ -81,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Objects set { repeatCount = value; - endPositionCache.Invalidate(); + updateNestedPositions(); } } @@ -111,8 +117,7 @@ namespace osu.Game.Rulesets.Osu.Objects public Slider() { - SamplesBindable.ItemsAdded += _ => updateNestedSamples(); - SamplesBindable.ItemsRemoved += _ => updateNestedSamples(); + SamplesBindable.CollectionChanged += (_, __) => updateNestedSamples(); Path.Version.ValueChanged += _ => updateNestedPositions(); } @@ -129,12 +134,12 @@ namespace osu.Game.Rulesets.Osu.Objects TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); foreach (var e in - SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset)) + SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) { switch (e.Type) { @@ -151,7 +156,7 @@ namespace osu.Game.Rulesets.Osu.Objects break; case SliderEventType.Head: - AddNested(HeadCircle = new SliderCircle + AddNested(HeadCircle = new SliderHeadCircle { StartTime = e.Time, Position = Position, @@ -173,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Objects break; case SliderEventType.Repeat: - AddNested(new RepeatPoint + AddNested(new SliderRepeat { RepeatIndex = e.SpanIndex, SpanDuration = SpanDuration, @@ -219,7 +224,7 @@ namespace osu.Game.Rulesets.Osu.Objects foreach (var tick in NestedHitObjects.OfType()) tick.Samples = sampleList; - foreach (var repeat in NestedHitObjects.OfType()) + foreach (var repeat in NestedHitObjects.OfType()) repeat.Samples = getNodeSamples(repeat.RepeatIndex + 1); if (HeadCircle != null) @@ -229,7 +234,7 @@ namespace osu.Game.Rulesets.Osu.Objects private IList getNodeSamples(int nodeIndex) => nodeIndex < NodeSamples.Count ? NodeSamples[nodeIndex] : Samples; - public override Judgement CreateJudgement() => new OsuJudgement(); + public override Judgement CreateJudgement() => new OsuIgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponents.cs b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs similarity index 65% rename from osu.Game.Rulesets.Mania/ManiaSkinComponents.cs rename to osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs index 6d85816e5a..f6d46aeef5 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.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. -namespace osu.Game.Rulesets.Mania +namespace osu.Game.Rulesets.Osu.Objects { - public enum ManiaSkinComponents + public class SliderHeadCircle : HitCircle { } } diff --git a/osu.Game.Rulesets.Osu/Objects/RepeatPoint.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs similarity index 80% rename from osu.Game.Rulesets.Osu/Objects/RepeatPoint.cs rename to osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs index a277517f9f..ac6c6905e4 100644 --- a/osu.Game.Rulesets.Osu/Objects/RepeatPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class RepeatPoint : OsuHitObject + public class SliderRepeat : OsuHitObject { public int RepeatIndex { get; set; } public double SpanDuration { get; set; } @@ -28,8 +28,13 @@ namespace osu.Game.Rulesets.Osu.Objects TimePreempt = Math.Min(SpanDuration * 2, TimePreempt); } - public override Judgement CreateJudgement() => new OsuJudgement(); - protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override Judgement CreateJudgement() => new SliderRepeatJudgement(); + + public class SliderRepeatJudgement : OsuJudgement + { + protected override int NumericResultFor(HitResult result) => result == MaxResult ? 30 : 0; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index c17d2275b8..1e54b576f1 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -23,8 +23,15 @@ namespace osu.Game.Rulesets.Osu.Objects pathVersion.BindValueChanged(_ => Position = slider.EndPosition); } - public override Judgement CreateJudgement() => new OsuSliderTailJudgement(); - protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override Judgement CreateJudgement() => new SliderTailJudgement(); + + public class SliderTailJudgement : OsuJudgement + { + protected override int NumericResultFor(HitResult result) => 0; + + public override bool AffectsCombo => false; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index a49f4cef8b..22f3f559db 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -30,8 +30,13 @@ namespace osu.Game.Rulesets.Osu.Objects TimePreempt = (StartTime - SpanStartTime) / 2 + offset; } - public override Judgement CreateJudgement() => new OsuJudgement(); - protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override Judgement CreateJudgement() => new SliderTickJudgement(); + + public class SliderTickJudgement : OsuJudgement + { + protected override int NumericResultFor(HitResult result) => result == MaxResult ? 10 : 0; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 8d308a3acb..4c21d9cfde 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -13,10 +13,15 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects { - public class Spinner : OsuHitObject, IHasEndTime + public class Spinner : OsuHitObject, IHasDuration { - public double EndTime { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } + + public double Duration { get; set; } /// /// Number of spins required to finish the spinner without miss. diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index cdea7276f3..c8fe4f41ca 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu /// public bool AllowUserCursorMovement { get; set; } = true; - protected override RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new OsuKeyBindingContainer(ruleset, variant, unique); public OsuInputManager(RulesetInfo ruleset) diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 36346eb78a..eaa5d8937a 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -29,9 +29,15 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Skinning; using System; +using System.Linq; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Osu { + [ExcludeFromDynamicCompile] public class OsuRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); @@ -52,7 +58,7 @@ namespace osu.Game.Rulesets.Osu new KeyBinding(InputKey.MouseRight, OsuAction.RightButton), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new OsuModNightcore(); @@ -113,7 +119,6 @@ namespace osu.Game.Rulesets.Osu new OsuModEasy(), new OsuModNoFail(), new MultiMod(new OsuModHalfTime(), new OsuModDaycore()), - new OsuModSpunOut(), }; case ModType.DifficultyIncrease: @@ -139,6 +144,7 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new OsuModAutoplay(), new OsuModCinema()), new OsuModRelax(), new OsuModAutopilot(), + new OsuModSpunOut(), }; case ModType.Fun: @@ -175,14 +181,42 @@ namespace osu.Game.Rulesets.Osu public override string ShortName => SHORT_NAME; + public override string PlayingVerb => "Clicking circles"; + public override RulesetSettingsSubsection CreateSettings() => new OsuSettingsSubsection(this); - public override ISkin CreateLegacySkinProvider(ISkinSource source) => new OsuLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new OsuLegacySkinTransformer(source); public int LegacyID => 0; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList()) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Accuracy Heatmap", new AccuracyHeatmap(score, playableBeatmap) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + } + }; } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index 4ea4220faf..b2cdc8ccbf 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu ApproachCircle, ReverseArrow, HitCircleText, + SliderHeadHitCircle, SliderFollowCircle, SliderBall, SliderBody, diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 2686ba4fd2..4cb2cd6539 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osuTK; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using System; diff --git a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs index c6ac1dd346..b42e9ac187 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Input.StateChanges; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Replays; using osu.Game.Rulesets.Replays; using osuTK; diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index e6c6db5e61..3db81d70da 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -26,11 +26,23 @@ namespace osu.Game.Rulesets.Osu.Replays Actions.AddRange(actions); } - public void ConvertFrom(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); } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(OsuAction.LeftButton)) + state |= ReplayButtonState.Left1; + if (Actions.Contains(OsuAction.RightButton)) + state |= ReplayButtonState.Right1; + + 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 a6491bb3f3..6f2998006f 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Scoring new DifficultyRange(HitResult.Great, 80, 50, 20), new DifficultyRange(HitResult.Good, 140, 100, 60), new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, 200, 200, 200), + new DifficultyRange(HitResult.Miss, 400, 400, 400), }; 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 1de7d488f3..86ec76e373 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -4,13 +4,27 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { - internal class OsuScoreProcessor : ScoreProcessor + public class OsuScoreProcessor : ScoreProcessor { - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new OsuJudgementResult(hitObject, judgement); + protected override HitEvent CreateHitEvent(JudgementResult result) + => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); + + protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) + { + switch (hitObject) + { + case HitCircle _: + return new OsuHitCircleJudgementResult(hitObject, judgement); + + default: + return new OsuJudgementResult(hitObject, judgement); + } + } public override HitWindows CreateHitWindows() => new OsuHitWindows(); } diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs index 93ae0371df..e7486ef9b0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; @@ -18,8 +19,12 @@ namespace osu.Game.Rulesets.Osu.Skinning { public class LegacyMainCirclePiece : CompositeDrawable { - public LegacyMainCirclePiece() + private readonly string priorityLookup; + + public LegacyMainCirclePiece(string priorityLookup = null) { + this.priorityLookup = priorityLookup; + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); } @@ -39,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Skinning { hitCircleSprite = new Sprite { - Texture = skin.GetTexture("hitcircle"), + Texture = getTextureWithFallback(string.Empty), Colour = drawableObject.AccentColour.Value, Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -51,12 +56,17 @@ namespace osu.Game.Rulesets.Osu.Skinning }, confineMode: ConfineMode.NoScaling), new Sprite { - Texture = skin.GetTexture("hitcircleoverlay"), + Texture = getTextureWithFallback("overlay"), Anchor = Anchor.Centre, Origin = Anchor.Centre, } }; + bool overlayAboveNumber = skin.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber)?.Value ?? true; + + if (!overlayAboveNumber) + ChangeInternalChildDepth(hitCircleText, -float.MaxValue); + state.BindTo(drawableObject.State); state.BindValueChanged(updateState, true); @@ -65,6 +75,16 @@ namespace osu.Game.Rulesets.Osu.Skinning indexInCurrentCombo.BindTo(osuObject.IndexInCurrentComboBindable); indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); + + Texture getTextureWithFallback(string name) + { + Texture tex = null; + + if (!string.IsNullOrEmpty(priorityLookup)) + tex = skin.GetTexture($"{priorityLookup}{name}"); + + return tex ?? skin.GetTexture($"hitcircle{name}"); + } } private void updateState(ValueChangedEvent state) diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs index 81c02199d0..b4ed75d97c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs @@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Osu.Skinning public LegacySliderBall(Drawable animationContent) { this.animationContent = animationContent; + + AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs index d41135ca69..21df49d80b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs @@ -3,7 +3,7 @@ using System; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osuTK.Graphics; diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs index 266b619334..3e5758ca01 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs @@ -2,20 +2,15 @@ // 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; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Skinning { - public class OsuLegacySkinTransformer : ISkin + public class OsuLegacySkinTransformer : LegacySkinTransformer { - private readonly ISkin source; - private Lazy hasHitCircle; /// @@ -26,19 +21,18 @@ namespace osu.Game.Rulesets.Osu.Skinning public const float LEGACY_CIRCLE_RADIUS = 64 - 5; public OsuLegacySkinTransformer(ISkinSource source) + : base(source) { - this.source = source; - - source.SourceChanged += sourceChanged; + Source.SourceChanged += sourceChanged; sourceChanged(); } private void sourceChanged() { - hasHitCircle = new Lazy(() => source.GetTexture("hitcircle") != null); + hasHitCircle = new Lazy(() => Source.GetTexture("hitcircle") != null); } - public Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable GetDrawableComponent(ISkinComponent component) { if (!(component is OsuSkinComponent osuComponent)) return null; @@ -46,30 +40,23 @@ namespace osu.Game.Rulesets.Osu.Skinning switch (osuComponent.Component) { case OsuSkinComponents.FollowPoint: - return this.GetAnimation(component.LookupName, true, false); + return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false); case OsuSkinComponents.SliderFollowCircle: - var followCircle = this.GetAnimation("sliderfollowcircle", true, true); + 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; case OsuSkinComponents.SliderBall: - var sliderBallContent = this.GetAnimation("sliderb", true, true, ""); + var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: ""); + + // todo: slider ball has a custom frame delay based on velocity + // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); if (sliderBallContent != null) - { - var size = sliderBallContent.Size; - - sliderBallContent.RelativeSizeAxes = Axes.Both; - sliderBallContent.Size = Vector2.One; - - return new LegacySliderBall(sliderBallContent) - { - Size = size - }; - } + return new LegacySliderBall(sliderBallContent); return null; @@ -79,6 +66,12 @@ namespace osu.Game.Rulesets.Osu.Skinning return null; + case OsuSkinComponents.SliderHeadHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderstartcircle"); + + return null; + case OsuSkinComponents.HitCircle: if (hasHitCircle.Value) return new LegacyMainCirclePiece(); @@ -86,13 +79,13 @@ namespace osu.Game.Rulesets.Osu.Skinning return null; case OsuSkinComponents.Cursor: - if (source.GetTexture("cursor") != null) + if (Source.GetTexture("cursor") != null) return new LegacyCursor(); return null; case OsuSkinComponents.CursorTrail: - if (source.GetTexture("cursortrail") != null) + if (Source.GetTexture("cursortrail") != null) return new LegacyCursorTrail(); return null; @@ -103,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Skinning return !hasFont(font) ? null - : new LegacySpriteText(source, font) + : new LegacySpriteText(Source, font) { // stable applies a blanket 0.8x scale to hitcircle fonts Scale = new Vector2(0.8f), @@ -114,16 +107,12 @@ namespace osu.Game.Rulesets.Osu.Skinning return null; } - public Texture GetTexture(string componentName) => source.GetTexture(componentName); - - public SampleChannel GetSample(ISampleInfo sample) => source.GetSample(sample); - - public IBindable GetConfig(TLookup lookup) + public override IBindable GetConfig(TLookup lookup) { switch (lookup) { case OsuSkinColour colour: - return source.GetConfig(new SkinCustomColourLookup(colour)); + return Source.GetConfig(new SkinCustomColourLookup(colour)); case OsuSkinConfiguration osuLookup: switch (osuLookup) @@ -133,14 +122,20 @@ namespace osu.Game.Rulesets.Osu.Skinning return SkinUtils.As(new BindableFloat(LEGACY_CIRCLE_RADIUS)); break; + + case OsuSkinConfiguration.HitCircleOverlayAboveNumber: + // See https://osu.ppy.sh/help/wiki/Skinning/skin.ini#%5Bgeneral%5D + // HitCircleOverlayAboveNumer (with typo) should still be supported for now. + return Source.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber) ?? + Source.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumer); } break; } - return source.GetConfig(lookup); + return Source.GetConfig(lookup); } - private bool hasFont(string fontName) => source.GetTexture($"{fontName}-0") != null; + private bool hasFont(string fontName) => Source.GetTexture($"{fontName}-0") != null; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 5d99960f10..154160fdb5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning SliderPathRadius, AllowSliderBallTint, CursorExpand, - CursorRotate + CursorRotate, + HitCircleOverlayAboveNumber, + HitCircleOverlayAboveNumer // Some old skins will have this typo } } diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs new file mode 100644 index 0000000000..20adbc1c02 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -0,0 +1,297 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Statistics +{ + public class AccuracyHeatmap : CompositeDrawable + { + /// + /// Size of the inner circle containing the "hit" points, relative to the size of this . + /// All other points outside of the inner circle are "miss" points. + /// + private const float inner_portion = 0.8f; + + /// + /// Number of rows/columns of points. + /// ~4px per point @ 128x128 size (the contents of the are always square). 1089 total points. + /// + private const int points_per_dimension = 33; + + private const float rotation = 45; + + private BufferedContainer bufferedGrid; + private GridContainer pointGrid; + + private readonly ScoreInfo score; + private readonly IBeatmap playableBeatmap; + + private const float line_thickness = 2; + + /// + /// The highest count of any point currently being displayed. + /// + protected float PeakValue { get; private set; } + + public AccuracyHeatmap(ScoreInfo score, IBeatmap playableBeatmap) + { + this.score = score; + this.playableBeatmap = playableBeatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Masking = true, + BorderThickness = line_thickness, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#202624") + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(1), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, + Rotation = -rotation, + Alpha = 0.3f, + }, + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, // adjust for edgesmoothness + Rotation = rotation + }, + } + }, + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 10, + EdgeSmoothness = new Vector2(1), + Height = line_thickness / 2, // adjust for edgesmoothness + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + EdgeSmoothness = new Vector2(1), + Width = line_thickness / 2, // adjust for edgesmoothness + Height = 10, + } + } + }, + bufferedGrid = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + CacheDrawnFrameBuffer = true, + BackgroundColour = Color4Extensions.FromHex("#202624").Opacity(0), + Child = pointGrid = new GridContainer + { + RelativeSizeAxes = Axes.Both + } + }, + } + }; + + Vector2 centre = new Vector2(points_per_dimension) / 2; + float innerRadius = centre.X * inner_portion; + + Drawable[][] points = new Drawable[points_per_dimension][]; + + for (int r = 0; r < points_per_dimension; r++) + { + points[r] = new Drawable[points_per_dimension]; + + for (int c = 0; c < points_per_dimension; c++) + { + HitPointType pointType = Vector2.Distance(new Vector2(c, r), centre) <= innerRadius + ? HitPointType.Hit + : HitPointType.Miss; + + var point = new HitPoint(pointType, this) + { + Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) + }; + + points[r][c] = point; + } + } + + pointGrid.Content = points; + + if (score.HitEvents == null || score.HitEvents.Count == 0) + return; + + // Todo: This should probably not be done like this. + float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5) / 5) / 2; + + foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) + { + if (e.LastHitObject == null || e.Position == null) + continue; + + AddPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value, radius); + } + } + + protected void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + { + if (pointGrid.Content.Length == 0) + return; + + double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. + float normalisedDistance = Vector2.Distance(hitPoint, end) / radius; + + // Consider two objects placed horizontally, with the start on the left and the end on the right. + // The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form: + // +pi | 0 + // O --------- O -----> Note: Math.Atan2 has a range (-pi <= theta <= +pi) + // -pi | 0 + // E.g. If the hit point was directly above end, it would have an angle pi/2. + // + // It also calculated the angle separating hitPoint from the line joining {start, end}, that is anti-clockwise in the form: + // 0 | pi + // O --------- O -----> + // 2pi | pi + // + // However keep in mind that cos(0)=1 and cos(2pi)=1, whereas we actually want these values to appear on the left, so the x-coordinate needs to be inverted. + // Likewise sin(pi/2)=1 and sin(3pi/2)=-1, whereas we actually want these values to appear on the bottom/top respectively, so the y-coordinate also needs to be inverted. + // + // We also need to apply the anti-clockwise rotation. + var rotatedAngle = finalAngle - MathUtils.DegreesToRadians(rotation); + var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle)); + + Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2; + float localRadius = localCentre.X * inner_portion * normalisedDistance; // The radius inside the inner portion which of the heatmap which the closest point lies. + Vector2 localPoint = localCentre + localRadius * rotatedCoordinate; + + // Find the most relevant hit point. + int r = Math.Clamp((int)Math.Round(localPoint.Y), 0, points_per_dimension - 1); + int c = Math.Clamp((int)Math.Round(localPoint.X), 0, points_per_dimension - 1); + + PeakValue = Math.Max(PeakValue, ((HitPoint)pointGrid.Content[r][c]).Increment()); + + bufferedGrid.ForceRedraw(); + } + + private class HitPoint : Circle + { + private readonly HitPointType pointType; + private readonly AccuracyHeatmap heatmap; + + public override bool IsPresent => count > 0; + + public HitPoint(HitPointType pointType, AccuracyHeatmap heatmap) + { + this.pointType = pointType; + this.heatmap = heatmap; + + RelativeSizeAxes = Axes.Both; + Alpha = 1; + } + + private int count; + + /// + /// Increment the value of this point by one. + /// + /// The value after incrementing. + public int Increment() + { + return ++count; + } + + protected override void Update() + { + base.Update(); + + // the point at which alpha is saturated and we begin to adjust colour lightness. + const float lighten_cutoff = 0.95f; + + // the amount of lightness to attribute regardless of relative value to peak point. + const float non_relative_portion = 0.2f; + + float amount = 0; + + // give some amount of alpha regardless of relative count + amount += non_relative_portion * Math.Min(1, count / 10f); + + // add relative portion + amount += (1 - non_relative_portion) * (count / heatmap.PeakValue); + + // apply easing + amount = (float)Interpolation.ApplyEasing(Easing.OutQuint, Math.Min(1, amount)); + + Debug.Assert(amount <= 1); + + Alpha = Math.Min(amount / lighten_cutoff, 1); + if (pointType == HitPointType.Hit) + Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff)); + } + } + + private enum HitPointType + { + Hit, + Miss + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 4e86662ec6..9bcb3abc63 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -5,7 +5,6 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; using osu.Framework.Allocation; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Batches; using osu.Framework.Graphics.OpenGL.Vertices; @@ -14,6 +13,7 @@ using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Framework.Layout; using osu.Framework.Timing; using osuTK; using osuTK.Graphics; @@ -43,6 +43,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor // -1 signals that the part is unusable, and should not be drawn parts[i].InvalidationID = -1; } + + AddLayout(partSizeCache); } [BackgroundDependencyLoader] @@ -72,20 +74,12 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } } - private readonly Cached partSizeCache = new Cached(); + private readonly LayoutValue partSizeCache = new LayoutValue(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence); private Vector2 partSize => partSizeCache.IsValid ? partSizeCache.Value : (partSizeCache.Value = new Vector2(Texture.DisplayWidth, Texture.DisplayHeight) * DrawInfo.Matrix.ExtractScale().Xy); - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & (Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence)) > 0) - partSizeCache.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); - } - /// /// The amount of time to fade the cursor trail pieces. /// @@ -97,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { base.Update(); - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); const int fade_clock_reset_threshold = 1000000; @@ -243,6 +237,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y + size.Y / 2), TexturePosition = textureRect.BottomLeft, + TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, Time = part.Time }); @@ -251,6 +246,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y + size.Y / 2), TexturePosition = textureRect.BottomRight, + TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, Time = part.Time }); @@ -259,6 +255,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y - size.Y / 2), TexturePosition = textureRect.TopRight, + TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, Time = part.Time }); @@ -267,6 +264,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y - size.Y / 2), TexturePosition = textureRect.TopLeft, + TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, Time = part.Time }); @@ -296,6 +294,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor [VertexMember(2, VertexAttribPointerType.Float)] public Vector2 TexturePosition; + [VertexMember(4, VertexAttribPointerType.Float)] + public Vector4 TextureRect; + [VertexMember(1, VertexAttribPointerType.Float)] public float Time; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 4f3d07f208..eea45c6c80 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -59,10 +59,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { if (!cursorExpand) return; - expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 100, Easing.OutQuad); + expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 400, Easing.OutElasticHalf); } - public void Contract() => expandTarget.ScaleTo(released_scale, 100, Easing.OutQuad); + public void Contract() => expandTarget.ScaleTo(released_scale, 400, Easing.OutQuad); private class DefaultCursor : OsuCursorSprite { @@ -115,24 +115,22 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor }, }, }, - new CircularContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.1f), - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - }, - }, - } - } + }, + }, + new Circle + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.14f), + Colour = new Color4(34, 93, 204, 255), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 8, + Colour = Color4.White, + }, + }, }; } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 6433ced624..5812e8cf75 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -12,6 +12,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; @@ -29,10 +30,12 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly Drawable cursorTrail; - public Bindable CursorScale; + public IBindable CursorScale => cursorScale; + + private readonly Bindable cursorScale = new BindableFloat(1); + private Bindable userCursorScale; private Bindable autoCursorScale; - private readonly IBindable beatmap = new Bindable(); public OsuCursorContainer() { @@ -43,37 +46,16 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor }; } + [Resolved(canBeNull: true)] + private GameplayBeatmap beatmap { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader(true)] - private void load(OsuConfigManager config, OsuRulesetConfigManager rulesetConfig, IBindable beatmap) + private void load(OsuConfigManager config, OsuRulesetConfigManager rulesetConfig) { rulesetConfig?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); - - this.beatmap.BindTo(beatmap); - this.beatmap.ValueChanged += _ => calculateScale(); - - userCursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); - userCursorScale.ValueChanged += _ => calculateScale(); - - autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); - autoCursorScale.ValueChanged += _ => calculateScale(); - - CursorScale = new Bindable(); - CursorScale.ValueChanged += e => ActiveCursor.Scale = cursorTrail.Scale = new Vector2(e.NewValue); - - calculateScale(); - } - - private void calculateScale() - { - float scale = userCursorScale.Value; - - if (autoCursorScale.Value && beatmap.Value != null) - { - // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. - scale *= 1f - 0.7f * (1f + beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY; - } - - CursorScale.Value = scale; } protected override void LoadComplete() @@ -81,6 +63,46 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor base.LoadComplete(); showTrail.BindValueChanged(v => cursorTrail.FadeTo(v.NewValue ? 1 : 0, 200), true); + + userCursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); + userCursorScale.ValueChanged += _ => calculateScale(); + + autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); + autoCursorScale.ValueChanged += _ => calculateScale(); + + CursorScale.BindValueChanged(e => + { + var newScale = new Vector2(e.NewValue); + + ActiveCursor.Scale = newScale; + cursorTrail.Scale = newScale; + }, true); + + calculateScale(); + } + + /// + /// Get the scale applicable to the ActiveCursor based on a beatmap's circle size. + /// + public static float GetScaleForCircleSize(float circleSize) => + 1f - 0.7f * (1f + circleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY; + + private void calculateScale() + { + float scale = userCursorScale.Value; + + if (autoCursorScale.Value && beatmap != null) + { + // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. + scale *= GetScaleForCircleSize(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + } + + cursorScale.Value = scale; + + var newScale = new Vector2(scale); + + ActiveCursor.ScaleTo(newScale, 400, Easing.OutQuint); + cursorTrail.Scale = newScale; } private int downCount; @@ -107,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor return false; } - public bool OnReleased(OsuAction action) + public void OnReleased(OsuAction action) { switch (action) { @@ -120,8 +142,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor updateExpandedState(); break; } - - return false; } public override bool HandlePositionalInput => true; // OverlayContainer will set this false when we go hidden, but we always want to receive input. diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index a37ef8d9a0..b4d51d11c9 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -58,6 +58,8 @@ namespace osu.Game.Rulesets.Osu.UI protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new OsuFramedReplayInputHandler(replay); + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new OsuReplayRecorder(replay); + public override double GameplayStartTime { get diff --git a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs new file mode 100644 index 0000000000..8e4f81347d --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.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 osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Ensures that s are hit in-order. Affectionately known as "note lock". + /// If a is hit out of order: + /// + /// The hit is blocked if it occurred earlier than the previous 's start time. + /// The hit causes all previous s to missed otherwise. + /// + /// + public class OrderedHitPolicy + { + private readonly HitObjectContainer hitObjectContainer; + + public OrderedHitPolicy(HitObjectContainer hitObjectContainer) + { + this.hitObjectContainer = hitObjectContainer; + } + + /// + /// Determines whether a can be hit at a point in time. + /// + /// The to check. + /// The time to check. + /// Whether can be hit at the given . + public bool IsHittable(DrawableHitObject hitObject, double time) + { + DrawableHitObject blockingObject = null; + + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) + { + if (hitObjectCanBlockFutureHits(obj)) + blockingObject = obj; + } + + // If there is no previous hitobject, allow the hit. + if (blockingObject == null) + return true; + + // A hit is allowed if: + // 1. The last blocking hitobject has been judged. + // 2. The current time is after the last hitobject's start time. + // Hits at exactly the same time as the blocking hitobject are allowed for maps that contain simultaneous hitobjects (e.g. /b/372245). + return blockingObject.Judged || time >= blockingObject.HitObject.StartTime; + } + + /// + /// Handles a being hit to potentially miss all earlier s. + /// + /// The that was hit. + public void HandleHit(DrawableHitObject hitObject) + { + // Hitobjects which themselves don't block future hitobjects don't cause misses (e.g. slider ticks, spinners). + if (!hitObjectCanBlockFutureHits(hitObject)) + return; + + if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset)) + throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!"); + + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) + { + if (obj.Judged) + continue; + + if (hitObjectCanBlockFutureHits(obj)) + ((DrawableOsuHitObject)obj).MissForcefully(); + } + } + + /// + /// Whether a blocks hits on future s until its start time is reached. + /// + /// The to test. + private static bool hitObjectCanBlockFutureHits(DrawableHitObject hitObject) + => hitObject is DrawableHitCircle; + + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in hitObjectContainer.AliveObjects) + { + if (obj.HitObject.StartTime >= targetTime) + yield break; + + yield return obj; + + foreach (var nestedObj in obj.NestedHitObjects) + { + if (nestedObj.HitObject.StartTime >= targetTime) + break; + + yield return nestedObj; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 6d1ea4bbfc..600efefca3 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -1,17 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; +using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Connections; -using osu.Game.Rulesets.UI; -using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.UI { @@ -20,11 +26,14 @@ namespace osu.Game.Rulesets.Osu.UI private readonly ApproachCircleProxyContainer approachCircles; private readonly JudgementContainer judgementLayer; private readonly FollowPointRenderer followPoints; + private readonly OrderedHitPolicy hitPolicy; public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); protected override GameplayCursorContainer CreateCursor() => new OsuCursorContainer(); + private readonly IDictionary> poolDictionary = new Dictionary>(); + public OsuPlayfield() { InternalChildren = new Drawable[] @@ -51,6 +60,15 @@ namespace osu.Game.Rulesets.Osu.UI Depth = -1, }, }; + + hitPolicy = new OrderedHitPolicy(HitObjectContainer); + + var hitWindows = new OsuHitWindows(); + + foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) + poolDictionary.Add(result, new DrawableJudgementPool(result)); + + AddRangeInternal(poolDictionary.Values); } public override void Add(DrawableHitObject h) @@ -64,7 +82,10 @@ namespace osu.Game.Rulesets.Osu.UI base.Add(h); - followPoints.AddFollowPoints((DrawableOsuHitObject)h); + DrawableOsuHitObject osuHitObject = (DrawableOsuHitObject)h; + osuHitObject.CheckHittable = hitPolicy.IsHittable; + + followPoints.AddFollowPoints(osuHitObject); } public override bool Remove(DrawableHitObject h) @@ -79,15 +100,13 @@ namespace osu.Game.Rulesets.Osu.UI private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) { + // Hitobjects that block future hits should miss previous hitobjects if they're hit out-of-order. + hitPolicy.HandleHit(judgedObject); + if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; - DrawableOsuJudgement explosion = new DrawableOsuJudgement(result, judgedObject) - { - Origin = Anchor.Centre, - Position = ((OsuHitObject)judgedObject.HitObject).StackedEndPosition, - Scale = new Vector2(((OsuHitObject)judgedObject.HitObject).Scale) - }; + DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject)); judgementLayer.Add(explosion); } @@ -98,5 +117,26 @@ namespace osu.Game.Rulesets.Osu.UI { public void Add(Drawable approachCircleProxy) => AddInternal(approachCircleProxy); } + + private class DrawableJudgementPool : DrawablePool + { + private readonly HitResult result; + + public DrawableJudgementPool(HitResult result) + : base(10) + { + this.result = result; + } + + protected override DrawableOsuJudgement CreateNewDrawable() + { + var judgement = base.CreateNewDrawable(); + + // just a placeholder to initialise the correct drawable hierarchy for this pool. + judgement.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null); + + return judgement; + } + } } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs new file mode 100644 index 0000000000..b68ea136d5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + public class OsuReplayRecorder : ReplayRecorder + { + public OsuReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new OsuReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 3b18e41f30..ec7751d2b4 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.UI private OsuClickToResumeCursor clickToResumeCursor; private OsuCursorContainer localCursorContainer; - private Bindable localCursorScale; + private IBindable localCursorScale; public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.UI { Add(localCursorContainer = new OsuCursorContainer()); - localCursorScale = new Bindable(); + localCursorScale = new BindableFloat(); localCursorScale.BindTo(localCursorContainer.CursorScale); localCursorScale.BindValueChanged(scale => cursorScaleContainer.Scale = new Vector2(scale.NewValue), true); } @@ -107,7 +107,9 @@ namespace osu.Game.Rulesets.Osu.UI return false; } - public bool OnReleased(OsuAction action) => false; + public void OnReleased(OsuAction action) + { + } public void Appear() => Schedule(() => { diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs new file mode 100644 index 0000000000..1db07b3244 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.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.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + internal class DrawableTestHit : DrawableTaikoHitObject + { + private readonly HitResult type; + + public DrawableTestHit(Hit hit, HitResult type = HitResult.Great) + : base(hit) + { + this.type = type; + } + + [BackgroundDependencyLoader] + private void load() + { + Result.Type = type; + } + + public override bool OnPressed(TaikoAction action) => false; + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs new file mode 100644 index 0000000000..a83cc16413 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public class TestSceneTaikoModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new TestTaikoRuleset(); + + public TestSceneTaikoModPerfect() + : base(new TaikoModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestHit(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Hit { StartTime = 1000, Type = HitType.Centre }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }), shouldMiss); + + private class TestTaikoRuleset : TaikoRuleset + { + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new TestTaikoHealthProcessor(); + + private class TestTaikoHealthProcessor : TaikoHealthProcessor + { + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + Health.Value = 1; // Don't care about the health condition (only the mod condition) + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png new file mode 100644 index 0000000000..72ef665478 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png new file mode 100644 index 0000000000..dc3d7f4c70 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png new file mode 100644 index 0000000000..5ca8a40d88 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png new file mode 100644 index 0000000000..3e44f33095 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png new file mode 100644 index 0000000000..15a89ade1b Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png new file mode 100644 index 0000000000..a01583c6fb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png new file mode 100644 index 0000000000..ac0fef8626 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png new file mode 100644 index 0000000000..cca9310322 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png new file mode 100644 index 0000000000..440e5b55e5 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png new file mode 100755 index 0000000000..5aba688756 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini new file mode 100644 index 0000000000..462c2c278e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,5 @@ +[General] +Name: an old skin +Author: an old guy + +// no version specified means v1 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png new file mode 100644 index 0000000000..ad55fd5a96 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png new file mode 100644 index 0000000000..f5c02509fb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png new file mode 100644 index 0000000000..53905792cb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png new file mode 100644 index 0000000000..2d9974a701 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png new file mode 100644 index 0000000000..07b2f167e0 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png new file mode 100644 index 0000000000..63504dd52d Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png new file mode 100644 index 0000000000..490c196fba Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png new file mode 100644 index 0000000000..99cd589a10 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png new file mode 100644 index 0000000000..26eec54d07 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png new file mode 100644 index 0000000000..272c6bcaf7 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png new file mode 100644 index 0000000000..e49e82a71f Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png new file mode 100644 index 0000000000..56d6d34c1a Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png new file mode 100644 index 0000000000..78c6ef6e21 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png new file mode 100644 index 0000000000..b824e4585b Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png new file mode 100644 index 0000000000..5d8b60da9e Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs new file mode 100644 index 0000000000..69250a14e1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public abstract 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 new file mode 100644 index 0000000000..f6aec20d53 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableBarLine : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + [BackgroundDependencyLoader] + private void load() + { + AddStep("Bar line", () => SetContents(() => + { + ScrollingHitObjectContainer hoc; + + var cont = new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.8f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new TaikoPlayfield(new ControlPointInfo()), + hoc = new ScrollingHitObjectContainer() + } + }; + + hoc.Add(new DrawableBarLine(createBarLineAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + return cont; + })); + + AddStep("Bar line (major)", () => SetContents(() => + { + ScrollingHitObjectContainer hoc; + + var cont = new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.8f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new TaikoPlayfield(new ControlPointInfo()), + hoc = new ScrollingHitObjectContainer() + } + }; + + hoc.Add(new DrawableBarLineMajor(createBarLineAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + return cont; + })); + } + + private BarLine createBarLineAtCurrentTime(bool major = false) + { + var barline = new BarLine + { + Major = major, + StartTime = Time.Current + 2000, + }; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 500 }); + + barline.ApplyDefaults(cpi, new BeatmapDifficulty()); + + return barline; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs new file mode 100644 index 0000000000..44646e5fc9 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.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 NUnit.Framework; +using osu.Framework.Allocation; +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; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableDrumRoll : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + [BackgroundDependencyLoader] + private void load() + { + AddStep("Drum roll", () => SetContents(() => + { + var hoc = new ScrollingHitObjectContainer(); + + hoc.Add(new DrawableDrumRoll(createDrumRollAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }); + + return hoc; + })); + + AddStep("Drum roll (strong)", () => SetContents(() => + { + var hoc = new ScrollingHitObjectContainer(); + + hoc.Add(new DrawableDrumRoll(createDrumRollAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }); + + return hoc; + })); + } + + private DrumRoll createDrumRollAtCurrentTime(bool strong = false) + { + var drumroll = new DrumRoll + { + IsStrong = strong, + StartTime = Time.Current + 1000, + Duration = 4000, + }; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 500 }); + + drumroll.ApplyDefaults(cpi, new BeatmapDifficulty()); + + return drumroll; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs new file mode 100644 index 0000000000..9930d97d31 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +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 class TestSceneDrawableHit : TaikoSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + AddStep("Centre hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Centre hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Rim hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Rim hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + + private Hit createHitAtCurrentTime(bool strong = false) + { + var hit = new Hit + { + IsStrong = strong, + StartTime = Time.Current + 3000, + }; + + 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 new file mode 100644 index 0000000000..d200c44a02 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +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.Scoring; +using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableTaikoMascot : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + private TaikoScoreProcessor scoreProcessor; + + private IEnumerable mascots => this.ChildrenOfType(); + private IEnumerable playfields => this.ChildrenOfType(); + + [SetUp] + public void SetUp() + { + scoreProcessor = new TaikoScoreProcessor(); + } + + [Test] + public void TestStateAnimations() + { + AddStep("set beatmap", () => setBeatmap()); + + AddStep("clear state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Clear))); + AddStep("idle state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Idle))); + AddStep("kiai state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai))); + AddStep("fail state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Fail))); + } + + [Test] + public void TestInitialState() + { + AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); + + AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle)); + } + + [Test] + public void TestClearStateTransition() + { + AddStep("set beatmap", () => setBeatmap()); + + AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); + + AddStep("set clear state", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); + AddStep("miss", () => mascots.ForEach(mascot => mascot.LastResult.Value = new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss })); + AddAssert("skins with animations remain in clear state", () => someMascotsIn(TaikoMascotAnimationState.Clear)); + AddUntilStep("state reverts to fail", () => allMascotsIn(TaikoMascotAnimationState.Fail)); + + AddStep("set clear state again", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); + AddAssert("skins with animations change to clear", () => someMascotsIn(TaikoMascotAnimationState.Clear)); + } + + [Test] + public void TestIdleState() + { + AddStep("set beatmap", () => setBeatmap()); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new StrongHitObject(), new TaikoStrongJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Idle); + } + + [Test] + public void TestKiaiState() + { + AddStep("set beatmap", () => setBeatmap(true)); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Kiai); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoStrongJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Kiai); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); + } + + [Test] + public void TestMissState() + { + AddStep("set beatmap", () => setBeatmap()); + + createDrawableRuleset(); + + 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.Fail); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Idle); + } + + [TestCase(true)] + [TestCase(false)] + public void TestClearStateOnComboMilestone(bool kiai) + { + AddStep("set beatmap", () => setBeatmap(kiai)); + + createDrawableRuleset(); + + AddRepeatStep("reach 49 combo", () => applyNewResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }), 49); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Clear); + } + + [TestCase(true, TaikoMascotAnimationState.Kiai)] + [TestCase(false, TaikoMascotAnimationState.Idle)] + public void TestClearStateOnClearedSwell(bool kiai, TaikoMascotAnimationState expectedStateAfterClear) + { + AddStep("set beatmap", () => setBeatmap(kiai)); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Swell(), new TaikoSwellJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Clear); + AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLower()}", () => allMascotsIn(expectedStateAfterClear)); + } + + private void setBeatmap(bool kiai = false) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 90 }); + + if (kiai) + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = new List { new Hit { Type = HitType.Centre } }, + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + Metadata = new BeatmapMetadata + { + Artist = "Unknown", + Title = "Sample Beatmap", + AuthorString = "Craftplacer", + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + ControlPointInfo = controlPointInfo + }); + + scoreProcessor.ApplyBeatmap(Beatmap.Value.Beatmap); + } + + private void createDrawableRuleset() + { + AddUntilStep("wait for beatmap to be loaded", () => Beatmap.Value.Track.IsLoaded); + + AddStep("create drawable ruleset", () => + { + Beatmap.Value.Track.Start(); + + SetContents(() => + { + var ruleset = new TaikoRuleset(); + return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo)); + }); + }); + } + + private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState) + { + AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", + () => applyNewResult(judgementResult)); + + AddAssert($"state is {expectedState.ToString().ToLower()}", () => allMascotsIn(expectedState)); + } + + private void applyNewResult(JudgementResult judgementResult) + { + scoreProcessor.ApplyResult(judgementResult); + + foreach (var playfield in playfields) + { + var hit = new DrawableTestHit(new Hit(), judgementResult.Type); + Add(hit); + + playfield.OnNewResult(hit, judgementResult); + } + + foreach (var mascot in mascots) + { + mascot.LastResult.Value = judgementResult; + } + } + + private bool allMascotsIn(TaikoMascotAnimationState state) => mascots.All(d => d.State.Value == state); + private bool someMascotsIn(TaikoMascotAnimationState state) => mascots.Any(d => d.State.Value == state); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs new file mode 100644 index 0000000000..2b5efec7f9 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneHitExplosion : TaikoSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + AddStep("Great", () => SetContents(() => getContentFor(HitResult.Great))); + AddStep("Good", () => SetContents(() => getContentFor(HitResult.Good))); + AddStep("Miss", () => SetContents(() => getContentFor(HitResult.Miss))); + } + + private Drawable getContentFor(HitResult type) + { + DrawableTaikoHitObject hit; + + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + hit = createHit(type), + new HitExplosion(hit) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + } + + private DrawableTaikoHitObject createHit(HitResult type) => new DrawableTestHit(new Hit { StartTime = Time.Current }, type); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs similarity index 56% rename from osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs rename to osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index 8c1b0c4c62..fa6c9da174 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -1,34 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; -using osuTK; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Taiko.Audio; using osu.Game.Rulesets.Taiko.UI; -using osu.Game.Tests.Visual; +using osuTK; -namespace osu.Game.Rulesets.Taiko.Tests +namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneInputDrum : OsuTestScene + public class TestSceneInputDrum : TaikoSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] + [BackgroundDependencyLoader] + private void load() { - typeof(InputDrum), - typeof(DrumSampleMapping), - typeof(HitSampleInfo), - typeof(SampleControlPoint) - }; - - public TestSceneInputDrum() - { - Add(new TaikoInputManager(new RulesetInfo { ID = 1 }) + SetContents(() => new TaikoInputManager(new RulesetInfo { ID = 1 }) { RelativeSizeAxes = Axes.Both, Child = new Container diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs new file mode 100644 index 0000000000..7b7e2c43d1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public class TestSceneTaikoPlayfield : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + public TestSceneTaikoPlayfield() + { + TaikoBeatmap beatmap; + bool kiai = false; + + AddStep("set beatmap", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap = new TaikoBeatmap()); + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); + + Beatmap.Value.Track.Start(); + }); + + AddStep("Load playfield", () => SetContents(() => new TaikoPlayfield(new ControlPointInfo()) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 0.6f, + })); + + AddRepeatStep("change height", () => this.ChildrenOfType().ForEach(p => p.Height = Math.Max(0.2f, (p.Height + 0.2f) % 1f)), 50); + + AddStep("Toggle kiai", () => + { + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new EffectControlPoint { KiaiMode = (kiai = !kiai) }); + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs new file mode 100644 index 0000000000..16ef5b968d --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.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.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Skinning; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public class TestSceneTaikoScroller : TaikoSkinnableTestScene + { + private readonly ManualClock clock = new ManualClock(); + + private bool reversed; + + public TestSceneTaikoScroller() + { + AddStep("Load scroller", () => SetContents(() => + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + { + Clock = new FramedClock(clock), + Height = 0.4f, + })); + + AddToggleStep("Toggle passing", passing => this.ChildrenOfType().ForEach(s => s.LastResult.Value = + new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Perfect : HitResult.Miss })); + + AddToggleStep("toggle playback direction", reversed => this.reversed = reversed); + } + + protected override void Update() + { + base.Update(); + + clock.CurrentTime += (reversed ? -1 : 1) * Clock.ElapsedFrameTime; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs index 28f5d4d301..d0c57b20c0 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Tests.Beatmaps; @@ -19,6 +19,9 @@ namespace osu.Game.Rulesets.Taiko.Tests [NonParallelizable] [TestCase("basic")] [TestCase("slider-generating-drumroll")] + [TestCase("sample-to-type-conversions")] + [TestCase("slider-conversion-v6")] + [TestCase("slider-conversion-v14")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) @@ -27,8 +30,8 @@ namespace osu.Game.Rulesets.Taiko.Tests { StartTime = hitObject.StartTime, EndTime = hitObject.GetEndTime(), - IsRim = hitObject is RimHit, - IsCentre = hitObject is CentreHit, + IsRim = (hitObject as Hit)?.Type == HitType.Rim, + IsCentre = (hitObject as Hit)?.Type == HitType.Centre, IsDrumRoll = hitObject is DrumRoll, IsSwell = hitObject is Swell, IsStrong = ((TaikoHitObject)hitObject).IsStrong @@ -41,7 +44,7 @@ namespace osu.Game.Rulesets.Taiko.Tests public struct ConvertValue : IEquatable { /// - /// A sane value to account for osu!stable using ints everwhere. + /// A sane value to account for osu!stable using ints everywhere. /// private const float conversion_lenience = 2; diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs new file mode 100644 index 0000000000..411fe08bcf --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneEditor.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public class TestSceneEditor : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs similarity index 92% rename from osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs rename to osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index b2c8c7feda..44452d70c1 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -7,7 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; @@ -24,9 +24,9 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] - public class TestSceneTaikoPlayfield : OsuTestScene + public class TestSceneHits : OsuTestScene { - private const double default_duration = 1000; + private const double default_duration = 3000; private const float scroll_time = 1000; protected override double TimePerAction => default_duration * 2; @@ -45,6 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Tests AddStep("Miss :(", addMissJudgement); AddStep("DrumRoll", () => addDrumRoll(false)); AddStep("Strong DrumRoll", () => addDrumRoll(true)); + AddStep("Kiai DrumRoll", () => addDrumRoll(true, kiai: true)); AddStep("Swell", () => addSwell()); AddStep("Centre", () => addCentreHit(false)); AddStep("Strong Centre", () => addCentreHit(true)); @@ -69,7 +70,7 @@ namespace osu.Game.Rulesets.Taiko.Tests WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap { - HitObjects = new List { new CentreHit() }, + HitObjects = new List { new Hit { Type = HitType.Centre } }, BeatmapInfo = new BeatmapInfo { BaseDifficulty = new BeatmapDifficulty(), @@ -148,6 +149,8 @@ namespace osu.Game.Rulesets.Taiko.Tests var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Good ? -0.1f : -0.05f, hitResult == HitResult.Good ? 0.1f : 0.05f) }; + Add(h); + ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); } @@ -163,6 +166,8 @@ namespace osu.Game.Rulesets.Taiko.Tests var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Good ? -0.1f : -0.05f, hitResult == HitResult.Good ? 0.1f : 0.05f) }; + Add(h); + ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(new TestStrongNestedHit(h), new JudgementResult(new HitObject(), new TaikoStrongJudgement()) { Type = HitResult.Great }); } @@ -192,7 +197,7 @@ namespace osu.Game.Rulesets.Taiko.Tests drawableRuleset.Playfield.Add(new DrawableSwell(swell)); } - private void addDrumRoll(bool strong, double duration = default_duration) + private void addDrumRoll(bool strong, double duration = default_duration, bool kiai = false) { addBarLine(true); addBarLine(true, scroll_time + duration); @@ -202,9 +207,13 @@ namespace osu.Game.Rulesets.Taiko.Tests StartTime = drawableRuleset.Playfield.Time.Current + scroll_time, IsStrong = strong, Duration = duration, + TickRate = 8, }; - d.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var cpi = new ControlPointInfo(); + cpi.Add(-10000, new EffectControlPoint { KiaiMode = kiai }); + + d.ApplyDefaults(cpi, new BeatmapDifficulty()); drawableRuleset.Playfield.Add(new DrawableDrumRoll(d)); } @@ -219,7 +228,7 @@ namespace osu.Game.Rulesets.Taiko.Tests h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableRuleset.Playfield.Add(new DrawableCentreHit(h)); + drawableRuleset.Playfield.Add(new DrawableHit(h)); } private void addRimHit(bool strong) @@ -232,7 +241,7 @@ namespace osu.Game.Rulesets.Taiko.Tests h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableRuleset.Playfield.Add(new DrawableRimHit(h)); + drawableRuleset.Playfield.Add(new DrawableHit(h)); } private class TestStrongNestedHit : DrawableStrongNestedHit @@ -244,13 +253,5 @@ namespace osu.Game.Rulesets.Taiko.Tests public override bool OnPressed(TaikoAction action) => false; } - - private class DrawableTestHit : DrawableHitObject - { - public DrawableTestHit(TaikoHitObject hitObject) - : base(hitObject) - { - } - } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs new file mode 100644 index 0000000000..4ba9c447fb --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + /// + /// Taiko has some interesting rules for legacy mappings. + /// + [HeadlessTest] + public class TestSceneSampleOutput : TestSceneTaikoPlayer + { + public override void SetUpSteps() + { + base.SetUpSteps(); + AddAssert("has correct samples", () => + { + var names = Player.DrawableRuleset.Playfield.AllHitObjects.OfType().Select(h => string.Join(',', h.GetSamples().Select(s => s.Name))); + + var expected = new[] + { + string.Empty, + string.Empty, + string.Empty, + string.Empty, + HitSampleInfo.HIT_FINISH, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + }; + + return names.SequenceEqual(expected); + }); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions"); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs index f27e329e8e..75049b7467 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs @@ -1,34 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Screens.Play; -using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneSwellJudgements : PlayerTestScene + public class TestSceneSwellJudgements : TestSceneTaikoPlayer { - protected new TestPlayer Player => (TestPlayer)base.Player; - - public TestSceneSwellJudgements() - : base(new TaikoRuleset()) - { - } - [Test] public void TestZeroTickTimeOffsets() { - AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted); - AddAssert("all tick offsets are 0", () => Player.Results.Where(r => r.Judgement is TaikoSwellTickJudgement).All(r => r.TimeOffset == 0)); + 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; @@ -50,25 +36,5 @@ namespace osu.Game.Rulesets.Taiko.Tests return beatmap; } - - protected override Player CreatePlayer(Ruleset ruleset) => new TestPlayer(); - - protected class TestPlayer : Player - { - public readonly List Results = new List(); - - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - - public TestPlayer() - : base(false, false) - { - } - - [BackgroundDependencyLoader] - private void load() - { - ScoreProcessor.NewJudgement += r => Results.Add(r); - } - } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs new file mode 100644 index 0000000000..34d5fdf857 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectComposer.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneTaikoHitObjectComposer : EditorClockTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + BeatDivisor.Value = 8; + Clock.Seek(0); + + Child = new TestComposer { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void BasicTest() + { + } + + private class TestComposer : CompositeDrawable + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + public readonly EditorBeatmap EditorBeatmap; + + public TestComposer() + { + InternalChildren = new Drawable[] + { + EditorBeatmap = new EditorBeatmap(new TaikoBeatmap()) + { + BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo } + }, + new TaikoHitObjectComposer(new TaikoRuleset()) + }; + + for (int i = 0; i < 10; i++) + EditorBeatmap.Add(new Hit { StartTime = 125 * i }); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs new file mode 100644 index 0000000000..cd7511241a --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneTaikoPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs index 140433a523..aaa634648a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs @@ -4,28 +4,21 @@ using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneTaikoSuddenDeath : PlayerTestScene + public class TestSceneTaikoSuddenDeath : TestSceneTaikoPlayer { - public TestSceneTaikoSuddenDeath() - : base(new TaikoRuleset()) - { - } - protected override bool AllowFail => true; - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { SelectedMods.Value = SelectedMods.Value.Concat(new[] { new TaikoModSuddenDeath() }).ToArray(); - return new ScoreAccessiblePlayer(); + return base.CreatePlayer(ruleset); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => @@ -49,20 +42,10 @@ namespace osu.Game.Rulesets.Taiko.Tests AddStep("Setup judgements", () => { judged = false; - ((ScoreAccessiblePlayer)Player).ScoreProcessor.NewJudgement += b => judged = true; + Player.ScoreProcessor.NewJudgement += b => judged = true; }); AddUntilStep("swell judged", () => judged); AddAssert("not failed", () => !Player.HasFailed); } - - private class ScoreAccessiblePlayer : TestPlayer - { - public ScoreAccessiblePlayer() - : base(false, false) - { - } - - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - } } } 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 d728d65bfd..ada7ac5d74 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -2,9 +2,9 @@ - + - + diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index cc9d6e4470..2a1aa5d1df 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; namespace osu.Game.Rulesets.Taiko.Beatmaps { @@ -19,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps /// osu! is generally slower than taiko, so a factor is added to increase /// speed. This must be used everywhere slider length or beat length is used. /// - private const float legacy_velocity_multiplier = 1.4f; + public const float LEGACY_VELOCITY_MULTIPLIER = 1.4f; /// /// Because swells are easier in taiko than spinners are in osu!, @@ -52,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps // Rewrite the beatmap info to add the slider velocity multiplier original.BeatmapInfo = original.BeatmapInfo.Clone(); original.BeatmapInfo.BaseDifficulty = original.BeatmapInfo.BaseDifficulty.Clone(); - original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= legacy_velocity_multiplier; + original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LEGACY_VELOCITY_MULTIPLIER; Beatmap converted = base.ConvertBeatmap(original); @@ -82,39 +83,9 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { case IHasDistance distanceData: { - // Number of spans of the object - one for the initial length and for each repeat - int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; - - TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); - DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); - - double speedAdjustment = difficultyPoint.SpeedMultiplier; - double speedAdjustedBeatLength = timingPoint.BeatLength / speedAdjustment; - - // The true distance, accounting for any repeats. This ends up being the drum roll distance later - double distance = distanceData.Distance * spans * legacy_velocity_multiplier; - - // The velocity of the taiko hit object - calculated as the velocity of a drum roll - double taikoVelocity = taiko_base_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the taiko hit object - double taikoDuration = distance / taikoVelocity; - - // The velocity of the osu! hit object - calculated as the velocity of a slider - double osuVelocity = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the osu! hit object - double osuDuration = distance / osuVelocity; - - // osu-stable always uses the speed-adjusted beatlength to determine the velocities, but - // only uses it for tick rate if beatmap version < 8 - if (beatmap.BeatmapInfo.BeatmapVersion >= 8) - speedAdjustedBeatLength *= speedAdjustment; - - // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat - double tickSpacing = Math.Min(speedAdjustedBeatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); - - if (!isForCurrentRuleset && tickSpacing > 0 && osuDuration < 2 * speedAdjustedBeatLength) + if (shouldConvertSliderToHits(obj, beatmap, distanceData, out var taikoDuration, out var tickSpacing)) { - List> allSamples = obj is IHasCurve curveData ? curveData.NodeSamples : new List>(new[] { samples }); + List> allSamples = obj is IHasPathWithRepeats curveData ? curveData.NodeSamples : new List>(new[] { samples }); int i = 0; @@ -124,24 +95,13 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps bool isRim = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE); strong = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_FINISH); - if (isRim) + yield return new Hit { - yield return new RimHit - { - StartTime = j, - Samples = currentSamples, - IsStrong = strong - }; - } - else - { - yield return new CentreHit - { - StartTime = j, - Samples = currentSamples, - IsStrong = strong - }; - } + StartTime = j, + Type = isRim ? HitType.Rim : HitType.Centre, + Samples = currentSamples, + IsStrong = strong + }; i = (i + 1) % allSamples.Count; } @@ -161,7 +121,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps break; } - case IHasEndTime endTimeData: + case IHasDuration endTimeData: { double hitMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; @@ -178,32 +138,69 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps default: { - bool isRim = samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE); + bool isRimDefinition(HitSampleInfo s) => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE; - if (isRim) + bool isRim = samples.Any(isRimDefinition); + + yield return new Hit { - yield return new RimHit - { - StartTime = obj.StartTime, - Samples = obj.Samples, - IsStrong = strong - }; - } - else - { - yield return new CentreHit - { - StartTime = obj.StartTime, - Samples = obj.Samples, - IsStrong = strong - }; - } + StartTime = obj.StartTime, + Type = isRim ? HitType.Rim : HitType.Centre, + Samples = samples, + IsStrong = strong + }; break; } } } + private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasDistance distanceData, out double taikoDuration, out double tickSpacing) + { + // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. + // Some of these calculations look redundant, but they are not - extremely small floating point errors are introduced to maintain 1:1 compatibility with stable. + // Rounding cannot be used as an alternative since the error deltas have been observed to be between 1e-2 and 1e-6. + + // The true distance, accounting for any repeats. This ends up being the drum roll distance later + int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; + double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER; + + TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); + DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); + + double beatLength; +#pragma warning disable 618 + if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) +#pragma warning restore 618 + beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + else + beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; + + double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + + // The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll. + double taikoVelocity = sliderScoringPointDistance * beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + taikoDuration = distance / taikoVelocity * beatLength; + + if (isForCurrentRuleset) + { + tickSpacing = 0; + return false; + } + + double osuVelocity = taikoVelocity * (1000f / beatLength); + + // osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8 + if (beatmap.BeatmapInfo.BeatmapVersion >= 8) + beatLength = timingPoint.BeatLength; + + // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat + tickSpacing = Math.Min(beatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); + + return tickSpacing > 0 + && distance / osuVelocity * 1000 < 2 * beatLength; + } + protected override Beatmap CreateBeatmap() => new TaikoBeatmap(); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index 24345275c1..6807142327 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) : base(hitObject, lastObject, clockRate) { - HasTypeChange = lastObject is RimHit != hitObject is RimHit; + HasTypeChange = (lastObject as Hit)?.Type != (hitObject as Hit)?.Type; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs index c6fe273b50..2c1885ae1a 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills double addition = 1; // We get an extra addition if we are not a slider or spinner - if (current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000) + if (current.LastObject is Hit && current.BaseObject is Hit && current.BaseObject.StartTime - current.LastObject.StartTime < 1000) { if (hasColourChange(current)) addition += 0.75; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 3a0fb64622..bc147b53ac 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -31,10 +32,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public override double Calculate(Dictionary categoryDifficulty = null) { mods = Score.Mods; - countGreat = Score.Statistics[HitResult.Great]; - countGood = Score.Statistics[HitResult.Good]; - countMeh = Score.Statistics[HitResult.Meh]; - countMiss = Score.Statistics[HitResult.Miss]; + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countGood = Score.Statistics.GetOrDefault(HitResult.Good); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs new file mode 100644 index 0000000000..eb07ce7635 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public 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 new file mode 100644 index 0000000000..b02e3aa9ba --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.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.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class HitPiece : CompositeDrawable + { + public HitPiece() + { + Origin = Anchor.Centre; + + InternalChild = new CircularContainer + { + Masking = true, + BorderThickness = 10, + BorderColour = Color4.Yellow, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs new file mode 100644 index 0000000000..c5191ab241 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class HitPlacementBlueprint : PlacementBlueprint + { + private readonly HitPiece piece; + + private static Hit hit; + + public HitPlacementBlueprint() + : base(hit = new Hit()) + { + InternalChild = piece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + }; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (e.Button) + { + case MouseButton.Left: + hit.Type = HitType.Centre; + EndPlacement(true); + return true; + + case MouseButton.Right: + hit.Type = HitType.Rim; + EndPlacement(true); + return true; + } + + return false; + } + + public override void UpdatePosition(SnapResult result) + { + piece.Position = ToLocalSpace(result.ScreenSpacePosition); + base.UpdatePosition(result); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs new file mode 100644 index 0000000000..6b651fd739 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.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.Edit.Blueprints +{ + public class LengthPiece : CompositeDrawable + { + public LengthPiece() + { + Origin = Anchor.CentreLeft; + + InternalChild = new Container + { + Masking = true, + Colour = Color4.Yellow, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Height = 8, + }, + new Box + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = 8, + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs new file mode 100644 index 0000000000..95fa82a0f2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public 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 new file mode 100644 index 0000000000..62f69122cc --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.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.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class TaikoSelectionBlueprint : OverlaySelectionBlueprint + { + public TaikoSelectionBlueprint(DrawableHitObject hitObject) + : base(hitObject) + { + RelativeSizeAxes = Axes.None; + + AddInternal(new HitPiece + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopLeft + }); + } + + protected override void Update() + { + base.Update(); + + // Move the rectangle to cover the hitobjects + var topLeft = new Vector2(float.MaxValue, float.MaxValue); + var bottomRight = new Vector2(float.MinValue, float.MinValue); + + topLeft = Vector2.ComponentMin(topLeft, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft)); + bottomRight = Vector2.ComponentMax(bottomRight, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight)); + + Size = bottomRight - topLeft; + Position = topLeft; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs new file mode 100644 index 0000000000..468d980b23 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.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 System; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class TaikoSpanPlacementBlueprint : PlacementBlueprint + { + private readonly HitPiece headPiece; + private readonly HitPiece tailPiece; + + private readonly LengthPiece lengthPiece; + + private readonly IHasDuration spanPlacementObject; + + public TaikoSpanPlacementBlueprint(HitObject hitObject) + : base(hitObject) + { + spanPlacementObject = hitObject as IHasDuration; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + headPiece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + }, + lengthPiece = new LengthPiece + { + Height = TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT + }, + tailPiece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + } + }; + } + + private double originalStartTime; + private Vector2 originalPosition; + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) + return false; + + BeginPlacement(true); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button != MouseButton.Left) + return; + + base.OnMouseUp(e); + EndPlacement(true); + } + + public override void UpdatePosition(SnapResult result) + { + base.UpdatePosition(result); + + if (PlacementActive) + { + if (result.Time is double dragTime) + { + if (dragTime < originalStartTime) + { + HitObject.StartTime = dragTime; + spanPlacementObject.Duration = Math.Abs(dragTime - originalStartTime); + headPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + tailPiece.Position = originalPosition; + } + else + { + HitObject.StartTime = originalStartTime; + spanPlacementObject.Duration = Math.Abs(dragTime - originalStartTime); + tailPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + headPiece.Position = originalPosition; + } + + lengthPiece.X = headPiece.X; + lengthPiece.Width = tailPiece.X - headPiece.X; + } + } + else + { + lengthPiece.Position = headPiece.Position = tailPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + + if (result.Time is double startTime) + { + originalStartTime = HitObject.StartTime = startTime; + originalPosition = ToLocalSpace(result.ScreenSpacePosition); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs new file mode 100644 index 0000000000..bf77c76670 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class DrumRollCompositionTool : HitObjectCompositionTool + { + public DrumRollCompositionTool() + : base(nameof(DrumRoll)) + { + } + + public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs new file mode 100644 index 0000000000..e877cf6240 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class HitCompositionTool : HitObjectCompositionTool + { + public HitCompositionTool() + : base(nameof(Hit)) + { + } + + public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs new file mode 100644 index 0000000000..a6191fcedc --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class SwellCompositionTool : HitObjectCompositionTool + { + public SwellCompositionTool() + : base(nameof(Swell)) + { + } + + public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs new file mode 100644 index 0000000000..35227b3c64 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.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.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoBlueprintContainer : ComposeBlueprintContainer + { + public TaikoBlueprintContainer(IEnumerable hitObjects) + : base(hitObjects) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => + new TaikoSelectionBlueprint(hitObject); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs new file mode 100644 index 0000000000..cdc9672a8e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.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.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoHitObjectComposer : HitObjectComposer + { + public TaikoHitObjectComposer(TaikoRuleset ruleset) + : base(ruleset) + { + } + + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + { + new HitCompositionTool(), + new DrumRollCompositionTool(), + new SwellCompositionTool() + }; + + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new TaikoBlueprintContainer(hitObjects); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs new file mode 100644 index 0000000000..eebf6980fe --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoSelectionHandler : SelectionHandler + { + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + { + if (selection.All(s => s.HitObject is Hit)) + { + var hits = selection.Select(s => s.HitObject).OfType(); + + yield return new TernaryStateMenuItem("Rim", action: state => + { + foreach (var h in hits) + { + switch (state) + { + case TernaryState.True: + h.Type = HitType.Rim; + break; + + case TernaryState.False: + h.Type = HitType.Centre; + break; + } + } + }) + { + State = { Value = getTernaryState(hits, h => h.Type == HitType.Rim) } + }; + } + + if (selection.All(s => s.HitObject is TaikoHitObject)) + { + var hits = selection.Select(s => s.HitObject).OfType(); + + yield return new TernaryStateMenuItem("Strong", action: state => + { + foreach (var h in hits) + { + switch (state) + { + case TernaryState.True: + h.IsStrong = true; + break; + + case TernaryState.False: + h.IsStrong = false; + break; + } + + EditorBeatmap?.UpdateHitObject(h); + } + }) + { + State = { Value = getTernaryState(hits, h => h.IsStrong) } + }; + } + } + + private TernaryState getTernaryState(IEnumerable selection, Func func) + { + if (selection.Any(func)) + return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; + + return TernaryState.False; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index b7db3307ad..1253b7c8ae 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -30,13 +30,15 @@ namespace osu.Game.Rulesets.Taiko.Mods private class TaikoFlashlight : Flashlight { - private readonly Cached flashlightProperties = new Cached(); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); private readonly TaikoPlayfield taikoPlayfield; public TaikoFlashlight(TaikoPlayfield taikoPlayfield) { this.taikoPlayfield = taikoPlayfield; FlashlightSize = new Vector2(0, getSizeFor(0)); + + AddLayout(flashlightProperties); } private float getSizeFor(int combo) @@ -56,16 +58,6 @@ namespace osu.Game.Rulesets.Taiko.Mods protected override string FragmentShader => "CircularFlashlight"; - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - { - flashlightProperties.Invalidate(); - } - - return base.Invalidate(invalidation, source, shallPropagate); - } - protected override void Update() { base.Update(); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs new file mode 100644 index 0000000000..1cf19ac18e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.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.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModRandom : ModRandom, IApplicableToBeatmap + { + public override string Description => @"Shuffle around the colours!"; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var taikoBeatmap = (TaikoBeatmap)beatmap; + + foreach (var obj in taikoBeatmap.HitObjects) + { + if (obj is Hit hit) + hit.Type = RNG.Next(2) == 0 ? HitType.Centre : HitType.Rim; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs index 2afbbc737c..6306195704 100644 --- a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Taiko.Objects @@ -8,5 +9,7 @@ namespace osu.Game.Rulesets.Taiko.Objects public class BarLine : TaikoHitObject, IBarLine { public bool Major { get; set; } + + 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 1a5a797f28..1e08e921a6 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Objects; using osuTK; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -27,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// The visual line tracker. /// - protected Box Tracker; + protected SkinnableDrawable Line; /// /// The bar line. @@ -45,14 +46,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables RelativeSizeAxes = Axes.Y; Width = tracker_width; - AddInternal(Tracker = new Box + AddInternal(Line = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.BarLine), _ => new Box + { + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = new Vector2(0.5f, 0), + }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(0.5f, 0), - Alpha = 0.75f + Alpha = 0.75f, }); } + + protected override void UpdateStateTransforms(ArmedState state) => this.FadeOut(150); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs index 4d3a1a3f8a..62aab3524b 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } }); - Tracker.Alpha = 1f; + Line.Alpha = 1f; } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.cs deleted file mode 100644 index 4979135f50..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.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.Framework.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables -{ - public class DrawableCentreHit : DrawableHit - { - public override TaikoAction[] HitActions { get; } = { TaikoAction.LeftCentre, TaikoAction.RightCentre }; - - public DrawableCentreHit(Hit hit) - : base(hit) - { - MainPiece.Add(new CentreHitSymbolPiece()); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - MainPiece.AccentColour = colours.PinkDarker; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 338fd9e20f..2c1c2d2bc1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -4,7 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; @@ -14,6 +14,8 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -29,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private int rollingHits; - private readonly Container tickContainer; + private Container tickContainer; private Color4 colourIdle; private Color4 colourEngaged; @@ -38,14 +40,19 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables : base(drumRoll) { RelativeSizeAxes = Axes.Y; - MainPiece.Add(tickContainer = new Container { RelativeSizeAxes = Axes.Both }); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - MainPiece.AccentColour = colourIdle = colours.YellowDark; + colourIdle = colours.YellowDark; colourEngaged = colours.YellowDarker; + + Content.Add(tickContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue + }); } protected override void LoadComplete() @@ -55,6 +62,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables OnNewResult += onNewResult; } + protected override void RecreatePieces() + { + base.RecreatePieces(); + updateColour(); + } + protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); @@ -84,7 +97,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return base.CreateNestedHitObject(hitObject); } - protected override TaikoPiece CreateMainPiece() => new ElongatedCirclePiece(); + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollBody), + _ => new ElongatedCirclePiece()); public override bool OnPressed(TaikoAction action) => false; @@ -100,8 +114,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables rollingHits = Math.Clamp(rollingHits, 0, rolling_hits_for_engaged_colour); - Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); - MainPiece.FadeAccent(newColour, 100); + updateColour(); } protected override void CheckForResult(bool userTriggered, double timeOffset) @@ -113,8 +126,11 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return; int countHit = NestedHitObjects.Count(o => o.IsHit); + if (countHit >= HitObject.RequiredGoodHits) + { ApplyResult(r => r.Type = countHit >= HitObject.RequiredGreatHits ? HitResult.Great : HitResult.Good); + } else ApplyResult(r => r.Type = HitResult.Miss); } @@ -130,8 +146,22 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } + protected override void Update() + { + base.Update(); + + OriginPosition = new Vector2(DrawHeight); + Content.X = DrawHeight / 2; + } + protected override DrawableStrongNestedHit CreateStrongHit(StrongHitObject hitObject) => new StrongNestedHit(hitObject, this); + private void updateColour() + { + Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); + (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, 100); + } + private class StrongNestedHit : DrawableStrongNestedHit { public StrongNestedHit(StrongHitObject strong, DrawableDrumRoll drumRoll) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 25b6141a0e..62405cf047 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -6,23 +6,28 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public class DrawableDrumRollTick : DrawableTaikoHitObject { + /// + /// The hit type corresponding to the that the user pressed to hit this . + /// + public HitType JudgementType; + public DrawableDrumRollTick(DrumRollTick tick) : base(tick) { FillMode = FillMode.Fit; } - public override bool DisplayResult => false; - - protected override TaikoPiece CreateMainPiece() => new TickPiece - { - Filled = HitObject.FirstTick - }; + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), + _ => new TickPiece + { + Filled = HitObject.FirstTick + }); protected override void CheckForResult(bool userTriggered, double timeOffset) { @@ -49,7 +54,11 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - public override bool OnPressed(TaikoAction action) => UpdateResult(true); + public override bool OnPressed(TaikoAction action) + { + JudgementType = action == TaikoAction.LeftRim || action == TaikoAction.RightRim ? HitType.Rim : HitType.Centre; + return UpdateResult(true); + } protected override DrawableStrongNestedHit CreateStrongHit(StrongHitObject hitObject) => new StrongNestedHit(hitObject, this); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs new file mode 100644 index 0000000000..460e760629 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +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 DrawableFlyingHit(DrawableDrumRollTick drumRollTick) + : base(new IgnoreHit + { + StartTime = drumRollTick.HitObject.StartTime + drumRollTick.Result.TimeOffset, + IsStrong = drumRollTick.HitObject.IsStrong, + Type = drumRollTick.JudgementType + }) + { + HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 4b25ff0ecc..92ae7e0fd3 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -2,37 +2,111 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public abstract class DrawableHit : DrawableTaikoHitObject + public class DrawableHit : DrawableTaikoHitObject { /// /// A list of keys which can result in hits for this HitObject. /// - public abstract TaikoAction[] HitActions { get; } + public TaikoAction[] HitActions { get; private set; } /// /// The action that caused this to be hit. /// - public TaikoAction? HitAction { get; private set; } + public TaikoAction? HitAction + { + get; + private set; + } private bool validActionPressed; private bool pressHandledThisFrame; - protected DrawableHit(Hit hit) + private Bindable type; + + public DrawableHit(Hit hit) : base(hit) { FillMode = FillMode.Fit; } + [BackgroundDependencyLoader] + private void load() + { + type = HitObject.TypeBindable.GetBoundCopy(); + type.BindValueChanged(_ => + { + updateType(); + RecreatePieces(); + }); + + updateType(); + } + + private void updateType() + { + HitActions = + HitObject.Type == HitType.Centre + ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre } + : new[] { TaikoAction.LeftRim, TaikoAction.RightRim }; + + RecreatePieces(); + } + + 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); + + public override IEnumerable GetSamples() + { + // normal and claps are always handled by the drum (see DrumSampleMapping). + // in addition, whistles are excluded as they are an alternative rim marker. + + var samples = HitObject.Samples.Where(s => + s.Name != HitSampleInfo.HIT_NORMAL + && s.Name != HitSampleInfo.HIT_CLAP + && s.Name != HitSampleInfo.HIT_WHISTLE); + + if (HitObject.Type == HitType.Rim && HitObject.IsStrong) + { + // strong + rim always maps to whistle. + // TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken. + // when we add a taiko editor, this is probably not going to play nice. + + var corrected = samples.ToList(); + + for (var i = 0; i < corrected.Count; i++) + { + var s = corrected[i]; + + if (s.Name != HitSampleInfo.HIT_FINISH) + continue; + + var sClone = s.Clone(); + sClone.Name = HitSampleInfo.HIT_WHISTLE; + corrected[i] = sClone; + } + + return corrected; + } + + return samples; + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); @@ -58,7 +132,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { if (pressHandledThisFrame) return true; - if (Judged) return false; @@ -66,22 +139,20 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // Only count this as handled if the new judgement is a hit var result = UpdateResult(true); - if (IsHit) HitAction = action; // Regardless of whether we've hit or not, any secondary key presses in the same frame should be discarded // E.g. hitting a non-strong centre as a strong should not fall through and perform a hit on the next note pressHandledThisFrame = true; - return result; } - public override bool OnReleased(TaikoAction action) + public override void OnReleased(TaikoAction action) { if (action == HitAction) HitAction = null; - return base.OnReleased(action); + base.OnReleased(action); } protected override void Update() @@ -91,8 +162,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // The input manager processes all input prior to us updating, so this is the perfect time // for us to remove the extra press blocking, before input is handled in the next frame pressHandledThisFrame = false; - - Size = BaseSize * Parent.RelativeChildSize; } protected override void UpdateStateTransforms(ArmedState state) @@ -115,7 +184,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // If we're far enough away from the left stage, we should bring outselves in front of it ProxyContent(); - var flash = (MainPiece as CirclePiece)?.FlashBox; + var flash = (MainPiece.Drawable as CirclePiece)?.FlashBox; flash?.FadeTo(0.9f).FadeOut(300); const float gravity_time = 300; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.cs deleted file mode 100644 index 5a12d71cea..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.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.Framework.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables -{ - public class DrawableRimHit : DrawableHit - { - public override TaikoAction[] HitActions { get; } = { TaikoAction.LeftRim, TaikoAction.RightRim }; - - public DrawableRimHit(Hit hit) - : base(hit) - { - MainPiece.Add(new RimHitSymbolPiece()); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - MainPiece.AccentColour = colours.BlueDarker; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index fa39819199..7294587b10 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -9,11 +9,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -34,8 +35,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; - private readonly SwellSymbolPiece symbol; - public DrawableSwell(Swell swell) : base(swell) { @@ -107,18 +106,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables }); AddInternal(ticks = new Container { RelativeSizeAxes = Axes.Both }); - - MainPiece.Add(symbol = new SwellSymbolPiece()); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - MainPiece.AccentColour = colours.YellowDark; expandingRing.Colour = colours.YellowLight; targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); } + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Swell), + _ => new SwellCirclePiece + { + // to allow for rotation transform + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + protected override void LoadComplete() { base.LoadComplete(); @@ -182,7 +186,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables .Then() .FadeTo(completion / 8, 2000, Easing.OutQuint); - symbol.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); + MainPiece.Drawable.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); @@ -233,7 +237,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables case ArmedState.Miss: case ArmedState.Hit: - using (BeginAbsoluteSequence(Time.Current, true)) + using (BeginDelayedSequence(HitObject.Duration, true)) { this.FadeOut(transition_duration, Easing.Out); bodyContainer.ScaleTo(1.4f, transition_duration); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index ce875ebba8..1685576f0d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs @@ -3,6 +3,8 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -28,5 +30,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } public override bool OnPressed(TaikoAction action) => false; + + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), + _ => new TickPiece()); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index b9d31ff906..929cf8a937 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -4,14 +4,16 @@ using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; using osuTK; using System.Linq; using osu.Game.Audio; using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -45,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// Moves to a layer proxied above the playfield. - /// Does nothing is content is already proxied. + /// Does nothing if content is already proxied. /// protected void ProxyContent() { @@ -77,7 +79,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public Drawable CreateProxiedContent() => proxiedContent.CreateProxy(); public abstract bool OnPressed(TaikoAction action); - public virtual bool OnReleased(TaikoAction action) => false; + + public virtual void OnReleased(TaikoAction action) + { + } public override double LifetimeStart { @@ -105,19 +110,21 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - public abstract class DrawableTaikoHitObject : DrawableTaikoHitObject - where TTaikoHit : TaikoHitObject + public abstract class DrawableTaikoHitObject : DrawableTaikoHitObject + where TObject : TaikoHitObject { public override Vector2 OriginPosition => new Vector2(DrawHeight / 2); - public new TTaikoHit HitObject; + public new TObject HitObject; - protected readonly Vector2 BaseSize; - protected readonly TaikoPiece MainPiece; + protected Vector2 BaseSize; + protected SkinnableDrawable MainPiece; + + private Bindable isStrong; private readonly Container strongHitContainer; - protected DrawableTaikoHitObject(TTaikoHit hitObject) + protected DrawableTaikoHitObject(TObject hitObject) : base(hitObject) { HitObject = hitObject; @@ -126,14 +133,25 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Origin = Anchor.Custom; RelativeSizeAxes = Axes.Both; - Size = BaseSize = new Vector2(HitObject.IsStrong ? TaikoHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); - - Content.Add(MainPiece = CreateMainPiece()); - MainPiece.KiaiMode = HitObject.Kiai; AddInternal(strongHitContainer = new Container()); } + [BackgroundDependencyLoader] + private void load() + { + isStrong = HitObject.IsStrongBindable.GetBoundCopy(); + isStrong.BindValueChanged(_ => RecreatePieces(), true); + } + + protected virtual void RecreatePieces() + { + Size = BaseSize = new Vector2(HitObject.IsStrong ? TaikoHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); + + MainPiece?.Expire(); + Content.Add(MainPiece = CreateMainPiece()); + } + protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); @@ -163,10 +181,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return base.CreateNestedHitObject(hitObject); } - // Normal and clap samples are handled by the drum - protected override IEnumerable GetSamples() => HitObject.Samples.Where(s => s.Name != HitSampleInfo.HIT_NORMAL && s.Name != HitSampleInfo.HIT_CLAP); + // Most osu!taiko hitsounds are managed by the drum (see DrumSampleMapping). + public override IEnumerable GetSamples() => Enumerable.Empty(); - protected virtual TaikoPiece CreateMainPiece() => new CirclePiece(); + protected abstract SkinnableDrawable CreateMainPiece(); /// /// Creates the handler for this 's . diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.cs new file mode 100644 index 0000000000..0509841ba8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; + +namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces +{ + public class CentreHitCirclePiece : CirclePiece + { + public CentreHitCirclePiece() + { + Add(new CentreHitSymbolPiece()); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.PinkDarker; + } + + /// + /// The symbol used for centre hit pieces. + /// + public class CentreHitSymbolPiece : Container + { + public CentreHitSymbolPiece() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + Padding = new MarginPadding(SYMBOL_BORDER); + + Children = new[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new[] { new Box { RelativeSizeAxes = Axes.Both } } + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs deleted file mode 100644 index 7ed61ede96..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osuTK; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - /// - /// The symbol used for centre hit pieces. - /// - public class CentreHitSymbolPiece : Container - { - public CentreHitSymbolPiece() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - Padding = new MarginPadding(CirclePiece.SYMBOL_BORDER); - - Children = new[] - { - new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new[] { new Box { RelativeSizeAxes = Axes.Both } } - } - }; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs index d9c0664ecd..f515a35c18 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/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.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,8 +9,9 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Backgrounds; using osuTK.Graphics; using osu.Game.Beatmaps.ControlPoints; -using osu.Framework.Audio.Track; using osu.Framework.Graphics.Effects; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { @@ -20,21 +22,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces /// for a usage example. /// /// - public class CirclePiece : TaikoPiece + public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour { public const float SYMBOL_SIZE = 0.45f; public const float SYMBOL_BORDER = 8; private const double pre_beat_transition_time = 80; + private Color4 accentColour; + /// /// The colour of the inner circle and outer glows. /// - public override Color4 AccentColour + public Color4 AccentColour { - get => base.AccentColour; + get => accentColour; set { - base.AccentColour = value; + accentColour = value; background.Colour = AccentColour; @@ -42,15 +46,17 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces } } + private bool kiaiMode; + /// /// Whether Kiai mode effects are enabled for this circle piece. /// - public override bool KiaiMode + public bool KiaiMode { - get => base.KiaiMode; + get => kiaiMode; set { - base.KiaiMode = value; + kiaiMode = value; resetEdgeEffects(); } @@ -64,8 +70,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces public Box FlashBox; - public CirclePiece() + protected CirclePiece() { + RelativeSizeAxes = Axes.Both; + EarlyActivationMilliseconds = pre_beat_transition_time; AddRangeInternal(new Drawable[] @@ -140,7 +148,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces }; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (!effectPoint.KiaiMode) return; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs index 7e3272e42b..034ab6dd21 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { @@ -12,18 +14,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces RelativeSizeAxes = Axes.Y; } + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.YellowDark; + } + protected override void Update() { base.Update(); - - var padding = Content.DrawHeight * Content.Width / 2; - - Content.Padding = new MarginPadding - { - Left = padding, - Right = padding, - }; - Width = Parent.DrawSize.X + DrawHeight; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.cs new file mode 100644 index 0000000000..3273ab7fa7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces +{ + public class RimHitCirclePiece : CirclePiece + { + public RimHitCirclePiece() + { + Add(new RimHitSymbolPiece()); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.BlueDarker; + } + + /// + /// The symbol used for rim hit pieces. + /// + public class RimHitSymbolPiece : CircularContainer + { + public RimHitSymbolPiece() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + + BorderThickness = SYMBOL_BORDER; + BorderColour = Color4.White; + Masking = true; + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.cs deleted file mode 100644 index e4c964a884..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.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 osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - /// - /// The symbol used for rim hit pieces. - /// - public class RimHitSymbolPiece : CircularContainer - { - public RimHitSymbolPiece() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - - BorderThickness = CirclePiece.SYMBOL_BORDER; - BorderColour = Color4.White; - Masking = true; - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - }; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs index 0ed9923924..a8f9f0b94d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs @@ -1,36 +1,52 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { - /// - /// The symbol used for swell pieces. - /// - public class SwellSymbolPiece : Container + public class SwellCirclePiece : CirclePiece { - public SwellSymbolPiece() + public SwellCirclePiece() { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; + Add(new SwellSymbolPiece()); + } - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - Padding = new MarginPadding(CirclePiece.SYMBOL_BORDER); + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.YellowDark; + } - Children = new[] + /// + /// The symbol used for swell pieces. + /// + public class SwellSymbolPiece : Container + { + public SwellSymbolPiece() { - new SpriteIcon + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + Padding = new MarginPadding(SYMBOL_BORDER); + + Children = new[] { - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.Asterisk, - Shadow = false - } - }; + new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.Asterisk, + Shadow = false + } + }; + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs deleted file mode 100644 index 8067054f8f..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; -using osu.Game.Graphics.Containers; -using osu.Framework.Graphics; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - public class TaikoPiece : BeatSyncedContainer, IHasAccentColour - { - /// - /// The colour of the inner circle and outer glows. - /// - public virtual Color4 AccentColour { get; set; } - - /// - /// Whether Kiai mode effects are enabled for this circle piece. - /// - public virtual bool KiaiMode { get; set; } - - public TaikoPiece() - { - RelativeSizeAxes = Axes.Both; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs index 83cf7a64ec..0648bcebcd 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs @@ -9,7 +9,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { - public class TickPiece : TaikoPiece + public class TickPiece : CompositeDrawable { /// /// Any tick that is not the first for a drumroll is not filled, but is instead displayed @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces FillMode = FillMode.Fit; Size = new Vector2(tick_size); - Add(new CircularContainer + InternalChild = new CircularContainer { RelativeSizeAxes = Axes.Both, Masking = true, @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces AlwaysPresent = true } } - }); + }; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 8956ca9c19..5f52160be1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -3,25 +3,38 @@ using osu.Game.Rulesets.Objects.Types; using System; +using System.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Judgements; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects { - public class DrumRoll : TaikoHitObject, IHasEndTime + public class DrumRoll : TaikoHitObject, IHasPath { /// /// Drum roll distance that results in a duration of 1 speed-adjusted beat length. /// private const float base_distance = 100; - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } public double Duration { get; set; } + /// + /// Velocity of this . + /// + public double Velocity { get; private set; } + /// /// Numer of ticks per beat length. /// @@ -50,22 +63,26 @@ namespace osu.Game.Rulesets.Taiko.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); + DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); + + double scoringDistance = base_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; overallDifficulty = difficulty.OverallDifficulty; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - createTicks(); + 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(); + base.CreateNestedHitObjects(cancellationToken); } - private void createTicks() + private void createTicks(CancellationToken cancellationToken) { if (tickSpacing == 0) return; @@ -74,6 +91,8 @@ namespace osu.Game.Rulesets.Taiko.Objects for (double t = StartTime; t < EndTime + tickSpacing / 2; t += tickSpacing) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new DrumRollTick { FirstTick = first, @@ -89,5 +108,14 @@ namespace osu.Game.Rulesets.Taiko.Objects public override Judgement CreateJudgement() => new TaikoDrumRollJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + #region LegacyBeatmapEncoder + + double IHasDistance.Distance => Duration * Velocity; + + SliderPath IHasPath.Path + => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER); + + #endregion } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 6cc9357580..68cc8d0ead 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -1,9 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; + namespace osu.Game.Rulesets.Taiko.Objects { public class Hit : TaikoHitObject { + public readonly Bindable TypeBindable = new Bindable(); + + /// + /// The that actuates this . + /// + public HitType Type + { + get => TypeBindable.Value; + set => TypeBindable.Value = value; + } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/HitType.cs b/osu.Game.Rulesets.Taiko/Objects/HitType.cs new file mode 100644 index 0000000000..17b3fdbd04 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/HitType.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.Rulesets.Taiko.Objects +{ + /// + /// The type of a . + /// + public enum HitType + { + /// + /// A that can be hit by the centre portion of the drum. + /// + Centre, + + /// + /// A that can be hit by the rim portion of the drum. + /// + Rim + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/RimHit.cs b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs similarity index 58% rename from osu.Game.Rulesets.Taiko/Objects/RimHit.cs rename to osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs index 6f6b089e03..302f940ef4 100644 --- a/osu.Game.Rulesets.Taiko/Objects/RimHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Judgements; + namespace osu.Game.Rulesets.Taiko.Objects { - public class RimHit : Hit + public class IgnoreHit : Hit { + public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index e60984596d..eeae6e79f8 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.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; +using System.Threading; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -9,9 +9,13 @@ using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects { - public class Swell : TaikoHitObject, IHasEndTime + public class Swell : TaikoHitObject, IHasDuration { - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } public double Duration { get; set; } @@ -20,17 +24,15 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public int RequiredHits = 10; - public override bool IsStrong + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - set => throw new NotSupportedException($"{nameof(Swell)} cannot be a strong hitobject."); - } - - protected override void CreateNestedHitObjects() - { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); for (int i = 0; i < RequiredHits; i++) + { + cancellationToken.ThrowIfCancellationRequested(); AddNested(new SwellTick()); + } } public override Judgement CreateJudgement() => new TaikoSwellJudgement(); diff --git a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs index 91f4d3dbe7..bdc0478195 100644 --- a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs @@ -3,13 +3,12 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects { public class SwellTick : TaikoHitObject { - public override Judgement CreateJudgement() => new TaikoSwellTickJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; } diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs index c41727557b..2922010001 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. +using System.Threading; +using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -26,15 +28,21 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public const float DEFAULT_STRONG_SIZE = DEFAULT_SIZE * STRONG_SCALE; + public readonly Bindable IsStrongBindable = new BindableBool(); + /// /// Whether this HitObject is a "strong" type. /// Strong hit objects give more points for hitting the hit object with both keys. /// - public virtual bool IsStrong { get; set; } - - protected override void CreateNestedHitObjects() + public bool IsStrong { - base.CreateNestedHitObjects(); + get => IsStrongBindable.Value; + set => IsStrongBindable.Value = value; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); if (IsStrong) AddNested(new StrongHitObject { StartTime = this.GetEndTime() }); diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs index 48eb33976e..273f4e4105 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs @@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Taiko.Replays { TaikoAction[] actions; - if (hit is CentreHit) + if (hit.Type == HitType.Centre) { actions = h.IsStrong ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre } diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs index c5ebefc397..d2a7329a28 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs @@ -23,12 +23,24 @@ namespace osu.Game.Rulesets.Taiko.Replays Actions.AddRange(actions); } - public void ConvertFrom(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); if (currentFrame.MouseLeft1) Actions.Add(TaikoAction.LeftCentre); if (currentFrame.MouseLeft2) Actions.Add(TaikoAction.RightCentre); } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(TaikoAction.LeftRim)) state |= ReplayButtonState.Right1; + if (Actions.Contains(TaikoAction.RightRim)) state |= ReplayButtonState.Right2; + if (Actions.Contains(TaikoAction.LeftCentre)) state |= ReplayButtonState.Left1; + if (Actions.Contains(TaikoAction.RightCentre)) state |= ReplayButtonState.Left2; + + return new LegacyReplayFrame(Time, null, null, state); + } } } diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json new file mode 100644 index 0000000000..47ca6aef68 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json @@ -0,0 +1,116 @@ +{ + "Mappings": [ + { + "StartTime": 110.0, + "Objects": [ + { + "StartTime": 110.0, + "EndTime": 110.0, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 538.0, + "Objects": [ + { + "StartTime": 538.0, + "EndTime": 538.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 967.0, + "Objects": [ + { + "StartTime": 967.0, + "EndTime": 967.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 1395.0, + "Objects": [ + { + "StartTime": 1395.0, + "EndTime": 1395.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 1824.0, + "Objects": [ + { + "StartTime": 1824.0, + "EndTime": 1824.0, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2252.0, + "Objects": [ + { + "StartTime": 2252.0, + "EndTime": 2252.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2681.0, + "Objects": [ + { + "StartTime": 2681.0, + "EndTime": 2681.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 3110.0, + "Objects": [ + { + "StartTime": 3110.0, + "EndTime": 3110.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/sample-to-type-conversions.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions.osu new file mode 100644 index 0000000000..a3537e7149 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions.osu @@ -0,0 +1,62 @@ +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 0 +SampleSet: Normal +StackLeniency: 0.5 +Mode: 1 +LetterboxInBreaks: 0 +WidescreenStoryboard: 1 + +[Editor] +Bookmarks: 110,13824,54967,82395,109824 +DistanceSpacing: 0.1 +BeatDivisor: 4 +GridSize: 32 +TimelineZoom: 3.099999 + +[Metadata] +Title:test +TitleUnicode:test +Artist:sample conversion +ArtistUnicode:sample conversion +Creator:banchobot +Version:sample test +Source: +Tags: +BeatmapID:0 +BeatmapSetID:-1 + +[Difficulty] +HPDrainRate:6 +CircleSize:2 +OverallDifficulty:6 +ApproachRate:7 +SliderMultiplier:1.4 +SliderTickRate:4 + +[Events] +//Background and Video events +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +110,428.571428571429,4,1,0,100,1,0 + +[HitObjects] +256,192,110,5,0,0:0:0:0: +256,192,538,1,8,0:0:0:0: +256,192,967,1,2,0:0:0:0: +256,192,1395,1,10,0:0:0:0: +256,192,1824,1,4,0:0:0:0: +256,192,2252,1,12,0:0:0:0: +256,192,2681,1,6,0:0:0:0: +256,192,3110,1,14,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json new file mode 100644 index 0000000000..6a6063cb74 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json @@ -0,0 +1,379 @@ +{ + "Mappings": [{ + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2173, + "EndTime": 2173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 4000, + "Objects": [{ + "StartTime": 4000, + "EndTime": 4000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 4173, + "EndTime": 4173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 6000, + "Objects": [{ + "StartTime": 6000, + "EndTime": 6000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6271, + "EndTime": 6271, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6542, + "EndTime": 6542, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 8000, + "Objects": [{ + "StartTime": 8000, + "EndTime": 8000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8026, + "EndTime": 8026, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8053, + "EndTime": 8053, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8080, + "EndTime": 8080, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8107, + "EndTime": 8107, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8133, + "EndTime": 8133, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8160, + "EndTime": 8160, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8187, + "EndTime": 8187, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8214, + "EndTime": 8214, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8241, + "EndTime": 8241, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8267, + "EndTime": 8267, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8294, + "EndTime": 8294, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8321, + "EndTime": 8321, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8348, + "EndTime": 8348, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8374, + "EndTime": 8374, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8401, + "EndTime": 8401, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8428, + "EndTime": 8428, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8455, + "EndTime": 8455, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8482, + "EndTime": 8482, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8508, + "EndTime": 8508, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8535, + "EndTime": 8535, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8562, + "EndTime": 8562, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8589, + "EndTime": 8589, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8615, + "EndTime": 8615, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8642, + "EndTime": 8642, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8669, + "EndTime": 8669, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8696, + "EndTime": 8696, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8723, + "EndTime": 8723, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8749, + "EndTime": 8749, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8776, + "EndTime": 8776, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8803, + "EndTime": 8803, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8830, + "EndTime": 8830, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8857, + "EndTime": 8857, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu new file mode 100644 index 0000000000..4c8fb1fde6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu @@ -0,0 +1,32 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:7 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.2 +SliderMultiplier:2.3 +SliderTickRate:1 + +[TimingPoints] +0,333.333333333333,4,1,0,50,1,0 +2000,-100,4,2,0,80,0,0 + +6000,389.61038961039,4,2,1,60,1,0 + +8000,428.571428571429,4,3,1,65,1,0 +8000,-133.333333333333,4,1,1,45,0,0 + +[HitObjects] +// Should convert. +48,32,2000,6,0,B|168:32,1,120,4|2 +312,68,4000,2,0,B|288:52|256:44|216:52|200:68,1,120,0|8 + +// Should convert. +184,224,6000,2,0,L|336:308,2,160,2|2|0,0:0|0:0|0:0,0:0:0:0: + +// Should convert. +328,36,8000,6,0,L|332:16,32,10.7812504112721,0|0,0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json new file mode 100644 index 0000000000..c3d3c52ebd --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json @@ -0,0 +1,137 @@ +{ + "Mappings": [{ + "StartTime": 0, + "Objects": [{ + "StartTime": 0, + "EndTime": 0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 162, + "EndTime": 162, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 325, + "EndTime": 325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 487, + "EndTime": 487, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 650, + "EndTime": 650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 813, + "EndTime": 813, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 975, + "EndTime": 975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2162, + "EndTime": 2162, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2325, + "EndTime": 2325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2487, + "EndTime": 2487, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2650, + "EndTime": 2650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2813, + "EndTime": 2813, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2975, + "EndTime": 2975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu new file mode 100644 index 0000000000..c1e4c3bbd7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu @@ -0,0 +1,20 @@ +osu file format v6 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:3 +CircleSize:4 +OverallDifficulty:1 +SliderMultiplier:1.2 +SliderTickRate:3 + +[TimingPoints] +0,487.884208814441,4,1,0,60,1,0 +2000,-100,4,1,0,65,0,1 + +[HitObjects] +// Should convert. +376,64,0,6,0,B|256:32|136:64,1,240,6|0 +256,120,2000,6,8,C|264:192|336:192,2,120,8|8|6 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs index edb089dbac..dd3c2289ea 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; @@ -39,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Scoring { base.ApplyBeatmap(beatmap); - hpMultiplier = 1 / (object_count_factor * beatmap.HitObjects.OfType().Count() * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); + hpMultiplier = 1 / (object_count_factor * Math.Max(1, beatmap.HitObjects.OfType().Count()) * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); hpMissMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.0018, 0.0075, 0.0120); } diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 003d40af56..e29ea87d25 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Taiko.Scoring { internal class TaikoScoreProcessor : ScoreProcessor { + protected override double DefaultAccuracyPortion => 0.75; + + protected override double DefaultComboPortion => 0.25; + public override HitWindows CreateHitWindows() => new TaikoHitWindows(); } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.cs new file mode 100644 index 0000000000..7d08a21ab1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyBarLine : Sprite + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Texture = skin.GetTexture("taiko-barline"); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(1, 0.88f); + FillMode = FillMode.Fill; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.cs new file mode 100644 index 0000000000..bfcf268c3d --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyCirclePiece : CompositeDrawable, IHasAccentColour + { + private Drawable backgroundLayer; + + public LegacyCirclePiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, DrawableHitObject drawableHitObject) + { + Drawable getDrawableFor(string lookup) + { + const string normal_hit = "taikohit"; + const string big_hit = "taikobig"; + + string prefix = ((drawableHitObject as DrawableTaikoHitObject)?.HitObject.IsStrong ?? false) ? big_hit : normal_hit; + + return skin.GetAnimation($"{prefix}{lookup}", true, false) ?? + // fallback to regular size if "big" version doesn't exist. + skin.GetAnimation($"{normal_hit}{lookup}", true, false); + } + + // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. + AddInternal(backgroundLayer = getDrawableFor("circle")); + + var foregroundLayer = getDrawableFor("circleoverlay"); + if (foregroundLayer != null) + AddInternal(foregroundLayer); + + // Animations in taiko skins are used in a custom way (>150 combo and animating in time with beat). + // For now just stop at first frame for sanity. + foreach (var c in InternalChildren) + { + (c as IFramedAnimation)?.Stop(); + + c.Anchor = Anchor.Centre; + c.Origin = Anchor.Centre; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateAccentColour(); + } + + protected override void Update() + { + base.Update(); + + // Not all skins (including the default osu-stable) have similar sizes for "hitcircle" and "hitcircleoverlay". + // 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); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (value == accentColour) + return; + + accentColour = value; + if (IsLoaded) + updateAccentColour(); + } + } + + private void updateAccentColour() + { + backgroundLayer.Colour = accentColour; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs new file mode 100644 index 0000000000..8223e3bc01 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyDrumRoll : CompositeDrawable, IHasAccentColour + { + private LegacyCirclePiece headCircle; + + private Sprite body; + + private Sprite end; + + public LegacyDrumRoll() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, OsuColour colours) + { + InternalChildren = new Drawable[] + { + end = new Sprite + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Texture = skin.GetTexture("taiko-roll-end", WrapMode.ClampToEdge, WrapMode.ClampToEdge), + FillMode = FillMode.Fit, + }, + body = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = skin.GetTexture("taiko-roll-middle", WrapMode.ClampToEdge, WrapMode.ClampToEdge), + }, + headCircle = new LegacyCirclePiece + { + RelativeSizeAxes = Axes.Y, + }, + }; + + AccentColour = colours.YellowDark; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateAccentColour(); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (value == accentColour) + return; + + accentColour = value; + if (IsLoaded) + updateAccentColour(); + } + } + + private void updateAccentColour() + { + headCircle.AccentColour = accentColour; + body.Colour = accentColour; + end.Colour = accentColour; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.cs new file mode 100644 index 0000000000..656728f6e4 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.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 osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyHit : LegacyCirclePiece + { + private readonly TaikoSkinComponents component; + + public LegacyHit(TaikoSkinComponents component) + { + this.component = component; + } + + [BackgroundDependencyLoader] + private void load() + { + AccentColour = component == TaikoSkinComponents.CentreHit + ? new Color4(235, 69, 44, 255) + : new Color4(67, 142, 172, 255); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.cs new file mode 100644 index 0000000000..b5ec2e8def --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.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; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyHitExplosion : CompositeDrawable + { + public LegacyHitExplosion(Drawable sprite) + { + InternalChild = sprite; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + AutoSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double animation_time = 120; + + this.FadeInFromZero(animation_time).Then().FadeOut(animation_time * 1.5); + + this.ScaleTo(0.6f) + .Then().ScaleTo(1.1f, animation_time * 0.8) + .Then().ScaleTo(0.9f, animation_time * 0.4) + .Then().ScaleTo(1f, animation_time * 0.2); + + Expire(true); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs new file mode 100644 index 0000000000..81d645e294 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs @@ -0,0 +1,181 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + /// + /// A component of the playfield that captures input and displays input as a drum. + /// + internal class LegacyInputDrum : Container + { + private LegacyHalfDrum left; + private LegacyHalfDrum right; + private Container content; + + public LegacyInputDrum() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Child = content = new Container + { + Size = new Vector2(180, 200), + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("taiko-bar-left") + }, + left = new LegacyHalfDrum(false) + { + Name = "Left Half", + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + right = new LegacyHalfDrum(true) + { + Name = "Right Half", + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopRight, + Scale = new Vector2(-1, 1), + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + } + } + }; + + // this will be used in the future for stable skin alignment. keeping here for reference. + const float taiko_bar_y = 0; + + // stable things + const float ratio = 1.6f; + + // because the right half is flipped, we need to position using width - position to get the true "topleft" origin position + float negativeScaleAdjust = content.Width / ratio; + + if (skin.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value >= 2.1m) + { + left.Centre.Position = new Vector2(0, taiko_bar_y) * ratio; + right.Centre.Position = new Vector2(negativeScaleAdjust - 56, taiko_bar_y) * ratio; + left.Rim.Position = new Vector2(0, taiko_bar_y) * ratio; + right.Rim.Position = new Vector2(negativeScaleAdjust - 56, taiko_bar_y) * ratio; + } + else + { + left.Centre.Position = new Vector2(18, taiko_bar_y + 31) * ratio; + right.Centre.Position = new Vector2(negativeScaleAdjust - 54, taiko_bar_y + 31) * ratio; + left.Rim.Position = new Vector2(8, taiko_bar_y + 23) * ratio; + right.Rim.Position = new Vector2(negativeScaleAdjust - 53, taiko_bar_y + 23) * ratio; + } + } + + protected override void Update() + { + base.Update(); + + // Relying on RelativeSizeAxes.Both + FillMode.Fit doesn't work due to the precise pixel layout requirements. + // This is a bit ugly but makes the non-legacy implementations a lot cleaner to implement. + content.Scale = new Vector2(DrawHeight / content.Size.Y); + } + + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private class LegacyHalfDrum : 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; + + public readonly Sprite Rim; + public readonly Sprite Centre; + + [Resolved] + private DrumSampleMapping sampleMappings { get; set; } + + public LegacyHalfDrum(bool flipped) + { + Masking = true; + + Children = new Drawable[] + { + Rim = new Sprite + { + Scale = new Vector2(-1, 1), + Origin = flipped ? Anchor.TopLeft : Anchor.TopRight, + Alpha = 0, + }, + Centre = new Sprite + { + Alpha = 0, + Origin = flipped ? Anchor.TopRight : Anchor.TopLeft, + } + }; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Rim.Texture = skin.GetTexture(@"taiko-drum-outer"); + Centre.Texture = skin.GetTexture(@"taiko-drum-inner"); + } + + public bool OnPressed(TaikoAction action) + { + Drawable target = null; + var drumSample = sampleMappings.SampleAt(Time.Current); + + if (action == CentreAction) + { + target = Centre; + drumSample.Centre?.Play(); + } + else if (action == RimAction) + { + target = Rim; + drumSample.Rim?.Play(); + } + + if (target != null) + { + const float alpha_amount = 1; + + const float down_time = 80; + const float up_time = 50; + + target.Animate( + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time) + ).Then( + t => t.FadeOut(up_time) + ); + } + + return false; + } + + public void OnReleased(TaikoAction action) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs new file mode 100644 index 0000000000..03813e0a99 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs @@ -0,0 +1,152 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyTaikoScroller : CompositeDrawable + { + public Bindable LastResult = new Bindable(); + + public LegacyTaikoScroller() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(GameplayBeatmap gameplayBeatmap) + { + if (gameplayBeatmap != null) + ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + } + + private bool passing; + + protected override void LoadComplete() + { + base.LoadComplete(); + + LastResult.BindValueChanged(result => + { + var r = result.NewValue; + + // always ignore hitobjects that don't affect combo (drumroll ticks etc.) + if (r?.Judgement.AffectsCombo == false) + return; + + passing = r == null || r.Type > HitResult.Miss; + + foreach (var sprite in InternalChildren.OfType()) + sprite.Passing = passing; + }, true); + } + + protected override void Update() + { + base.Update(); + + // store X before checking wide enough so if we perform layout there is no positional discrepancy. + float currentX = (InternalChildren?.FirstOrDefault()?.X ?? 0) - (float)Clock.ElapsedFrameTime * 0.1f; + + // ensure we have enough sprites + if (!InternalChildren.Any() + || InternalChildren.First().ScreenSpaceDrawQuad.Width * InternalChildren.Count < ScreenSpaceDrawQuad.Width * 2) + AddInternal(new ScrollerSprite { Passing = passing }); + + var first = InternalChildren.First(); + var last = InternalChildren.Last(); + + foreach (var sprite in InternalChildren) + { + // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale. + sprite.X = currentX; + currentX += sprite.DrawWidth; + } + + if (first.ScreenSpaceDrawQuad.TopLeft.X >= ScreenSpaceDrawQuad.TopLeft.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X -= first.DrawWidth; + } + + if (last.ScreenSpaceDrawQuad.TopRight.X <= ScreenSpaceDrawQuad.TopRight.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X += first.DrawWidth; + } + } + + private class ScrollerSprite : CompositeDrawable + { + private Sprite passingSprite; + private Sprite failingSprite; + + private bool passing = true; + + public bool Passing + { + get => passing; + set + { + if (value == passing) + return; + + passing = value; + + if (IsLoaded) + updatePassing(); + } + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + FillMode = FillMode.Fit; + + InternalChildren = new Drawable[] + { + passingSprite = new Sprite { Texture = skin.GetTexture("taiko-slider") }, + failingSprite = new Sprite { Texture = skin.GetTexture("taiko-slider-fail"), Alpha = 0 }, + }; + + updatePassing(); + } + + protected override void Update() + { + base.Update(); + + foreach (var c in InternalChildren) + c.Scale = new Vector2(DrawHeight / c.Height); + } + + private void updatePassing() + { + if (passing) + { + passingSprite.Show(); + failingSprite.FadeOut(200); + } + else + { + failingSprite.FadeIn(200); + passingSprite.Delay(200).FadeOut(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.cs new file mode 100644 index 0000000000..e522fb7c10 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class TaikoLegacyHitTarget : CompositeDrawable + { + private Container content; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + RelativeSizeAxes = Axes.Both; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("approachcircle"), + Scale = new Vector2(0.73f), + Alpha = 0.47f, // eyeballed to match stable + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Sprite + { + Texture = skin.GetTexture("taikobigcircle"), + Scale = new Vector2(0.7f), + Alpha = 0.22f, // eyeballed to match stable + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + }; + } + + protected override void Update() + { + base.Update(); + + // Relying on RelativeSizeAxes.Both + FillMode.Fit doesn't work due to the precise pixel layout requirements. + // This is a bit ugly but makes the non-legacy implementations a lot cleaner to implement. + content.Scale = new Vector2(DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..4bbb6be6b1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.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 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.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class TaikoLegacyPlayfieldBackgroundRight : BeatSyncedContainer + { + private Sprite kiai; + + private bool kiaiDisplayed; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("taiko-bar-right"), + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + }, + kiai = new Sprite + { + Texture = skin.GetTexture("taiko-bar-right-glow"), + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Alpha = 0, + } + }; + } + + 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); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs index 381cd14cd4..23d675cfb0 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs @@ -1,32 +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.Collections.Generic; 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.Rulesets.Taiko.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Skinning { - public class TaikoLegacySkinTransformer : ISkin + public class TaikoLegacySkinTransformer : LegacySkinTransformer { - private readonly ISkinSource source; - public TaikoLegacySkinTransformer(ISkinSource source) + : base(source) { - this.source = source; } - public Drawable GetDrawableComponent(ISkinComponent component) => source.GetDrawableComponent(component); + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (!(component is TaikoSkinComponent taikoComponent)) + return null; - public Texture GetTexture(string componentName) => source.GetTexture(componentName); + switch (taikoComponent.Component) + { + case TaikoSkinComponents.DrumRollBody: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyDrumRoll(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + return null; - public IBindable GetConfig(TLookup lookup) => source.GetConfig(lookup); + case TaikoSkinComponents.InputDrum: + if (GetTexture("taiko-bar-left") != null) + return new LegacyInputDrum(); + + return null; + + case TaikoSkinComponents.CentreHit: + case TaikoSkinComponents.RimHit: + + if (GetTexture("taikohitcircle") != null) + return new LegacyHit(taikoComponent.Component); + + return null; + + case TaikoSkinComponents.DrumRollTick: + return this.GetAnimation("sliderscorepoint", false, false); + + case TaikoSkinComponents.HitTarget: + if (GetTexture("taikobigcircle") != null) + return new TaikoLegacyHitTarget(); + + return null; + + case TaikoSkinComponents.PlayfieldBackgroundRight: + if (GetTexture("taiko-bar-right") != null) + return new TaikoLegacyPlayfieldBackgroundRight(); + + return null; + + case TaikoSkinComponents.PlayfieldBackgroundLeft: + // This is displayed inside LegacyInputDrum. It is required to be there for layout purposes (can be seen on legacy skins). + if (GetTexture("taiko-bar-right") != null) + return Drawable.Empty(); + + return null; + + case TaikoSkinComponents.BarLine: + if (GetTexture("taiko-barline") != null) + return new LegacyBarLine(); + + return null; + + case TaikoSkinComponents.TaikoExplosionGood: + case TaikoSkinComponents.TaikoExplosionGreat: + case TaikoSkinComponents.TaikoExplosionMiss: + + var sprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); + if (sprite != null) + return new LegacyHitExplosion(sprite); + + return null; + + case TaikoSkinComponents.Scroller: + if (GetTexture("taiko-slider") != null) + return new LegacyTaikoScroller(); + + return null; + + case TaikoSkinComponents.Mascot: + if (GetTexture("pippidonclear0") != null) + return new DrawableTaikoMascot(); + + return null; + } + + return Source.GetDrawableComponent(component); + } + + private string getHitName(TaikoSkinComponents component) + { + switch (component) + { + case TaikoSkinComponents.TaikoExplosionMiss: + return "taiko-hit0"; + + case TaikoSkinComponents.TaikoExplosionGood: + return "taiko-hit100"; + + case TaikoSkinComponents.TaikoExplosionGreat: + return "taiko-hit300"; + } + + throw new ArgumentOutOfRangeException(nameof(component), "Invalid result type"); + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + + public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); private class LegacyTaikoSampleInfo : ISampleInfo { diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 536cbdc562..2011842591 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -21,11 +21,18 @@ using osu.Game.Rulesets.Taiko.Difficulty; using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; using System; +using System.Linq; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Skinning; +using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko { + [ExcludeFromDynamicCompile] public class TaikoRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); @@ -36,7 +43,7 @@ namespace osu.Game.Rulesets.Taiko public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); - public override ISkin CreateLegacySkinProvider(ISkinSource source) => new TaikoLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new TaikoLegacySkinTransformer(source); public const string SHORT_NAME = "taiko"; @@ -50,7 +57,7 @@ namespace osu.Game.Rulesets.Taiko new KeyBinding(InputKey.K, TaikoAction.RightRim), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new TaikoModNightcore(); @@ -114,6 +121,7 @@ namespace osu.Game.Rulesets.Taiko case ModType.Conversion: return new Mod[] { + new TaikoModRandom(), new TaikoModDifficultyAdjust(), }; @@ -139,8 +147,12 @@ namespace osu.Game.Rulesets.Taiko public override string ShortName => SHORT_NAME; + public override string PlayingVerb => "Bashing drums"; + public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetTaiko }; + public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this); + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap); public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new TaikoPerformanceCalculator(this, beatmap, score); @@ -148,5 +160,20 @@ namespace osu.Game.Rulesets.Taiko public int LegacyID => 1; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is Hit).ToList()) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + } + }; } } diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs index 04aca534c6..ac4fb51661 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs @@ -5,5 +5,20 @@ namespace osu.Game.Rulesets.Taiko { public enum TaikoSkinComponents { + InputDrum, + CentreHit, + RimHit, + DrumRollBody, + DrumRollTick, + Swell, + HitTarget, + PlayfieldBackgroundLeft, + PlayfieldBackgroundRight, + BarLine, + TaikoExplosionMiss, + TaikoExplosionGood, + TaikoExplosionGreat, + Scroller, + Mascot, } } diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs new file mode 100644 index 0000000000..9943a58e3e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class DefaultHitExplosion : CircularContainer + { + [Resolved] + private DrawableHitObject judgedObject { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + + BorderColour = Color4.White; + BorderThickness = 1; + + Blending = BlendingParameters.Additive; + + Alpha = 0.15f; + Masking = true; + + if (judgedObject.Result.Type == HitResult.Miss) + return; + + bool isRim = (judgedObject.HitObject as Hit)?.Type == HitType.Rim; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = isRim ? colours.BlueDarker : colours.PinkDarker, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.ScaleTo(3f, 1000, Easing.OutQuint); + this.FadeOut(500); + + Expire(true); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs new file mode 100644 index 0000000000..b937beae3c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.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 osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class DrawableTaikoMascot : BeatSyncedContainer + { + public readonly Bindable State; + public readonly Bindable LastResult; + + private readonly Dictionary animations; + private TaikoMascotAnimation currentAnimation; + + private bool lastObjectHit = true; + private bool kiaiMode; + + public DrawableTaikoMascot(TaikoMascotAnimationState startingState = TaikoMascotAnimationState.Idle) + { + Origin = Anchor = Anchor.BottomLeft; + + State = new Bindable(startingState); + LastResult = new Bindable(); + + animations = new Dictionary(); + } + + [BackgroundDependencyLoader(true)] + private void load(TextureStore textures, GameplayBeatmap gameplayBeatmap) + { + InternalChildren = new[] + { + animations[TaikoMascotAnimationState.Idle] = new TaikoMascotAnimation(TaikoMascotAnimationState.Idle), + animations[TaikoMascotAnimationState.Clear] = new TaikoMascotAnimation(TaikoMascotAnimationState.Clear), + animations[TaikoMascotAnimationState.Kiai] = new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai), + animations[TaikoMascotAnimationState.Fail] = new TaikoMascotAnimation(TaikoMascotAnimationState.Fail), + }; + + if (gameplayBeatmap != null) + ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + animations.Values.ForEach(animation => animation.Hide()); + + State.BindValueChanged(mascotStateChanged, true); + LastResult.BindValueChanged(onNewResult); + } + + private void onNewResult(ValueChangedEvent resultChangedEvent) + { + var result = resultChangedEvent.NewValue; + if (result == null) + return; + + // TODO: missing support for clear/fail state transition at end of beatmap gameplay + + if (triggerComboClear(result) || triggerSwellClear(result)) + { + State.Value = TaikoMascotAnimationState.Clear; + // always consider a clear equivalent to a hit to avoid clear -> miss transitions + lastObjectHit = true; + } + + if (!result.Judgement.AffectsCombo) + return; + + lastObjectHit = result.IsHit; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + kiaiMode = effectPoint.KiaiMode; + } + + protected override void Update() + { + base.Update(); + State.Value = getNextState(); + } + + private TaikoMascotAnimationState getNextState() + { + // don't change state if current animation is still playing (and we haven't rewound before it). + // used for clear state - others are manually animated on new beats. + if (currentAnimation?.Completed == false && currentAnimation.DisplayTime <= Time.Current) + return State.Value; + + if (!lastObjectHit) + return TaikoMascotAnimationState.Fail; + + return kiaiMode ? TaikoMascotAnimationState.Kiai : TaikoMascotAnimationState.Idle; + } + + private void mascotStateChanged(ValueChangedEvent state) + { + currentAnimation?.Hide(); + currentAnimation = animations[state.NewValue]; + currentAnimation.Show(); + } + + private bool triggerComboClear(JudgementResult judgementResult) + => (judgementResult.ComboAtJudgement + 1) % 50 == 0 && judgementResult.Judgement.AffectsCombo && judgementResult.IsHit; + + private bool triggerSwellClear(JudgementResult judgementResult) + => judgementResult.Judgement is TaikoSwellJudgement && judgementResult.IsHit; + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 0c7495aa52..e6aacf34dc 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; @@ -16,11 +17,15 @@ using osu.Game.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.UI { public class DrawableTaikoRuleset : DrawableScrollingRuleset { + private SkinnableDrawable scroller; + protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false; @@ -36,6 +41,20 @@ namespace osu.Game.Rulesets.Taiko.UI private void load() { new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar))); + + FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + { + RelativeSizeAxes = Axes.X, + Depth = float.MaxValue + }); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + var playfieldScreen = Playfield.ScreenSpaceDrawQuad; + scroller.Height = ToLocalSpace(playfieldScreen.TopLeft + new Vector2(0, playfieldScreen.Height / 20)).Y; } public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer(); @@ -48,11 +67,8 @@ namespace osu.Game.Rulesets.Taiko.UI { switch (h) { - case CentreHit centreHit: - return new DrawableCentreHit(centreHit); - - case RimHit rimHit: - return new DrawableRimHit(rimHit); + case Hit hit: + return new DrawableHit(hit); case DrumRoll drumRoll: return new DrawableDrumRoll(drumRoll); @@ -65,5 +81,7 @@ namespace osu.Game.Rulesets.Taiko.UI } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new TaikoFramedReplayInputHandler(replay); + + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new TaikoReplayRecorder(replay); } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs new file mode 100644 index 0000000000..fde42bec04 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.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.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class DrumRollHitContainer : ScrollingHitObjectContainer + { + protected override void Update() + { + base.Update(); + + // Remove any auxiliary hit notes that were spawned during a drum roll but subsequently rewound. + for (var i = AliveInternalChildren.Count - 1; i >= 0; i--) + { + var flyingHit = (DrawableFlyingHit)AliveInternalChildren[i]; + if (Time.Current <= flyingHit.HitObject.StartTime) + Remove(flyingHit); + } + } + + protected override void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) + { + base.OnChildLifetimeBoundaryCrossed(e); + + // ensure all old hits are removed on becoming alive (may miss being in the AliveInternalChildren list above). + if (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward) + Remove((DrawableHitObject)e.Child); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index 404960c26f..f0585b9c50 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI { @@ -20,55 +20,49 @@ namespace osu.Game.Rulesets.Taiko.UI { public override bool RemoveWhenNotAlive => true; + [Cached(typeof(DrawableHitObject))] public readonly DrawableHitObject JudgedObject; - private readonly Box innerFill; + private SkinnableDrawable skinnable; - private readonly bool isRim; + public override double LifetimeStart => skinnable.Drawable.LifetimeStart; - public HitExplosion(DrawableHitObject judgedObject, bool isRim) + public override double LifetimeEnd => skinnable.Drawable.LifetimeEnd; + + public HitExplosion(DrawableHitObject judgedObject) { - this.isRim = isRim; - JudgedObject = judgedObject; - Anchor = Anchor.CentreLeft; + Anchor = Anchor.Centre; Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE); RelativePositionAxes = Axes.Both; - - BorderColour = Color4.White; - BorderThickness = 1; - - Alpha = 0.15f; - Masking = true; - - Children = new[] - { - innerFill = new Box - { - RelativeSizeAxes = Axes.Both, - } - }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - innerFill.Colour = isRim ? colours.BlueDarker : colours.PinkDarker; + Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(JudgedObject.Result?.Type ?? HitResult.Great)), _ => new DefaultHitExplosion()); } - protected override void LoadComplete() + private TaikoSkinComponents getComponentName(HitResult resultType) { - base.LoadComplete(); + switch (resultType) + { + case HitResult.Miss: + return TaikoSkinComponents.TaikoExplosionMiss; - this.ScaleTo(3f, 1000, Easing.OutQuint); - this.FadeOut(500); + case HitResult.Good: + return TaikoSkinComponents.TaikoExplosionGood; - Expire(true); + case HitResult.Great: + return TaikoSkinComponents.TaikoExplosionGreat; + } + + throw new ArgumentOutOfRangeException(nameof(resultType), "Invalid result type"); } /// diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 5234ae1f69..06ccd45cb8 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -12,6 +12,8 @@ using osu.Framework.Input.Bindings; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Screens.Play; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI { @@ -22,48 +24,52 @@ namespace osu.Game.Rulesets.Taiko.UI { private const float middle_split = 0.025f; - private readonly ControlPointInfo controlPoints; + [Cached] + private DrumSampleMapping sampleMapping; public InputDrum(ControlPointInfo controlPoints) { - this.controlPoints = controlPoints; + sampleMapping = new DrumSampleMapping(controlPoints); RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fit; } [BackgroundDependencyLoader] private void load() { - var sampleMappings = new DrumSampleMapping(controlPoints); - - Children = new Drawable[] + Child = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container { - new TaikoHalfDrum(false, sampleMappings) + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Scale = new Vector2(0.9f), + Children = new Drawable[] { - 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, sampleMappings) - { - 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 + 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 + } } - }; + }); - AddRangeInternal(sampleMappings.Sounds); + AddRangeInternal(sampleMapping.Sounds); } /// @@ -86,12 +92,11 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly Sprite centre; private readonly Sprite centreHit; - private readonly DrumSampleMapping sampleMappings; + [Resolved] + private DrumSampleMapping sampleMappings { get; set; } - public TaikoHalfDrum(bool flipped, DrumSampleMapping sampleMappings) + public TaikoHalfDrum(bool flipped) { - this.sampleMappings = sampleMappings; - Masking = true; Children = new Drawable[] @@ -141,6 +146,9 @@ namespace osu.Game.Rulesets.Taiko.UI centreHit.Colour = colours.Pink; } + [Resolved(canBeNull: true)] + private GameplayClock gameplayClock { get; set; } + public bool OnPressed(TaikoAction action) { Drawable target = null; @@ -153,14 +161,16 @@ namespace osu.Game.Rulesets.Taiko.UI target = centreHit; back = centre; - drumSample.Centre?.Play(); + if (gameplayClock?.IsSeeking != true) + drumSample.Centre?.Play(); } else if (action == RimAction) { target = rimHit; back = rim; - drumSample.Rim?.Play(); + if (gameplayClock?.IsSeeking != true) + drumSample.Rim?.Play(); } if (target != null) @@ -187,7 +197,9 @@ namespace osu.Game.Rulesets.Taiko.UI return false; } - public bool OnReleased(TaikoAction action) => false; + public void OnReleased(TaikoAction action) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs index e80b463481..067d390894 100644 --- a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs @@ -18,14 +18,12 @@ namespace osu.Game.Rulesets.Taiko.UI public override bool RemoveWhenNotAlive => true; public readonly DrawableHitObject JudgedObject; + private readonly HitType type; - private readonly bool isRim; - - public KiaiHitExplosion(DrawableHitObject judgedObject, bool isRim) + public KiaiHitExplosion(DrawableHitObject judgedObject, HitType type) { - this.isRim = isRim; - JudgedObject = judgedObject; + this.type = type; Anchor = Anchor.CentreLeft; Origin = Anchor.Centre; @@ -33,6 +31,8 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE, 1); + Blending = BlendingParameters.Additive; + Masking = true; Alpha = 0.25f; @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.UI EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = isRim ? colours.BlueDarker : colours.PinkDarker, + Colour = type == HitType.Rim ? colours.BlueDarker : colours.PinkDarker, Radius = 60, }; } diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs new file mode 100644 index 0000000000..2a8890a95d --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class PlayfieldBackgroundLeft : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray1, + RelativeSizeAxes = Axes.Both, + }, + new Box + { + Anchor = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = 10, + Colour = Framework.Graphics.Colour.ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.6f), Color4.Black.Opacity(0)), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..44bfdacf37 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class PlayfieldBackgroundRight : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Name = "Transparent playfield background"; + RelativeSizeAxes = Axes.Both; + Masking = true; + BorderColour = colours.Gray1; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.2f), + Radius = 5, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray0, + Alpha = 0.6f + }, + new Container + { + Name = "Border", + RelativeSizeAxes = Axes.Both, + Masking = true, + MaskingSmoothness = 0, + BorderThickness = 2, + AlwaysPresent = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/HitTarget.cs b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs similarity index 90% rename from osu.Game.Rulesets.Taiko/UI/HitTarget.cs rename to osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs index 2bb208bd1d..caddc8b122 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitTarget.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs @@ -13,15 +13,17 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A component that is displayed at the hit position in the taiko playfield. /// - internal class HitTarget : Container + internal class TaikoHitTarget : Container { /// /// Thickness of all drawn line pieces. /// private const float border_thickness = 2.5f; - public HitTarget() + public TaikoHitTarget() { + RelativeSizeAxes = Axes.Both; + Children = new Drawable[] { new Box @@ -39,8 +41,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, @@ -61,8 +62,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(TaikoHitObject.DEFAULT_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs new file mode 100644 index 0000000000..6f25a5f662 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public sealed class TaikoMascotAnimation : BeatSyncedContainer + { + private readonly TextureAnimation textureAnimation; + + private int currentFrame; + + public double DisplayTime; + + public TaikoMascotAnimation(TaikoMascotAnimationState state) + { + InternalChild = textureAnimation = createTextureAnimation(state).With(animation => + { + animation.Origin = animation.Anchor = Anchor.BottomLeft; + animation.Scale = new Vector2(0.51f); // close enough to stable + }); + + RelativeSizeAxes = Axes.Both; + Origin = Anchor = Anchor.BottomLeft; + + // needs to be always present to prevent the animation clock consuming time spent when not present. + AlwaysPresent = true; + } + + public bool Completed => !textureAnimation.IsPlaying || textureAnimation.PlaybackPosition >= textureAnimation.Duration; + + public override void Show() + { + base.Show(); + DisplayTime = Time.Current; + textureAnimation.Seek(0); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + // assume that if the animation is playing on its own, it's independent from the beat and doesn't need to be touched. + if (textureAnimation.FrameCount == 0 || textureAnimation.IsPlaying) + return; + + textureAnimation.GotoFrame(currentFrame); + currentFrame = (currentFrame + 1) % textureAnimation.FrameCount; + } + + private static TextureAnimation createTextureAnimation(TaikoMascotAnimationState state) + { + switch (state) + { + case TaikoMascotAnimationState.Clear: + return new ClearMascotTextureAnimation(); + + case TaikoMascotAnimationState.Idle: + case TaikoMascotAnimationState.Kiai: + case TaikoMascotAnimationState.Fail: + return new ManualMascotTextureAnimation(state); + + default: + throw new ArgumentOutOfRangeException(nameof(state), $"Mascot animations for state {state} are not supported"); + } + } + + private class ManualMascotTextureAnimation : TextureAnimation + { + private readonly TaikoMascotAnimationState state; + + public ManualMascotTextureAnimation(TaikoMascotAnimationState state) + { + this.state = state; + + IsPlaying = false; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + for (int frameIndex = 0; true; frameIndex++) + { + var texture = getAnimationFrame(skin, state, frameIndex); + + if (texture == null) + break; + + AddFrame(texture); + } + } + } + + private class ClearMascotTextureAnimation : TextureAnimation + { + private const float clear_animation_speed = 1000 / 10f; + + private static readonly int[] clear_animation_sequence = { 0, 1, 2, 3, 4, 5, 6, 5, 6, 5, 4, 3, 2, 1, 0 }; + + public ClearMascotTextureAnimation() + { + DefaultFrameLength = clear_animation_speed; + Loop = false; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + foreach (var frameIndex in clear_animation_sequence) + { + var texture = getAnimationFrame(skin, TaikoMascotAnimationState.Clear, frameIndex); + + if (texture == null) + // as per https://osu.ppy.sh/help/wiki/Skinning/osu!taiko#pippidon + break; + + AddFrame(texture); + } + } + } + + private static Texture getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex) + => skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}"); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs new file mode 100644 index 0000000000..02bf245b7b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Taiko.UI +{ + public enum TaikoMascotAnimationState + { + Idle, + Clear, + Kiai, + Fail + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index a10f70a344..dabdfe6f44 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -3,207 +3,162 @@ 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.Effects; -using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { public class TaikoPlayfield : ScrollingPlayfield { + private readonly ControlPointInfo controlPoints; + /// /// Default height of a when inside a . /// public const float DEFAULT_HEIGHT = 178; - /// - /// The offset from which the center of the hit target lies at. - /// - public const float HIT_TARGET_OFFSET = 100; + private Container hitExplosionContainer; + private Container kiaiExplosionContainer; + private JudgementContainer judgementContainer; + private ScrollingHitObjectContainer drumRollHitContainer; + internal Drawable HitTarget; + private SkinnableDrawable mascot; - /// - /// The size of the left area of the playfield. This area contains the input drum. - /// - private const float left_area_size = 240; + private ProxyContainer topLevelHitContainer; + private ProxyContainer barlineContainer; + private Container rightArea; + private Container leftArea; - private readonly Container hitExplosionContainer; - private readonly Container kiaiExplosionContainer; - private readonly JudgementContainer judgementContainer; - internal readonly HitTarget HitTarget; - - private readonly ProxyContainer topLevelHitContainer; - private readonly ProxyContainer barlineContainer; - - private readonly Container overlayBackgroundContainer; - private readonly Container backgroundContainer; - - private readonly Box overlayBackground; - private readonly Box background; + private Container hitTargetOffsetContent; public TaikoPlayfield(ControlPointInfo controlPoints) { - InternalChildren = new Drawable[] + this.controlPoints = controlPoints; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new[] { - backgroundContainer = new Container - { - Name = "Transparent playfield background", - RelativeSizeAxes = Axes.Both, - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.2f), - Radius = 5, - }, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.6f - }, - } - }, - new Container + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()), + rightArea = new Container { Name = "Right area", RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = left_area_size }, + RelativePositionAxes = Axes.Both, Children = new Drawable[] { new Container { Name = "Masked elements before hit objects", RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Masking = true, - Children = new Drawable[] + FillMode = FillMode.Fit, + Children = new[] { hitExplosionContainer = new Container { RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Blending = BlendingParameters.Additive, }, - HitTarget = new HitTarget + HitTarget = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.HitTarget), _ => new TaikoHitTarget()) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit } } }, - barlineContainer = new ProxyContainer + hitTargetOffsetContent = new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET } - }, - new Container - { - Name = "Hit objects", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Masking = true, - Child = HitObjectContainer - }, - kiaiExplosionContainer = new Container - { - Name = "Kiai hit explosions", - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Margin = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Blending = BlendingParameters.Additive - }, - judgementContainer = new JudgementContainer - { - Name = "Judgements", - RelativeSizeAxes = Axes.Y, - Margin = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Blending = BlendingParameters.Additive + Children = new Drawable[] + { + barlineContainer = new ProxyContainer + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Name = "Hit objects", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + HitObjectContainer, + drumRollHitContainer = new DrumRollHitContainer() + } + }, + kiaiExplosionContainer = new Container + { + Name = "Kiai hit explosions", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + }, + judgementContainer = new JudgementContainer + { + Name = "Judgements", + RelativeSizeAxes = Axes.Y, + }, + } }, } }, - overlayBackgroundContainer = new Container + leftArea = new Container { Name = "Left overlay", - RelativeSizeAxes = Axes.Y, - Size = new Vector2(left_area_size, 1), + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + BorderColour = colours.Gray0, Children = new Drawable[] { - overlayBackground = new Box - { - RelativeSizeAxes = Axes.Both, - }, + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), new InputDrum(controlPoints) { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Scale = new Vector2(0.9f), - Margin = new MarginPadding { Right = 20 } - }, - new Box - { - Anchor = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 10, - Colour = Framework.Graphics.Colour.ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.6f), Color4.Black.Opacity(0)), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, }, } }, - new Container + mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty()) { - Name = "Border", - RelativeSizeAxes = Axes.Both, - Masking = true, - MaskingSmoothness = 0, - BorderThickness = 2, - AlwaysPresent = true, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } + 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(), }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + protected override void Update() { - overlayBackgroundContainer.BorderColour = colours.Gray0; - overlayBackground.Colour = colours.Gray1; + base.Update(); - backgroundContainer.BorderColour = colours.Gray1; - background.Colour = colours.Gray0; + // 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 }; + + mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT); } public override void Add(DrawableHitObject h) { h.OnNewResult += OnNewResult; - base.Add(h); switch (h) @@ -222,7 +177,6 @@ namespace osu.Game.Rulesets.Taiko.UI { if (!DisplayJudgements.Value) return; - if (!judgedObject.DisplayResult) return; @@ -233,6 +187,15 @@ namespace osu.Game.Rulesets.Taiko.UI hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).MainObject)?.VisualiseSecondHit(); break; + case TaikoDrumRollTickJudgement _: + if (!result.IsHit) + break; + + var drawableTick = (DrawableDrumRollTick)judgedObject; + + addDrumRollHit(drawableTick); + break; + default: judgementContainer.Add(new DrawableTaikoJudgement(result, judgedObject) { @@ -245,17 +208,23 @@ namespace osu.Game.Rulesets.Taiko.UI if (!result.IsHit) break; - bool isRim = judgedObject.HitObject is RimHit; - - hitExplosionContainer.Add(new HitExplosion(judgedObject, isRim)); - - if (judgedObject.HitObject.Kiai) - kiaiExplosionContainer.Add(new KiaiHitExplosion(judgedObject, isRim)); + var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; + addExplosion(judgedObject, type); break; } } + private void addDrumRollHit(DrawableDrumRollTick drawableTick) => + drumRollHitContainer.Add(new DrawableFlyingHit(drawableTick)); + + private void addExplosion(DrawableHitObject drawableObject, HitType type) + { + hitExplosionContainer.Add(new HitExplosion(drawableObject)); + if (drawableObject.HitObject.Kiai) + kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); + } + private class ProxyContainer : LifetimeManagementContainer { public new MarginPadding Padding diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 980f5ea340..1041456020 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -13,18 +13,16 @@ namespace osu.Game.Rulesets.Taiko.UI private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; private const float default_aspect = 16f / 9f; - public TaikoPlayfieldAdjustmentContainer() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - } - protected override void Update() { base.Update(); float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; Size = new Vector2(1, default_relative_height * aspectAdjust); + + // Position the taiko playfield exactly one playfield from the top of the screen. + RelativePositionAxes = Axes.Y; + Y = Size.Y; } } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs new file mode 100644 index 0000000000..1859dabf03 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class TaikoReplayRecorder : ReplayRecorder + { + public TaikoReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => + new TaikoReplayFrame(Time.Current, actions.ToArray()); + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 33f484a9aa..dab923d75b 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -241,6 +241,11 @@ namespace osu.Game.Tests.Beatmaps.Formats { var controlPoints = decoder.Decode(stream).ControlPointInfo; + Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(4)); + Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.SamplePoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.DifficultyPointAt(500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); Assert.That(controlPoints.DifficultyPointAt(1500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); Assert.That(controlPoints.DifficultyPointAt(2500).SpeedMultiplier, Is.EqualTo(0.75).Within(0.1)); @@ -360,7 +365,7 @@ namespace osu.Game.Tests.Beatmaps.Formats { var hitObjects = decoder.Decode(stream).HitObjects; - var curveData = hitObjects[0] as IHasCurve; + var curveData = hitObjects[0] as IHasPathWithRepeats; var positionData = hitObjects[0] as IHasPosition; Assert.IsNotNull(positionData); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index f2b3a16f68..30331e98d2 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.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. +using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using NUnit.Framework; +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.IO.Serialization; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Beatmaps.Formats @@ -16,39 +26,87 @@ namespace osu.Game.Tests.Beatmaps.Formats [TestFixture] public class LegacyBeatmapEncoderTest { - private const string normal = "Soleily - Renatus (Gamu) [Insane].osu"; - private static IEnumerable allBeatmaps => TestResources.GetStore().GetAvailableResources().Where(res => res.EndsWith(".osu")); [TestCaseSource(nameof(allBeatmaps))] - public void TestDecodeEncodedBeatmap(string name) + public void TestEncodeDecodeStability(string name) { - var decoded = decode(normal, out var encoded); + var decoded = decodeFromLegacy(TestResources.GetStore().GetStream(name)); + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded)); - Assert.That(decoded.HitObjects.Count, Is.EqualTo(encoded.HitObjects.Count)); - Assert.That(encoded.Serialize(), Is.EqualTo(decoded.Serialize())); + sort(decoded); + sort(decodedAfterEncode); + + Assert.That(decodedAfterEncode.Serialize(), Is.EqualTo(decoded.Serialize())); } - private Beatmap decode(string filename, out Beatmap encoded) + private void sort(IBeatmap beatmap) { - using (var stream = TestResources.OpenResource(filename)) - using (var sr = new LineBufferedReader(stream)) + // Sort control points to ensure a sane ordering, as they may be parsed in different orders. This works because each group contains only uniquely-typed control points. + foreach (var g in beatmap.ControlPointInfo.Groups) { - var legacyDecoded = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(sr); - - using (var ms = new MemoryStream()) - using (var sw = new StreamWriter(ms)) - using (var sr2 = new LineBufferedReader(ms)) - { - new LegacyBeatmapEncoder(legacyDecoded).Encode(sw); - sw.Flush(); - - ms.Position = 0; - - encoded = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(sr2); - return legacyDecoded; - } + ArrayList.Adapter((IList)g.ControlPoints).Sort( + Comparer.Create((c1, c2) => string.Compare(c1.GetType().ToString(), c2.GetType().ToString(), StringComparison.Ordinal))); } } + + private IBeatmap decodeFromLegacy(Stream stream) + { + using (var reader = new LineBufferedReader(stream)) + return convert(new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader)); + } + + private Stream encodeToLegacy(IBeatmap beatmap) + { + var stream = new MemoryStream(); + + using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmap).Encode(writer); + + stream.Position = 0; + + return stream; + } + + private IBeatmap convert(IBeatmap beatmap) + { + switch (beatmap.BeatmapInfo.RulesetID) + { + case 0: + beatmap.BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + break; + + case 1: + beatmap.BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + break; + + case 2: + beatmap.BeatmapInfo.Ruleset = new CatchRuleset().RulesetInfo; + break; + + case 3: + beatmap.BeatmapInfo.Ruleset = new ManiaRuleset().RulesetInfo; + break; + } + + return new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset); + } + + private class TestWorkingBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public TestWorkingBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetTrack() => throw new NotImplementedException(); + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 96ff6b81e3..9ebedb3c80 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -26,36 +26,43 @@ namespace osu.Game.Tests.Beatmaps.Formats var storyboard = decoder.Decode(stream); Assert.IsTrue(storyboard.HasDrawable); - Assert.AreEqual(4, storyboard.Layers.Count()); + Assert.AreEqual(6, storyboard.Layers.Count()); StoryboardLayer background = storyboard.Layers.FirstOrDefault(l => l.Depth == 3); Assert.IsNotNull(background); Assert.AreEqual(16, background.Elements.Count); - Assert.IsTrue(background.EnabledWhenFailing); - Assert.IsTrue(background.EnabledWhenPassing); + Assert.IsTrue(background.VisibleWhenFailing); + Assert.IsTrue(background.VisibleWhenPassing); Assert.AreEqual("Background", background.Name); StoryboardLayer fail = storyboard.Layers.FirstOrDefault(l => l.Depth == 2); Assert.IsNotNull(fail); Assert.AreEqual(0, fail.Elements.Count); - Assert.IsTrue(fail.EnabledWhenFailing); - Assert.IsFalse(fail.EnabledWhenPassing); + Assert.IsTrue(fail.VisibleWhenFailing); + Assert.IsFalse(fail.VisibleWhenPassing); Assert.AreEqual("Fail", fail.Name); StoryboardLayer pass = storyboard.Layers.FirstOrDefault(l => l.Depth == 1); Assert.IsNotNull(pass); Assert.AreEqual(0, pass.Elements.Count); - Assert.IsFalse(pass.EnabledWhenFailing); - Assert.IsTrue(pass.EnabledWhenPassing); + Assert.IsFalse(pass.VisibleWhenFailing); + Assert.IsTrue(pass.VisibleWhenPassing); Assert.AreEqual("Pass", pass.Name); StoryboardLayer foreground = storyboard.Layers.FirstOrDefault(l => l.Depth == 0); Assert.IsNotNull(foreground); Assert.AreEqual(151, foreground.Elements.Count); - Assert.IsTrue(foreground.EnabledWhenFailing); - Assert.IsTrue(foreground.EnabledWhenPassing); + Assert.IsTrue(foreground.VisibleWhenFailing); + Assert.IsTrue(foreground.VisibleWhenPassing); Assert.AreEqual("Foreground", foreground.Name); + StoryboardLayer overlay = storyboard.Layers.FirstOrDefault(l => l.Depth == int.MinValue); + Assert.IsNotNull(overlay); + Assert.IsEmpty(overlay.Elements); + Assert.IsTrue(overlay.VisibleWhenFailing); + Assert.IsTrue(overlay.VisibleWhenPassing); + Assert.AreEqual("Overlay", overlay.Name); + int spriteCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSprite)); int animationCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardAnimation)); int sampleCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSampleInfo)); @@ -99,7 +106,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(123456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); + Assert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index 63346b8c9d..b4c78ce273 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -95,7 +95,7 @@ namespace osu.Game.Tests.Beatmaps.Formats { var beatmap = decodeAsJson(normal); - var curveData = beatmap.HitObjects[0] as IHasCurve; + var curveData = beatmap.HitObjects[0] as IHasPathWithRepeats; var positionData = beatmap.HitObjects[0] as IHasPosition; Assert.IsNotNull(positionData); @@ -127,6 +127,31 @@ namespace osu.Game.Tests.Beatmaps.Formats .Assert(); } + [Test] + public void TestGetJsonDecoder() + { + Decoder decoder; + + using (var stream = TestResources.OpenResource(normal)) + using (var sr = new LineBufferedReader(stream)) + { + var legacyDecoded = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(sr); + + using (var memStream = new MemoryStream()) + using (var memWriter = new StreamWriter(memStream)) + using (var memReader = new LineBufferedReader(memStream)) + { + memWriter.Write(legacyDecoded.Serialize()); + memWriter.Flush(); + + memStream.Position = 0; + decoder = Decoder.GetDecoder(memReader); + } + } + + Assert.IsInstanceOf(typeof(JsonBeatmapDecoder), decoder); + } + /// /// Reads a .osu file first with a , serializes the resulting to JSON /// and then deserializes the result back into a through an . diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index 4766411cbd..0151678db3 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -11,14 +11,17 @@ using NUnit.Framework; using osu.Framework.Platform; using osu.Game.IPC; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.IO; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Resources; using SharpCompress.Archives; using SharpCompress.Archives.Zip; using SharpCompress.Common; using SharpCompress.Writers.Zip; +using FileInfo = System.IO.FileInfo; namespace osu.Game.Tests.Beatmaps.IO { @@ -28,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWhenClosed() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWhenClosed))) { try @@ -45,7 +48,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenDelete() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenDelete))) { try @@ -66,7 +69,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenImport() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImport))) { try @@ -90,10 +93,170 @@ namespace osu.Game.Tests.Beatmaps.IO } } + [Test] + public async Task TestImportThenImportWithReZip() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithReZip))) + { + try + { + var osu = loadOsu(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + string hashBefore = hashFile(temp); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + // zip files differ because different compression or encoder. + Assert.AreNotEqual(hashBefore, hashFile(temp)); + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // but contents doesn't, so existing should still be used. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + private string hashFile(string filename) + { + using (var s = File.OpenRead(filename)) + return s.ComputeMD5Hash(); + } + + [Test] + public async Task TestImportThenImportWithChangedFile() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithChangedFile))) + { + try + { + var osu = loadOsu(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to non-hashed file + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.mp3").First()).AppendText()) + await sw.WriteLineAsync("text"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportThenImportWithDifferentFilename() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithDifferentFilename))) + { + try + { + var osu = loadOsu(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // change filename + var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); + firstFile.MoveTo(Path.Combine(firstFile.DirectoryName, $"{firstFile.Name}-changed{firstFile.Extension}")); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + [Test] public async Task TestImportCorruptThenImport() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportCorruptThenImport))) { try @@ -135,7 +298,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestRollbackOnFailure() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestRollbackOnFailure))) { try @@ -153,8 +316,8 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); // ReSharper disable once AccessToModifiedClosure - manager.ItemAdded += _ => Interlocked.Increment(ref itemAddRemoveFireCount); - manager.ItemRemoved += _ => Interlocked.Increment(ref itemAddRemoveFireCount); + manager.ItemUpdated.BindValueChanged(_ => Interlocked.Increment(ref itemAddRemoveFireCount)); + manager.ItemRemoved.BindValueChanged(_ => Interlocked.Increment(ref itemAddRemoveFireCount)); var imported = await LoadOszIntoOsu(osu); @@ -163,7 +326,7 @@ namespace osu.Game.Tests.Beatmaps.IO imported.Hash += "-changed"; manager.Update(imported); - Assert.AreEqual(0, itemAddRemoveFireCount -= 2); + Assert.AreEqual(0, itemAddRemoveFireCount -= 1); checkBeatmapSetCount(osu, 1); checkBeatmapCount(osu, 12); @@ -172,7 +335,7 @@ namespace osu.Game.Tests.Beatmaps.IO var breakTemp = TestResources.GetTestBeatmapForImport(); MemoryStream brokenOsu = new MemoryStream(); - MemoryStream brokenOsz = new MemoryStream(File.ReadAllBytes(breakTemp)); + MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(breakTemp)); File.Delete(breakTemp); @@ -209,41 +372,10 @@ namespace osu.Game.Tests.Beatmaps.IO } } - [Test] - public async Task TestImportThenImportDifferentHash() - { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportDifferentHash))) - { - try - { - var osu = loadOsu(host); - var manager = osu.Dependencies.Get(); - - var imported = await LoadOszIntoOsu(osu); - - imported.Hash += "-changed"; - manager.Update(imported); - - var importedSecondTime = await LoadOszIntoOsu(osu); - - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID < importedSecondTime.Beatmaps.First().ID); - - // only one beatmap will exist as the online set ID matched, causing purging of the first import. - checkBeatmapSetCount(osu, 1); - } - finally - { - host.Exit(); - } - } - } - [Test] public async Task TestImportThenDeleteThenImport() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenDeleteThenImport))) { try @@ -271,7 +403,7 @@ namespace osu.Game.Tests.Beatmaps.IO [TestCase(false)] public async Task TestImportThenDeleteThenImportWithOnlineIDMismatch(bool set) { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(TestImportThenDeleteThenImportWithOnlineIDMismatch)}-{set}")) { try @@ -305,7 +437,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWithDuplicateBeatmapIDs() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWithDuplicateBeatmapIDs))) { try @@ -414,7 +546,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWithDuplicateHashes() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportNestedStructure))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWithDuplicateHashes))) { try { @@ -519,7 +651,7 @@ namespace osu.Game.Tests.Beatmaps.IO using (var resourceForkFile = File.CreateText(resourceForkFilePath)) { - resourceForkFile.WriteLine("adding content so that it's not empty"); + await resourceForkFile.WriteLineAsync("adding content so that it's not empty"); } try @@ -552,6 +684,78 @@ namespace osu.Game.Tests.Beatmaps.IO } } + [Test] + public async Task TestUpdateBeatmapInfo() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUpdateBeatmapInfo))) + { + try + { + var osu = loadOsu(host); + var manager = osu.Dependencies.Get(); + + var temp = TestResources.GetTestBeatmapForImport(); + await osu.Dependencies.Get().Import(temp); + + // Update via the beatmap, not the beatmap info, to ensure correct linking + BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; + Beatmap beatmapToUpdate = (Beatmap)manager.GetWorkingBeatmap(setToUpdate.Beatmaps.First(b => b.RulesetID == 0)).Beatmap; + beatmapToUpdate.BeatmapInfo.Version = "updated"; + + manager.Update(setToUpdate); + + BeatmapInfo updatedInfo = manager.QueryBeatmap(b => b.ID == beatmapToUpdate.BeatmapInfo.ID); + Assert.That(updatedInfo.Version, Is.EqualTo("updated")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestUpdateBeatmapFile() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUpdateBeatmapFile))) + { + try + { + var osu = loadOsu(host); + var manager = osu.Dependencies.Get(); + + var temp = TestResources.GetTestBeatmapForImport(); + await osu.Dependencies.Get().Import(temp); + + BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; + + var beatmapInfo = setToUpdate.Beatmaps.First(b => b.RulesetID == 0); + Beatmap beatmapToUpdate = (Beatmap)manager.GetWorkingBeatmap(setToUpdate.Beatmaps.First(b => b.RulesetID == 0)).Beatmap; + BeatmapSetFileInfo fileToUpdate = setToUpdate.Files.First(f => beatmapToUpdate.BeatmapInfo.Path.Contains(f.Filename)); + + string oldMd5Hash = beatmapToUpdate.BeatmapInfo.MD5Hash; + + beatmapToUpdate.HitObjects.Clear(); + beatmapToUpdate.HitObjects.Add(new HitCircle { StartTime = 5000 }); + + manager.Save(beatmapInfo, beatmapToUpdate); + + // Check that the old file reference has been removed + Assert.That(manager.QueryBeatmapSet(s => s.ID == setToUpdate.ID).Files.All(f => f.ID != fileToUpdate.ID)); + + // Check that the new file is referenced correctly by attempting a retrieval + Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(manager.QueryBeatmap(b => b.ID == beatmapToUpdate.BeatmapInfo.ID)).Beatmap; + Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(1)); + Assert.That(updatedBeatmap.HitObjects[0].StartTime, Is.EqualTo(5000)); + Assert.That(updatedBeatmap.BeatmapInfo.MD5Hash, Is.Not.EqualTo(oldMd5Hash)); + } + finally + { + host.Exit(); + } + } + } + public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) { var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); @@ -615,12 +819,12 @@ namespace osu.Game.Tests.Beatmaps.IO waitForOrAssert(() => (resultSets = store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526)).Any(), @"BeatmapSet did not import to the database in allocated time.", timeout); - //ensure we were stored to beatmap database backing... + // ensure we were stored to beatmap database backing... Assert.IsTrue(resultSets.Count() == 1, $@"Incorrect result count found ({resultSets.Count()} but should be 1)."); IEnumerable queryBeatmaps() => store.QueryBeatmaps(s => s.BeatmapSet.OnlineBeatmapSetID == 241526 && s.BaseDifficultyID > 0); IEnumerable queryBeatmapSets() => store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526); - //if we don't re-check here, the set will be inserted but the beatmaps won't be present yet. + // if we don't re-check here, the set will be inserted but the beatmaps won't be present yet. waitForOrAssert(() => queryBeatmaps().Count() == 12, @"Beatmaps did not import to the database in allocated time", timeout); waitForOrAssert(() => queryBeatmapSets().Count() == 1, diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index 9fba0f1668..6c8133660f 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestSingleSpan() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null, default).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestRepeat() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null, default).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestNonEvenTicks() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null, default).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -85,7 +85,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestLegacyLastTickOffset() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100, default).ToArray(); Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); Assert.That(events[2].Time, Is.EqualTo(900)); @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps const double velocity = 5; const double min_distance = velocity * 10; - var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0, default).ToArray(); Assert.Multiple(() => { diff --git a/osu.Game.Tests/Beatmaps/EditorBeatmapTest.cs b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs similarity index 56% rename from osu.Game.Tests/Beatmaps/EditorBeatmapTest.cs rename to osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs index 12d729d09f..b7b48ec06a 100644 --- a/osu.Game.Tests/Beatmaps/EditorBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs @@ -1,18 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; -using Microsoft.EntityFrameworkCore.Internal; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; namespace osu.Game.Tests.Beatmaps { - [TestFixture] - public class EditorBeatmapTest + [HeadlessTest] + public class TestSceneEditorBeatmap : EditorClockTestScene { /// /// Tests that the addition event is correctly invoked after a hitobject is added. @@ -55,13 +58,19 @@ namespace osu.Game.Tests.Beatmaps public void TestInitialHitObjectStartTimeChangeEvent() { var hitCircle = new HitCircle(); - var editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); HitObject changedObject = null; - editorBeatmap.StartTimeChanged += h => changedObject = h; - hitCircle.StartTime = 1000; - Assert.That(changedObject, Is.EqualTo(hitCircle)); + AddStep("add beatmap", () => + { + EditorBeatmap editorBeatmap; + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); + editorBeatmap.HitObjectUpdated += h => changedObject = h; + }); + + AddStep("change start time", () => hitCircle.StartTime = 1000); + AddAssert("received change event", () => changedObject == hitCircle); } /// @@ -71,18 +80,22 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestAddedHitObjectStartTimeChangeEvent() { - var editorBeatmap = new EditorBeatmap(new OsuBeatmap()); - + EditorBeatmap editorBeatmap = null; HitObject changedObject = null; - editorBeatmap.StartTimeChanged += h => changedObject = h; + + AddStep("add beatmap", () => + { + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.HitObjectUpdated += h => changedObject = h; + }); var hitCircle = new HitCircle(); - editorBeatmap.Add(hitCircle); - Assert.That(changedObject, Is.Null); + AddStep("add object", () => editorBeatmap.Add(hitCircle)); + AddAssert("event not received", () => changedObject == null); - hitCircle.StartTime = 1000; - Assert.That(changedObject, Is.EqualTo(hitCircle)); + AddStep("change start time", () => hitCircle.StartTime = 1000); + AddAssert("event received", () => changedObject == hitCircle); } /// @@ -95,7 +108,7 @@ namespace osu.Game.Tests.Beatmaps var editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); HitObject changedObject = null; - editorBeatmap.StartTimeChanged += h => changedObject = h; + editorBeatmap.HitObjectUpdated += h => changedObject = h; editorBeatmap.Remove(hitCircle); Assert.That(changedObject, Is.Null); @@ -124,7 +137,7 @@ namespace osu.Game.Tests.Beatmaps var hitCircle = new HitCircle { StartTime = 1000 }; editorBeatmap.Add(hitCircle); Assert.That(editorBeatmap.HitObjects.Count(h => h == hitCircle), Is.EqualTo(1)); - Assert.That(editorBeatmap.HitObjects.IndexOf(hitCircle), Is.EqualTo(3)); + Assert.That(Array.IndexOf(editorBeatmap.HitObjects.ToArray(), hitCircle), Is.EqualTo(3)); } /// @@ -148,7 +161,71 @@ namespace osu.Game.Tests.Beatmaps hitCircle.StartTime = 0; Assert.That(editorBeatmap.HitObjects.Count(h => h == hitCircle), Is.EqualTo(1)); - Assert.That(editorBeatmap.HitObjects.IndexOf(hitCircle), Is.EqualTo(1)); + Assert.That(Array.IndexOf(editorBeatmap.HitObjects.ToArray(), hitCircle), Is.EqualTo(1)); + } + + /// + /// Tests that multiple hitobjects are updated simultaneously. + /// + [Test] + public void TestMultipleHitObjectUpdate() + { + var updatedObjects = new List(); + var allHitObjects = new List(); + EditorBeatmap editorBeatmap = null; + + AddStep("add beatmap", () => + { + updatedObjects.Clear(); + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + + for (int i = 0; i < 10; i++) + { + var h = new HitCircle(); + editorBeatmap.Add(h); + allHitObjects.Add(h); + } + }); + + AddStep("change all start times", () => + { + editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + + for (int i = 0; i < 10; i++) + allHitObjects[i].StartTime += 10; + }); + + // Distinct ensures that all hitobjects have been updated once, debounce is tested below. + AddAssert("all hitobjects updated", () => updatedObjects.Distinct().Count() == 10); + } + + /// + /// Tests that hitobject updates are debounced when they happen too soon. + /// + [Test] + public void TestDebouncedUpdate() + { + var updatedObjects = new List(); + EditorBeatmap editorBeatmap = null; + + AddStep("add beatmap", () => + { + updatedObjects.Clear(); + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.Add(new HitCircle()); + }); + + AddStep("change start time twice", () => + { + editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + + editorBeatmap.HitObjects[0].StartTime = 10; + editorBeatmap.HitObjects[0].StartTime = 20; + }); + + AddAssert("only updated once", () => updatedObjects.Count == 1); } } } diff --git a/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs new file mode 100644 index 0000000000..c477bbd9cf --- /dev/null +++ b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class ToStringFormattingTest + { + [Test] + public void TestArtistTitle() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title" + } + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title")); + } + + [Test] + public void TestArtistTitleCreator() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title", + Author = new User { Username = "creator" } + } + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title (creator)")); + } + + [Test] + public void TestArtistTitleCreatorDifficulty() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title", + Author = new User { Username = "creator" } + }, + Version = "difficulty" + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title (creator) [difficulty]")); + } + } +} diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index fbb0416c45..600c820ce1 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -428,22 +428,27 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(5, result.Links.Count); Link f = result.Links.Find(l => l.Url == "https://osu.ppy.sh/wiki/wiki links"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(44, f.Index); Assert.AreEqual(10, f.Length); f = result.Links.Find(l => l.Url == "http://www.simple-test.com"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(10, f.Index); Assert.AreEqual(11, f.Length); f = result.Links.Find(l => l.Url == "http://google.com"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(97, f.Index); Assert.AreEqual(4, f.Length); f = result.Links.Find(l => l.Url == "https://osu.ppy.sh"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(78, f.Index); Assert.AreEqual(18, f.Length); f = result.Links.Find(l => l.Url == "\uD83D\uDE12"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(101, f.Index); Assert.AreEqual(3, f.Length); } diff --git a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs new file mode 100644 index 0000000000..feda1ae0e9 --- /dev/null +++ b/osu.Game.Tests/Editing/EditorChangeHandlerTest.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Screens.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class EditorChangeHandlerTest + { + [Test] + public void TestSaveRestoreState() + { + var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap())); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + handler.SaveState(); + + 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); + } + + [Test] + public void TestMaxStatesSaved() + { + var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap())); + + Assert.That(handler.CanUndo.Value, Is.False); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + handler.SaveState(); + + Assert.That(handler.CanUndo.Value, Is.True); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + { + Assert.That(handler.CanUndo.Value, Is.True); + handler.RestoreState(-1); + } + + Assert.That(handler.CanUndo.Value, Is.False); + } + + [Test] + public void TestMaxStatesExceeded() + { + var handler = new EditorChangeHandler(new EditorBeatmap(new Beatmap())); + + Assert.That(handler.CanUndo.Value, Is.False); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES * 2; i++) + handler.SaveState(); + + Assert.That(handler.CanUndo.Value, Is.True); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + { + Assert.That(handler.CanUndo.Value, Is.True); + handler.RestoreState(-1); + } + + Assert.That(handler.CanUndo.Value, Is.False); + } + } +} diff --git a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs new file mode 100644 index 0000000000..ff17f23d50 --- /dev/null +++ b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs @@ -0,0 +1,367 @@ +// 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 NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class LegacyEditorBeatmapPatcherTest + { + private LegacyEditorBeatmapPatcher patcher; + private EditorBeatmap current; + + [SetUp] + public void Setup() + { + patcher = new LegacyEditorBeatmapPatcher(current = new EditorBeatmap(new OsuBeatmap + { + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo + } + })); + } + + [Test] + public void TestAddHitObject() + { + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 1000 } + } + }; + + runTest(patch); + } + + [Test] + public void TestInsertHitObject() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 2000 }, + (OsuHitObject)current.HitObjects[1], + } + }; + + runTest(patch); + } + + [Test] + public void TestDeleteHitObject() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeStartTime() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500 }, + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSample() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 2000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSliderPath() + { + current.AddRange(new OsuHitObject[] + { + new HitCircle { StartTime = 1000 }, + new Slider + { + StartTime = 2000, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(Vector2.One), + new PathControlPoint(new Vector2(2), PathType.Bezier), + new PathControlPoint(new Vector2(3)), + }, 50) + }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new Slider + { + StartTime = 2000, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.Bezier), + new PathControlPoint(new Vector2(4)), + new PathControlPoint(new Vector2(5)), + }, 100) + }, + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestAddMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500 }, + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 1500 }, + (OsuHitObject)current.HitObjects[1], + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + (OsuHitObject)current.HitObjects[2], + new HitCircle { StartTime = 3500 }, + } + }; + + runTest(patch); + } + + [Test] + public void TestDeleteMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[3], + (OsuHitObject)current.HitObjects[6], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSamplesOfMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 1000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + (OsuHitObject)current.HitObjects[2], + (OsuHitObject)current.HitObjects[3], + new HitCircle { StartTime = 2250, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE } } }, + (OsuHitObject)current.HitObjects[5], + new HitCircle { StartTime = 3000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP } } }, + (OsuHitObject)current.HitObjects[7], + } + }; + + runTest(patch); + } + + [Test] + public void TestAddAndDeleteHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 750 }, + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[4], + (OsuHitObject)current.HitObjects[5], + new HitCircle { StartTime = 2650 }, + new HitCircle { StartTime = 2750 }, + new HitCircle { StartTime = 4000 }, + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeHitObjectAtSameTime() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500, Position = new Vector2(50) }, + new HitCircle { StartTime = 500, Position = new Vector2(100) }, + new HitCircle { StartTime = 500, Position = new Vector2(150) }, + new HitCircle { StartTime = 500, Position = new Vector2(200) }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500, Position = new Vector2(150) }, + new HitCircle { StartTime = 500, Position = new Vector2(100) }, + new HitCircle { StartTime = 500, Position = new Vector2(50) }, + new HitCircle { StartTime = 500, Position = new Vector2(200) }, + } + }; + + runTest(patch); + } + + private void runTest(IBeatmap patch) + { + // Due to the method of testing, "patch" comes in without having been decoded via a beatmap decoder. + // This causes issues because the decoder adds various default properties (e.g. new combo on first object, default samples). + // To resolve "patch" into a sane state it is encoded and then re-decoded. + patch = decode(encode(patch)); + + // Apply the patch. + patcher.Patch(encode(current), encode(patch)); + + // Convert beatmaps to strings for assertion purposes. + string currentStr = Encoding.ASCII.GetString(encode(current)); + string patchStr = Encoding.ASCII.GetString(encode(patch)); + + Assert.That(currentStr, Is.EqualTo(patchStr)); + } + + private byte[] encode(IBeatmap beatmap) + { + using (var encoded = new MemoryStream()) + { + using (var sw = new StreamWriter(encoded)) + new LegacyBeatmapEncoder(beatmap).Encode(sw); + + return encoded.ToArray(); + } + } + + private IBeatmap decode(byte[] state) + { + using (var stream = new MemoryStream(state)) + using (var reader = new LineBufferedReader(stream)) + return Decoder.GetDecoder(reader).Decode(reader); + } + } +} diff --git a/osu.Game.Tests/Editor/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs similarity index 81% rename from osu.Game.Tests/Editor/TestSceneHitObjectComposerDistanceSnapping.cs rename to osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 2d336bd19c..168ec0f09d 100644 --- a/osu.Game.Tests/Editor/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -3,15 +3,18 @@ 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.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Screens.Edit; using osu.Game.Tests.Visual; -namespace osu.Game.Tests.Editor +namespace osu.Game.Tests.Editing { [HeadlessTest] public class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene @@ -19,12 +22,34 @@ namespace osu.Game.Tests.Editor private TestHitObjectComposer composer; [Cached(typeof(EditorBeatmap))] - private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap; + + protected override Container Content { get; } + + public TestSceneHitObjectComposerDistanceSnapping() + { + base.Content.Add(new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()), + Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }, + }); + } [SetUp] public void Setup() => Schedule(() => { - Child = composer = new TestHitObjectComposer(); + Children = new Drawable[] + { + composer = new TestHitObjectComposer() + }; BeatDivisor.Value = 1; @@ -111,17 +136,19 @@ namespace osu.Game.Tests.Editor [Test] public void TestGetSnappedDurationFromDistance() { - assertSnappedDuration(50, 0); + assertSnappedDuration(0, 0); + assertSnappedDuration(50, 1000); assertSnappedDuration(100, 1000); - assertSnappedDuration(150, 1000); + assertSnappedDuration(150, 2000); assertSnappedDuration(200, 2000); - assertSnappedDuration(250, 2000); + assertSnappedDuration(250, 3000); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + assertSnappedDuration(0, 0); assertSnappedDuration(50, 0); - assertSnappedDuration(100, 0); - assertSnappedDuration(150, 0); + assertSnappedDuration(100, 1000); + assertSnappedDuration(150, 1000); assertSnappedDuration(200, 1000); assertSnappedDuration(250, 1000); @@ -132,8 +159,8 @@ namespace osu.Game.Tests.Editor }); assertSnappedDuration(50, 0); - assertSnappedDuration(100, 0); - assertSnappedDuration(150, 0); + assertSnappedDuration(100, 500); + assertSnappedDuration(150, 500); assertSnappedDuration(200, 500); assertSnappedDuration(250, 500); assertSnappedDuration(400, 1000); @@ -142,17 +169,17 @@ namespace osu.Game.Tests.Editor [Test] public void GetSnappedDistanceFromDistance() { - assertSnappedDistance(50, 0); + assertSnappedDistance(50, 100); assertSnappedDistance(100, 100); - assertSnappedDistance(150, 100); + assertSnappedDistance(150, 200); assertSnappedDistance(200, 200); - assertSnappedDistance(250, 200); + assertSnappedDistance(250, 300); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); assertSnappedDistance(50, 0); - assertSnappedDistance(100, 0); - assertSnappedDistance(150, 0); + assertSnappedDistance(100, 200); + assertSnappedDistance(150, 200); assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); @@ -163,8 +190,8 @@ namespace osu.Game.Tests.Editor }); assertSnappedDistance(50, 0); - assertSnappedDistance(100, 0); - assertSnappedDistance(150, 0); + assertSnappedDistance(100, 200); + assertSnappedDistance(150, 200); assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); assertSnappedDistance(400, 400); diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index eec52669ff..460ad1b898 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -2,12 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -18,7 +18,6 @@ namespace osu.Game.Tests.Gameplay [HeadlessTest] public class TestSceneDrainingHealthProcessor : OsuTestScene { - private Bindable breakTime; private HealthProcessor processor; private ManualClock clock; @@ -41,6 +40,64 @@ namespace osu.Game.Tests.Gameplay assertHealthEqualTo(1); } + [Test] + public void TestHealthDrainBetweenBreakAndObjects() + { + createProcessor(createBeatmap(0, 2000, new BreakPeriod(325, 375))); + + // 275 300 325 350 375 400 425 + // hitobjects o o + // break [-------------] + // no drain [---------------------------] + + setTime(285); + setHealth(1); + + setTime(295); + assertHealthNotEqualTo(1); + + setTime(305); + setHealth(1); + + setTime(315); + assertHealthEqualTo(1); + + setTime(365); + assertHealthEqualTo(1); + + setTime(395); + assertHealthEqualTo(1); + + setTime(425); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestHealthDrainDuringMaximalBreak() + { + createProcessor(createBeatmap(0, 2000, new BreakPeriod(300, 400))); + + // 275 300 325 350 375 400 425 + // hitobjects o o + // break [---------------------------] + // no drain [---------------------------] + + setTime(285); + setHealth(1); + + setTime(295); + assertHealthNotEqualTo(1); + + setTime(305); + setHealth(1); + + setTime(395); + assertHealthEqualTo(1); + + setTime(425); + assertHealthNotEqualTo(1); + } + [Test] public void TestHealthNotDrainedAfterGameplayEnd() { @@ -54,18 +111,6 @@ namespace osu.Game.Tests.Gameplay assertHealthEqualTo(1); } - [Test] - public void TestHealthNotDrainedDuringBreak() - { - createProcessor(createBeatmap(0, 2000)); - setBreak(true); - - setTime(700); - assertHealthEqualTo(1); - setTime(900); - assertHealthEqualTo(1); - } - [Test] public void TestHealthDrainedDuringGameplay() { @@ -112,30 +157,49 @@ namespace osu.Game.Tests.Gameplay assertHealthNotEqualTo(1); } - private Beatmap createBeatmap(double startTime, double endTime) + [Test] + public void TestBonusObjectsExcludedFromDrain() { var beatmap = new Beatmap { - BeatmapInfo = { BaseDifficulty = { DrainRate = 5 } }, + BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + }; + + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 0 }); + for (double time = 0; time < 5000; time += 100) + beatmap.HitObjects.Add(new JudgeableHitObject(false) { StartTime = time }); + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 5000 }); + + createProcessor(beatmap); + setTime(4900); // Get close to the second combo-affecting object + assertHealthNotEqualTo(0); + } + + private Beatmap createBeatmap(double startTime, double endTime, params BreakPeriod[] breaks) + { + var beatmap = new Beatmap + { + BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, }; for (double time = startTime; time <= endTime; time += 100) + { beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = time }); + } + + beatmap.Breaks.AddRange(breaks); return beatmap; } private void createProcessor(Beatmap beatmap) => AddStep("create processor", () => { - breakTime = new Bindable(); - Child = processor = new DrainingHealthProcessor(beatmap.HitObjects[0].StartTime).With(d => { d.RelativeSizeAxes = Axes.Both; d.Clock = new FramedClock(clock = new ManualClock()); }); - processor.IsBreakTime.BindTo(breakTime); processor.ApplyBeatmap(beatmap); }); @@ -143,8 +207,6 @@ namespace osu.Game.Tests.Gameplay private void setHealth(double health) => AddStep($"set health = {health}", () => processor.Health.Value = health); - private void setBreak(bool enabled) => AddStep($"{(enabled ? "enable" : "disable")} break", () => breakTime.Value = enabled); - private void assertHealthEqualTo(double value) => AddAssert($"health = {value}", () => Precision.AlmostEquals(value, processor.Health.Value, 0.0001f)); @@ -153,7 +215,25 @@ namespace osu.Game.Tests.Gameplay private class JudgeableHitObject : HitObject { - public override Judgement CreateJudgement() => new Judgement(); + private readonly bool affectsCombo; + + public JudgeableHitObject(bool affectsCombo = true) + { + this.affectsCombo = affectsCombo; + } + + public override Judgement CreateJudgement() => new TestJudgement(affectsCombo); + protected override HitWindows CreateHitWindows() => new HitWindows(); + + private class TestJudgement : Judgement + { + public override bool AffectsCombo { get; } + + public TestJudgement(bool affectsCombo) + { + AffectsCombo = affectsCombo; + } + } } } } diff --git a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs new file mode 100644 index 0000000000..cd3669f160 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneGameplayClockContainer : OsuTestScene + { + [Test] + public void TestStartThenElapsedTime() + { + GameplayClockContainer gcc = null; + + AddStep("create container", () => Add(gcc = new GameplayClockContainer(CreateWorkingBeatmap(new OsuRuleset().RulesetInfo), Array.Empty(), 0))); + AddStep("start track", () => gcc.Start()); + AddUntilStep("elapsed greater than zero", () => gcc.GameplayClock.ElapsedFrameTime > 0); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index c6d1f9da29..2d5e4b911e 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -8,11 +8,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.Textures; using osu.Framework.Testing; using osu.Game.Audio; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using osu.Game.Tests.Visual; @@ -78,7 +79,7 @@ namespace osu.Game.Tests.Gameplay } } - private class TestHitObjectWithCombo : HitObject, IHasComboInformation + private class TestHitObjectWithCombo : ConvertHitObject, IHasComboInformation { public bool NewCombo { get; } = false; public int ComboOffset { get; } = 0; @@ -118,7 +119,7 @@ namespace osu.Game.Tests.Gameplay public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); @@ -126,10 +127,10 @@ namespace osu.Game.Tests.Gameplay { switch (lookup) { - case GlobalSkinConfiguration global: + case GlobalSkinColours global: switch (global) { - case GlobalSkinConfiguration.ComboColours: + case GlobalSkinColours.ComboColours: return SkinUtils.As(new Bindable>(ComboColours)); } diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs new file mode 100644 index 0000000000..737946e1e0 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -0,0 +1,231 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + protected override IResourceStore Resources => TestResources.GetStore(); + + /// + /// Tests that a hitobject which provides no custom sample set retrieves samples from the user skin. + /// + [Test] + public void TestDefaultSampleFromUserSkin() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-skin-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the beatmap skin. + /// + [Test] + public void TestDefaultSampleFromBeatmap() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the user skin when the beatmap does not contain the sample. + /// + [Test] + public void TestDefaultSampleFromUserSkinFallback() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(null, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin: + /// normal-hitnormal2 + /// normal-hitnormal + /// + [TestCase("normal-hitnormal2")] + [TestCase("normal-hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + { + SetupSkins(expectedSample, expectedSample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertBeatmapLookup(expectedSample); + } + + /// + /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the user skin when the beatmap does not contain the sample: + /// normal-hitnormal2 + /// normal-hitnormal + /// + [TestCase("normal-hitnormal2")] + [TestCase("normal-hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + { + SetupSkins(string.Empty, expectedSample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertUserLookup(expectedSample); + } + + /// + /// Tests that a hitobject which provides a sample file retrieves the sample file from the beatmap skin. + /// + [Test] + public void TestFileSampleFromBeatmap() + { + const string expected_sample = "hit_1.wav"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("file-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a default hitobject and control point causes . + /// + [Test] + public void TestControlPointSampleFromSkin() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("controlpoint-skin-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a control point that provides a custom sample set of 1 causes . + /// + [Test] + public void TestControlPointSampleFromBeatmap() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("controlpoint-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a control point that provides a custom sample of 2 causes . + /// + [TestCase("normal-hitnormal2")] + [TestCase("normal-hitnormal")] + public void TestControlPointCustomSampleFromBeatmap(string sampleName) + { + SetupSkins(sampleName, sampleName); + + CreateTestWithBeatmap("controlpoint-beatmap-custom-sample.osu"); + + AssertBeatmapLookup(sampleName); + } + + /// + /// Tests that a hitobject's custom sample overrides the control point's. + /// + [Test] + public void TestHitObjectCustomSampleOverride() + { + const string expected_sample = "normal-hitnormal3"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-override.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that when a custom sample bank is used, both the normal and additional sounds will be looked up. + /// + [Test] + public void TestHitObjectCustomSampleBank() + { + string[] expectedSamples = + { + "normal-hitnormal2", + "normal-hitwhistle2" + }; + + SetupSkins(expectedSamples[0], expectedSamples[1]); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expectedSamples[0]); + AssertUserLookup(expectedSamples[1]); + } + + /// + /// Tests that when a custom sample bank is used, but is disabled, + /// only the additional sound will be looked up. + /// + [Test] + public void TestHitObjectCustomSampleBankWithoutLayered() + { + const string expected_sample = "normal-hitwhistle2"; + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, unwanted_sample); + disableLayeredHitSounds(); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + AssertNoLookup(unwanted_sample); + } + + /// + /// Tests that when a normal sample bank is used and is disabled, + /// the normal sound will be looked up anyway. + /// + [Test] + public void TestHitObjectNormalSampleBankWithoutLayered() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + disableLayeredHitSounds(); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + private void disableLayeredHitSounds() + => AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[GlobalSkinConfiguration.LayeredHitSounds.ToString()] = "0"); + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 5deb136c85..b30870d057 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -8,13 +9,22 @@ using NUnit.Framework; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.IO.Stores; +using osu.Framework.Testing; using osu.Game.Audio; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Play; using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { + [HeadlessTest] public class TestSceneStoryboardSamples : OsuTestScene { [Test] @@ -41,6 +51,71 @@ namespace osu.Game.Tests.Gameplay AddAssert("sample is non-null", () => channel != null); } + [Test] + public void TestSamplePlaybackAtZero() + { + GameplayClockContainer gameplayContainer = null; + DrawableStoryboardSample sample = null; + + AddStep("create container", () => + { + Add(gameplayContainer = new GameplayClockContainer(CreateWorkingBeatmap(new OsuRuleset().RulesetInfo), Array.Empty(), 0)); + + gameplayContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + { + Clock = gameplayContainer.GameplayClock + }); + }); + + AddStep("start time", () => gameplayContainer.Start()); + + AddUntilStep("sample playback succeeded", () => sample.LifetimeEnd < double.MaxValue); + } + + [TestCase(typeof(OsuModDoubleTime), 1.5)] + [TestCase(typeof(OsuModHalfTime), 0.75)] + [TestCase(typeof(ModWindUp), 1.5)] + [TestCase(typeof(ModWindDown), 0.75)] + [TestCase(typeof(OsuModDoubleTime), 2)] + [TestCase(typeof(OsuModHalfTime), 0.5)] + [TestCase(typeof(ModWindUp), 2)] + [TestCase(typeof(ModWindDown), 0.5)] + public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate) + { + GameplayClockContainer gameplayContainer = null; + TestDrawableStoryboardSample sample = null; + + Mod testedMod = Activator.CreateInstance(expectedMod) as Mod; + + switch (testedMod) + { + case ModRateAdjust m: + m.SpeedChange.Value = expectedRate; + break; + + case ModTimeRamp m: + m.InitialRate.Value = m.FinalRate.Value = expectedRate; + break; + } + + AddStep("setup storyboard sample", () => + { + Beatmap.Value = new TestCustomSkinWorkingBeatmap(new OsuRuleset().RulesetInfo, Audio); + SelectedMods.Value = new[] { testedMod }; + + Add(gameplayContainer = new GameplayClockContainer(Beatmap.Value, SelectedMods.Value, 0)); + + gameplayContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) + { + Clock = gameplayContainer.GameplayClock + }); + }); + + AddStep("start", () => gameplayContainer.Start()); + + AddAssert("sample playback rate matches mod rates", () => sample.Channel.AggregateFrequency.Value == expectedRate); + } + private class TestSkin : LegacySkin { public TestSkin(string resourceName, AudioManager audioManager) @@ -58,11 +133,11 @@ namespace osu.Game.Tests.Gameplay this.resourceName = resourceName; } - public byte[] Get(string name) => name == resourceName ? TestResources.GetStore().Get("Resources/test-sample.mp3") : null; + public byte[] Get(string name) => name == resourceName ? TestResources.GetStore().Get("Resources/Samples/test-sample.mp3") : null; - public Task GetAsync(string name) => name == resourceName ? TestResources.GetStore().GetAsync("Resources/test-sample.mp3") : null; + public Task GetAsync(string name) => name == resourceName ? TestResources.GetStore().GetAsync("Resources/Samples/test-sample.mp3") : null; - public Stream GetStream(string name) => name == resourceName ? TestResources.GetStore().GetStream("Resources/test-sample.mp3") : null; + public Stream GetStream(string name) => name == resourceName ? TestResources.GetStore().GetStream("Resources/Samples/test-sample.mp3") : null; public IEnumerable GetAvailableResources() => new[] { resourceName }; @@ -70,5 +145,28 @@ namespace osu.Game.Tests.Gameplay { } } + + private class TestCustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly AudioManager audio; + + public TestCustomSkinWorkingBeatmap(RulesetInfo ruleset, AudioManager audio) + : base(ruleset, null, audio) + { + this.audio = audio; + } + + protected override ISkin GetSkin() => new TestSkin("test-sample", audio); + } + + private class TestDrawableStoryboardSample : DrawableStoryboardSample + { + public TestDrawableStoryboardSample(StoryboardSampleInfo sampleInfo) + : base(sampleInfo) + { + } + + public new SampleChannel Channel => base.Channel; + } } } diff --git a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs new file mode 100644 index 0000000000..e663e1128e --- /dev/null +++ b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.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.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.NonVisual +{ + public class BarLineGeneratorTest + { + [Test] + public void TestRoundingErrorCompensation() + { + // The aim of this test is to make sure bar line generation compensates for floating-point errors. + // The premise of the test is that we have a single timing point that should result in bar lines + // that start at a time point that is a whole number every seventh beat. + + // The fact it's every seventh beat is important - it's a number indivisible by 2, which makes + // it susceptible to rounding inaccuracies. In fact this was originally spotted in cases of maps + // that met exactly this criteria. + + const int beat_length_numerator = 2000; + const int beat_length_denominator = 7; + const TimeSignatures signature = TimeSignatures.SimpleQuadruple; + + var beatmap = new Beatmap + { + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = 120_000 } + }, + ControlPointInfo = new ControlPointInfo() + }; + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = (double)beat_length_numerator / beat_length_denominator, + TimeSignature = signature + }); + + var barLines = new BarLineGenerator(beatmap).BarLines; + + for (int i = 0; i * beat_length_denominator < barLines.Count; i++) + { + var barLine = barLines[i * beat_length_denominator]; + var expectedTime = beat_length_numerator * (int)signature * i; + + // every seventh bar's start time should be at least greater than the whole number we expect. + // It cannot be less, as that can affect overlapping scroll algorithms + // (the previous timing point might be chosen incorrectly if this is not the case) + Assert.GreaterOrEqual(barLine.StartTime, expectedTime); + + // on the other side, make sure we don't stray too far from the expected time either. + Assert.IsTrue(Precision.AlmostEquals(barLine.StartTime, expectedTime)); + + // check major/minor lines for good measure too + Assert.AreEqual(i % (int)signature == 0, barLine.Major); + } + } + + private class BarLine : IBarLine + { + public double StartTime { get; set; } + public bool Major { get; set; } + } + } +} diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index 2782e902fe..830e4bc603 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -29,11 +29,17 @@ namespace osu.Game.Tests.NonVisual var cpi = new ControlPointInfo(); cpi.Add(0, new TimingControlPoint()); // is *not* redundant, special exception for first timing point. + cpi.Add(1000, new TimingControlPoint()); // is also not redundant, due to change of offset + + 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(1000, new TimingControlPoint()); // is redundant - Assert.That(cpi.Groups.Count, Is.EqualTo(1)); - Assert.That(cpi.TimingPoints.Count, Is.EqualTo(1)); - Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); } [Test] @@ -86,11 +92,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 }); // is not redundant + 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 - Assert.That(cpi.Groups.Count, Is.EqualTo(1)); - Assert.That(cpi.EffectPoints.Count, Is.EqualTo(1)); - Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.EffectPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); } [Test] diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs new file mode 100644 index 0000000000..199e69a19d --- /dev/null +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -0,0 +1,324 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Configuration; +using osu.Framework.Platform; +using osu.Game.Configuration; +using osu.Game.IO; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class CustomDataDirectoryTest + { + [Test] + public void TestDefaultDirectory() + { + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestDefaultDirectory))) + { + try + { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestDefaultDirectory)); + + var osu = loadOsu(host); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCustomDirectory() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost(nameof(TestCustomDirectory))) + { + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) + storageConfig.Set(StorageConfig.FullPath, customPath); + + try + { + var osu = loadOsu(host); + + // switch to DI'd storage + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestSubDirectoryLookup() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost(nameof(TestSubDirectoryLookup))) + { + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) + storageConfig.Set(StorageConfig.FullPath, customPath); + + try + { + var osu = loadOsu(host); + + // switch to DI'd storage + var storage = osu.Dependencies.Get(); + + string actualTestFile = Path.Combine(customPath, "rulesets", "test"); + + File.WriteAllText(actualTestFile, "test"); + + var rulesetStorage = storage.GetStorageForDirectory("rulesets"); + var lookupPath = rulesetStorage.GetFiles(".").Single(); + + Assert.That(lookupPath, Is.EqualTo("test")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigration() + { + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigration))) + { + try + { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestMigration)); + + var osu = loadOsu(host); + var storage = osu.Dependencies.Get(); + + // Store the current storage's path. We'll need to refer to this for assertions in the original directory after the migration completes. + string originalDirectory = storage.GetFullPath("."); + + // ensure we perform a save + host.Dependencies.Get().Save(); + + // ensure we "use" cache + host.Storage.GetStorageForDirectory("cache"); + + // for testing nested files are not ignored (only top level) + host.Storage.GetStorageForDirectory("test-nested").GetStorageForDirectory("cache"); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); + + osu.Migrate(customPath); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); + + // ensure cache was not moved + Assert.That(Directory.Exists(Path.Combine(originalDirectory, "cache"))); + + // ensure nested cache was moved + Assert.That(!Directory.Exists(Path.Combine(originalDirectory, "test-nested", "cache"))); + Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); + + foreach (var file in OsuStorage.IGNORE_FILES) + { + Assert.That(File.Exists(Path.Combine(originalDirectory, file))); + Assert.That(storage.Exists(file), Is.False); + } + + foreach (var dir in OsuStorage.IGNORE_DIRECTORIES) + { + Assert.That(Directory.Exists(Path.Combine(originalDirectory, dir))); + Assert.That(storage.ExistsDirectory(dir), Is.False); + } + + Assert.That(new StreamReader(Path.Combine(originalDirectory, "storage.ini")).ReadToEnd().Contains($"FullPath = {customPath}")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationBetweenTwoTargets() + { + string customPath = prepareCustomPath(); + string customPath2 = prepareCustomPath("-2"); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationBetweenTwoTargets))) + { + try + { + var osu = loadOsu(host); + + const string database_filename = "client.db"; + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.That(File.Exists(Path.Combine(customPath, database_filename))); + + Assert.DoesNotThrow(() => osu.Migrate(customPath2)); + Assert.That(File.Exists(Path.Combine(customPath2, database_filename))); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.That(File.Exists(Path.Combine(customPath, database_filename))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToSameTargetFails() + { + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToSameTargetFails))) + { + try + { + var osu = loadOsu(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.Throws(() => osu.Migrate(customPath)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToNestedTargetFails() + { + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToNestedTargetFails))) + { + try + { + var osu = loadOsu(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string subFolder = Path.Combine(customPath, "sub"); + + if (Directory.Exists(subFolder)) + Directory.Delete(subFolder, true); + + Directory.CreateDirectory(subFolder); + + Assert.Throws(() => osu.Migrate(subFolder)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToSeeminglyNestedTarget() + { + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToSeeminglyNestedTarget))) + { + try + { + var osu = loadOsu(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string seeminglySubFolder = customPath + "sub"; + + if (Directory.Exists(seeminglySubFolder)) + Directory.Delete(seeminglySubFolder, true); + + Directory.CreateDirectory(seeminglySubFolder); + + osu.Migrate(seeminglySubFolder); + } + finally + { + host.Exit(); + } + } + } + + private OsuGameBase loadOsu(GameHost host) + { + var osu = new OsuGameBase(); + Task.Run(() => host.Run(osu)); + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + + return osu; + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + + private static string getDefaultLocationFor(string testTypeName) + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, "headless", testTypeName); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + private string prepareCustomPath(string suffix = "") + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, $"custom-path{suffix}"); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + public class CustomTestHeadlessGameHost : HeadlessGameHost + { + public Storage InitialStorage { get; } + + public CustomTestHeadlessGameHost(string name) + : base(name) + { + string defaultStorageLocation = getDefaultLocationFor(name); + + InitialStorage = new DesktopStorage(defaultStorageLocation, this); + InitialStorage.DeleteDirectory(string.Empty); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 7df7df22ea..92a60663de 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -46,12 +46,12 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(0); confirmNextFrame(1); - //if we hit the first frame perfectly, time should progress to it. + // if we hit the first frame perfectly, time should progress to it. setTime(1000, 1000); confirmCurrentFrame(1); confirmNextFrame(2); - //in between non-important frames should progress based on input. + // in between non-important frames should progress based on input. setTime(1200, 1200); confirmCurrentFrame(1); @@ -144,7 +144,7 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(2); confirmNextFrame(1); - //ensure each frame plays out until start + // ensure each frame plays out until start setTime(-500, 1000); confirmCurrentFrame(1); confirmNextFrame(0); diff --git a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs new file mode 100644 index 0000000000..62c7732b66 --- /dev/null +++ b/osu.Game.Tests/NonVisual/PeriodTrackerTest.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.Utils; +using osu.Game.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class PeriodTrackerTest + { + private static readonly Period[] single_period = { new Period(1.0, 2.0) }; + + private static readonly Period[] unordered_periods = + { + new Period(-9.1, -8.3), + new Period(-3.4, 2.1), + new Period(9.0, 50.0), + new Period(5.25, 10.50) + }; + + [Test] + public void TestCheckValueInsideSinglePeriod() + { + var tracker = new PeriodTracker(single_period); + + var period = single_period.Single(); + Assert.IsTrue(tracker.IsInAny(period.Start)); + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + Assert.IsTrue(tracker.IsInAny(period.End)); + } + + [Test] + public void TestCheckValuesInsidePeriods() + { + var tracker = new PeriodTracker(unordered_periods); + + foreach (var period in unordered_periods) + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + } + + [Test] + public void TestCheckValuesInRandomOrder() + { + var tracker = new PeriodTracker(unordered_periods); + + foreach (var period in unordered_periods.OrderBy(_ => RNG.Next())) + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + } + + [Test] + public void TestCheckValuesOutOfPeriods() + { + var tracker = new PeriodTracker(new[] + { + new Period(1.0, 2.0), + new Period(3.0, 4.0) + }); + + Assert.IsFalse(tracker.IsInAny(0.9), "Time before first period is being considered inside"); + + Assert.IsFalse(tracker.IsInAny(2.1), "Time right after first period is being considered inside"); + Assert.IsFalse(tracker.IsInAny(2.9), "Time right before second period is being considered inside"); + + Assert.IsFalse(tracker.IsInAny(4.1), "Time after last period is being considered inside"); + } + + [Test] + public void TestReversedPeriodHandling() + { + Assert.Throws(() => + { + _ = new PeriodTracker(new[] + { + new Period(2.0, 1.0) + }); + }); + } + + private double getMidpoint(Period period) => period.Start + (period.End - period.Start) / 2; + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs new file mode 100644 index 0000000000..69e66942ab --- /dev/null +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Game.Skinning; + +namespace osu.Game.Tests.NonVisual.Skinning +{ + [TestFixture] + public sealed class LegacySkinTextureFallbackTest + { + private static object[][] fallbackTestCases = + { + new object[] + { + // textures in store + new[] { "Gameplay/osu/followpoint@2x", "Gameplay/osu/followpoint" }, + // requested component + "Gameplay/osu/followpoint", + // returned texture name & scale + "Gameplay/osu/followpoint@2x", 2 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint@2x" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint@2x", 2 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint", 1 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint", "followpoint@2x" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint", 1 + }, + new object[] + { + new[] { "followpoint@2x", "followpoint" }, + "Gameplay/osu/followpoint", + "followpoint@2x", 2 + }, + new object[] + { + new[] { "followpoint@2x" }, + "Gameplay/osu/followpoint", + "followpoint@2x", 2 + }, + new object[] + { + new[] { "followpoint" }, + "Gameplay/osu/followpoint", + "followpoint", 1 + }, + }; + + [TestCaseSource(nameof(fallbackTestCases))] + public void TestFallbackOrder(string[] filesInStore, string requestedComponent, string expectedTexture, float expectedScale) + { + var textureStore = new TestTextureStore(filesInStore); + var legacySkin = new TestLegacySkin(textureStore); + + var texture = legacySkin.GetTexture(requestedComponent); + + Assert.IsNotNull(texture); + Assert.AreEqual(textureStore.Textures[expectedTexture], texture); + Assert.AreEqual(expectedScale, texture.ScaleAdjust); + } + + [Test] + public void TestReturnNullOnFallbackFailure() + { + var textureStore = new TestTextureStore("sliderb", "hit100"); + var legacySkin = new TestLegacySkin(textureStore); + + var texture = legacySkin.GetTexture("Gameplay/osu/followpoint"); + + Assert.IsNull(texture); + } + + private class TestLegacySkin : LegacySkin + { + public TestLegacySkin(TextureStore textureStore) + : base(new SkinInfo(), null, null, string.Empty) + { + Textures = textureStore; + } + } + + private class TestTextureStore : TextureStore + { + public readonly Dictionary Textures; + + public TestTextureStore(params string[] fileNames) + { + Textures = fileNames.ToDictionary(fileName => fileName, fileName => new Texture(1, 1)); + } + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => Textures.GetValueOrDefault(name); + } + } +} diff --git a/osu.Game.Tests/Online/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModSerialization.cs new file mode 100644 index 0000000000..5948582d77 --- /dev/null +++ b/osu.Game.Tests/Online/TestAPIModSerialization.cs @@ -0,0 +1,139 @@ +// 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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + public class TestAPIModSerialization + { + [Test] + public void TestAcronymIsPreserved() + { + var apiMod = new APIMod(new TestMod()); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + + Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym)); + } + + [Test] + public void TestRawSettingIsPreserved() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); + } + + [Test] + public void TestConvertedModHasCorrectSetting() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestMod)deserialized.ToMod(new TestRuleset()); + + Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); + } + + [Test] + public void TestDeserialiseTimeRampMod() + { + // Create the mod with values different from default. + var apiMod = new APIMod(new TestModTimeRamp + { + AdjustPitch = { Value = false }, + InitialRate = { Value = 1.25 }, + FinalRate = { Value = 0.25 } + }); + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false)); + Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25)); + Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new Mod[] + { + new TestMod(), + new TestModTimeRamp(), + }; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new System.NotImplementedException(); + + public override string Description { get; } = string.Empty; + public override string ShortName { get; } = string.Empty; + } + + private class TestMod : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public BindableNumber TestSetting { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Precision = 0.01, + }; + } + + private class TestModTimeRamp : ModTimeRamp + { + public override string Name => "Test Mod"; + public override string Acronym => "TMTR"; + public override double ScoreMultiplier => 1; + + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber InitialRate { get; } = new BindableDouble + { + 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 + { + 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 + }; + } + } +} diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs new file mode 100644 index 0000000000..42948c3731 --- /dev/null +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestDummyAPIRequestHandling : OsuTestScene + { + [Test] + public void TestGenericRequestHandling() + { + AddStep("register request handling", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case CommentVoteRequest cRequest: + cRequest.TriggerSuccess(new CommentBundle()); + break; + } + }); + + CommentVoteRequest request = null; + CommentBundle response = null; + + AddStep("fire request", () => + { + response = null; + request = new CommentVoteRequest(1, CommentVoteAction.Vote); + request.Success += res => response = res; + API.Queue(request); + }); + + AddAssert("response event fired", () => response != null); + + AddAssert("request has response", () => request.Result == response); + } + + [Test] + public void TestQueueRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.Queue(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + [Test] + public void TestPerformRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.Perform(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + [Test] + public void TestPerformAsyncRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.PerformAsync(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + private void registerHandler() + { + AddStep("register request handling", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case LeaveChannelRequest cRequest: + cRequest.TriggerSuccess(); + break; + } + }); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneBeatmapManager.cs b/osu.Game.Tests/Online/TestSceneBeatmapManager.cs new file mode 100644 index 0000000000..0ae0186770 --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneBeatmapManager.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.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestSceneBeatmapManager : OsuTestScene + { + private BeatmapManager beatmaps; + private ProgressNotification recentNotification; + + private static readonly BeatmapSetInfo test_model = new BeatmapSetInfo { OnlineBeatmapSetID = 1 }; + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps) + { + this.beatmaps = beatmaps; + + beatmaps.PostNotification = n => recentNotification = n as ProgressNotification; + } + + [Test] + public void TestCancelDownloadFromRequest() + { + AddStep("download beatmap", () => beatmaps.Download(test_model)); + + AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_model).Cancel()); + + AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_model) == null); + AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); + } + + [Test] + public void TestCancelDownloadFromNotification() + { + AddStep("download beatmap", () => beatmaps.Download(test_model)); + + AddStep("cancel download from notification", () => recentNotification.Close()); + + AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_model) == null); + AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz b/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz new file mode 100644 index 0000000000..f264a8dda2 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz differ diff --git a/osu.Game.Tests/Resources/Archives/ogg-skin.osk b/osu.Game.Tests/Resources/Archives/ogg-skin.osk new file mode 100644 index 0000000000..d7379446aa Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/ogg-skin.osk differ diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu new file mode 100644 index 0000000000..91dbc6a60e --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu new file mode 100644 index 0000000000..3274820100 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,1,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu new file mode 100644 index 0000000000..c53ec465fb --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,0,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu new file mode 100644 index 0000000000..65b5ea8707 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +255,193,2170,1,0,0:0:0:0:hit_1.wav diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..c50c921839 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,2,0:0:0:0: diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu new file mode 100644 index 0000000000..13dc2faab1 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:3:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu new file mode 100644 index 0000000000..4ab672dbb0 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:2:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu new file mode 100644 index 0000000000..33bc34949a --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:1:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu new file mode 100644 index 0000000000..47f5b44c90 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/test-sample.mp3 b/osu.Game.Tests/Resources/Samples/test-sample.mp3 similarity index 100% rename from osu.Game.Tests/Resources/test-sample.mp3 rename to osu.Game.Tests/Resources/Samples/test-sample.mp3 diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index c6eb3ae7a4..e882229570 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -9,7 +9,7 @@ namespace osu.Game.Tests.Resources { public static class TestResources { - public static DllResourceStore GetStore() => new DllResourceStore("osu.Game.Tests.dll"); + public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly); public static Stream OpenResource(string name) => GetStore().GetStream($"Resources/{name}"); @@ -17,14 +17,14 @@ namespace osu.Game.Tests.Resources public static string GetTestBeatmapForImport(bool virtualTrack = false) { - var temp = Path.GetTempFileName() + ".osz"; + var tempPath = Path.GetTempFileName() + ".osz"; using (var stream = GetTestBeatmapStream(virtualTrack)) - using (var newFile = File.Create(temp)) + using (var newFile = File.Create(tempPath)) stream.CopyTo(newFile); - Assert.IsTrue(File.Exists(temp)); - return temp; + Assert.IsTrue(File.Exists(tempPath)); + return tempPath; } } } diff --git a/osu.Game.Tests/Resources/Textures/test-image.png b/osu.Game.Tests/Resources/Textures/test-image.png new file mode 100644 index 0000000000..5d0092edc8 Binary files /dev/null and b/osu.Game.Tests/Resources/Textures/test-image.png differ diff --git a/osu.Game.Tests/Resources/hitobject-combo-offset.osu b/osu.Game.Tests/Resources/hitobject-combo-offset.osu index c1f0dab8e9..d39a3e8548 100644 --- a/osu.Game.Tests/Resources/hitobject-combo-offset.osu +++ b/osu.Game.Tests/Resources/hitobject-combo-offset.osu @@ -5,27 +5,27 @@ osu file format v14 255,193,1000,49,0,0:0:0:0: // Combo index = 4 -// Slider with new combo followed by circle with no new combo +// Spinner with new combo followed by circle with no new combo 256,192,2000,12,0,2000,0:0:0:0: 255,193,3000,1,0,0:0:0:0: // Combo index = 5 -// Slider without new combo followed by circle with no new combo +// Spinner without new combo followed by circle with no new combo 256,192,4000,8,0,5000,0:0:0:0: 255,193,6000,1,0,0:0:0:0: // Combo index = 5 -// Slider without new combo followed by circle with new combo +// Spinner without new combo followed by circle with new combo 256,192,7000,8,0,8000,0:0:0:0: 255,193,9000,5,0,0:0:0:0: // Combo index = 6 -// Slider with new combo and offset (1) followed by circle with new combo and offset (3) +// Spinner with new combo and offset (1) followed by circle with new combo and offset (3) 256,192,10000,28,0,11000,0:0:0:0: 255,193,12000,53,0,0:0:0:0: // Combo index = 11 -// Slider with new combo and offset (2) followed by slider with no new combo followed by circle with no new combo +// Spinner with new combo and offset (2) followed by slider with no new combo followed by circle with no new combo 256,192,13000,44,0,14000,0:0:0:0: 256,192,15000,8,0,16000,0:0:0:0: 255,193,17000,1,0,0:0:0:0: diff --git a/osu.Game.Tests/Resources/mania-skin-colours.ini b/osu.Game.Tests/Resources/mania-skin-colours.ini new file mode 100644 index 0000000000..91d9696e0c --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-colours.ini @@ -0,0 +1,3 @@ +[Mania] +Keys: 4 +ColourBarline: 50,50,50,50 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-duplicate.ini b/osu.Game.Tests/Resources/mania-skin-duplicate.ini new file mode 100644 index 0000000000..2f4fa92c52 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-duplicate.ini @@ -0,0 +1,9 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 + +[Mania] +Keys: 4 +ColumnWidth: 20,20,20,20 +HitPosition: 460 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-extra-data.ini b/osu.Game.Tests/Resources/mania-skin-extra-data.ini new file mode 100644 index 0000000000..e538b5335a --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-extra-data.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10,10,10,10 +HitPosition: 470 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-multiple.ini b/osu.Game.Tests/Resources/mania-skin-multiple.ini new file mode 100644 index 0000000000..247c7738a0 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-multiple.ini @@ -0,0 +1,9 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 + +[Mania] +Keys: 2 +ColumnWidth: 20,20 +HitPosition: 460 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-single.ini b/osu.Game.Tests/Resources/mania-skin-single.ini new file mode 100644 index 0000000000..3ae38fd75e --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-single.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini b/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini new file mode 100644 index 0000000000..fd22e2e299 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +WidthForNoteHeightScale: 0 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-catch.osu b/osu.Game.Tests/Resources/sample-beatmap-catch.osu new file mode 100644 index 0000000000..09ef762e3e --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-catch.osu @@ -0,0 +1,30 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 2 + +[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] +32,183,24,5,0,0:0:0:0: +106,123,200,1,10,0:0:0:0: +199,108,376,1,2,0:0:0:0: +305,105,553,5,4,0:0:0:0: +386,112,729,1,14,0:0:0:0: +486,197,906,5,12,0:0:0:0: +14,199,1082,2,0,L|473:198,1,449.999988079071 +14,199,1700,6,6,P|248:33|490:222,1,629.9999833107,0|8,0:0|0:0,0:0:0:0: +10,190,2494,2,8,B|252:29|254:335|468:167,1,449.999988079071,10|12,0:0|0:0,0:0:0:0: +256,192,3112,12,0,3906,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-mania.osu b/osu.Game.Tests/Resources/sample-beatmap-mania.osu new file mode 100644 index 0000000000..04d6a31ab6 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-mania.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,128,12,2582:0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-osu.osu b/osu.Game.Tests/Resources/sample-beatmap-osu.osu new file mode 100644 index 0000000000..27c96077e6 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-osu.osu @@ -0,0 +1,32 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 0 + +[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] +98,69,24,1,0,0:0:0:0: +419,72,200,1,2,0:0:0:0: +81,314,376,1,6,0:0:0:0: +423,321,553,1,12,0:0:0:0: +86,192,729,2,0,P|459:193|460:193,1,359.999990463257 +86,192,1259,2,0,P|246:82|453:203,1,449.999988079071 +86,192,1876,2,0,B|256:30|257:313|464:177,1,359.999990463257 +86,55,2406,2,12,B|447:51|447:51|452:348|452:348|78:344,1,989.999973773957,14|2,0:0|0:0,0:0:0:0: +256,192,3553,12,0,4259,0:0:0:0: +67,57,4435,5,0,0:0:0:0: +440,52,4612,5,0,0:0:0:0: +86,181,4788,6,0,L|492:183,1,359.999990463257 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-taiko.osu b/osu.Game.Tests/Resources/sample-beatmap-taiko.osu new file mode 100644 index 0000000000..94b4288336 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-taiko.osu @@ -0,0 +1,42 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 1 + +[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] +231,129,24,1,0,0:0:0:0: +231,129,200,1,0,0:0:0:0: +231,129,376,1,0,0:0:0:0: +231,129,553,1,0,0:0:0:0: +231,129,729,1,0,0:0:0:0: +373,132,906,1,4,0:0:0:0: +373,132,1082,1,4,0:0:0:0: +373,132,1259,1,4,0:0:0:0: +373,132,1435,1,4,0:0:0:0: +231,129,1788,1,8,0:0:0:0: +231,129,1964,1,8,0:0:0:0: +231,129,2140,1,8,0:0:0:0: +231,129,2317,1,8,0:0:0:0: +231,129,2493,1,8,0:0:0:0: +373,132,2670,1,12,0:0:0:0: +373,132,2846,1,12,0:0:0:0: +373,132,3023,1,12,0:0:0:0: +373,132,3199,1,12,0:0:0:0: +51,189,3553,2,0,L|150:188,1,89.9999976158143 +52,191,3906,2,0,L|512:189,1,449.999988079071 +26,196,4612,2,4,L|501:195,1,449.999988079071 +17,242,5318,2,10,P|250:69|495:243,1,629.9999833107,0|8,0:0|0:0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/skin-zero-alpha-colour.ini b/osu.Game.Tests/Resources/skin-zero-alpha-colour.ini new file mode 100644 index 0000000000..3c0dae6b13 --- /dev/null +++ b/osu.Game.Tests/Resources/skin-zero-alpha-colour.ini @@ -0,0 +1,5 @@ +[General] +Version: latest + +[Colours] +Combo1: 255,255,255,0 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/storyboard_no_video.osu b/osu.Game.Tests/Resources/storyboard_no_video.osu new file mode 100644 index 0000000000..25f1ff6361 --- /dev/null +++ b/osu.Game.Tests/Resources/storyboard_no_video.osu @@ -0,0 +1,31 @@ +osu file format v14 + +[Events] +//Background and Video events +0,0,"BG.jpg",0,0 +Video,0,"video.avi" +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +1674,333.333333333333,4,2,1,70,1,0 +1674,-100,4,2,1,70,0,0 +3340,-100,4,2,1,70,0,0 +3507,-100,4,2,1,70,0,0 +3673,-100,4,2,1,70,0,0 + +[Colours] +Combo1 : 240,80,80 +Combo2 : 171,252,203 +Combo3 : 128,128,255 +Combo4 : 249,254,186 + +[HitObjects] +148,303,1674,5,6,3:2:0:0: +378,252,1840,1,0,0:0:0:0: +389,270,2340,5,2,0:1:0:0: diff --git a/osu.Game.Tests/Resources/variable-with-suffix.osb b/osu.Game.Tests/Resources/variable-with-suffix.osb index 5c9b46ca98..fd284eb055 100644 --- a/osu.Game.Tests/Resources/variable-with-suffix.osb +++ b/osu.Game.Tests/Resources/variable-with-suffix.osb @@ -1,5 +1,5 @@ [Variables] -$var=1234 +$var=34 [Events] Sprite,Background,TopCentre,"img.jpg",$var56,240 diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs new file mode 100644 index 0000000000..64d1024efb --- /dev/null +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Rulesets.Scoring +{ + public class ScoreProcessorTest + { + private ScoreProcessor scoreProcessor; + private IBeatmap beatmap; + + [SetUp] + public void SetUp() + { + scoreProcessor = new ScoreProcessor(); + beatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = new List + { + new HitCircle() + } + }; + } + + [TestCase(ScoringMode.Standardised, HitResult.Meh, 750_000)] + [TestCase(ScoringMode.Standardised, HitResult.Good, 800_000)] + [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 50)] + [TestCase(ScoringMode.Classic, HitResult.Good, 100)] + [TestCase(ScoringMode.Classic, HitResult.Great, 300)] + public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) + { + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(beatmap); + + var judgementResult = new JudgementResult(beatmap.HitObjects.Single(), new OsuJudgement()) + { + Type = hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value)); + } + } +} diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index a139c3a8c2..57f0d7e957 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -183,11 +183,8 @@ namespace osu.Game.Tests.Scores.IO { var beatmapManager = osu.Dependencies.Get(); - if (score.Beatmap == null) - score.Beatmap = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - - if (score.Ruleset == null) - score.Ruleset = new OsuRuleset().RulesetInfo; + score.Beatmap ??= beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + score.Ruleset ??= new OsuRuleset().RulesetInfo; var scoreManager = osu.Dependencies.Get(); await scoreManager.Import(score, archive); @@ -236,8 +233,6 @@ namespace osu.Game.Tests.Scores.IO } public override IEnumerable Filenames => new[] { "test_file.osr" }; - - public override Stream GetUnderlyingStream() => new MemoryStream(); } } } diff --git a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs index d7f709dc03..a6e8622b6f 100644 --- a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs @@ -18,12 +18,21 @@ namespace osu.Game.Tests.ScrollAlgorithms } [Test] - public void TestDisplayStartTime() + public void TestPointDisplayStartTime() { - Assert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 10000)); - Assert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 5000)); - Assert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 5000)); - Assert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 10000)); + Assert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 0, 10000, 1)); + Assert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 0, 5000, 1)); + Assert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 0, 5000, 1)); + Assert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 0, 10000, 1)); + } + + [Test] + public void TestObjectDisplayStartTime() + { + Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 + Assert.AreEqual(8900, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 + Assert.AreEqual(13500, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 + Assert.AreEqual(19000, algorithm.GetDisplayStartTime(25000, 100, 5000, 500)); // 25000 - (1 + 100 / 500) * 5000 } [Test] diff --git a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs index 106aa88be3..1429d22c1a 100644 --- a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs @@ -27,11 +27,22 @@ namespace osu.Game.Tests.ScrollAlgorithms } [Test] - public void TestDisplayStartTime() + public void TestPointDisplayStartTime() { - Assert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 1000)); // Like constant - Assert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 1000)); // 10500 - (1000 * 0.5) - Assert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 1000)); // 23000 - (1000 / 0.5) + Assert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 0, 1000, 1)); // Like constant + Assert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 0, 1000, 1)); // 10500 - (1000 * 0.5) + Assert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 0, 1000, 1)); // 23000 - (1000 / 0.5) + } + + [Test] + public void TestObjectDisplayStartTime() + { + Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 / 1 + Assert.AreEqual(9450, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 / 2 + Assert.AreEqual(14250, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 / 2 + Assert.AreEqual(16500, algorithm.GetDisplayStartTime(18000, 250, 2000, 500)); // 18000 - (1 + 250 / 500) * 2000 / 2 + Assert.AreEqual(17800, algorithm.GetDisplayStartTime(20000, 50, 1000, 500)); // 20000 - (1 + 50 / 500) * 1000 / 0.5 + Assert.AreEqual(19800, algorithm.GetDisplayStartTime(22000, 50, 1000, 500)); // 22000 - (1 + 50 / 500) * 1000 / 0.5 } [Test] diff --git a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs index 1f0c069f8d..bd578dcbc4 100644 --- a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs @@ -29,8 +29,22 @@ namespace osu.Game.Tests.ScrollAlgorithms [Test] public void TestDisplayStartTime() { - // Sequential scroll algorithm approximates the start time - // This should be fixed in the future + // easy cases - time range adjusted for velocity fits within control point duration + Assert.AreEqual(2500, algorithm.GetDisplayStartTime(5000, 0, 2500, 1)); // 5000 - (2500 / 1) + Assert.AreEqual(13750, algorithm.GetDisplayStartTime(15000, 0, 2500, 1)); // 15000 - (2500 / 2) + Assert.AreEqual(20000, algorithm.GetDisplayStartTime(25000, 0, 2500, 1)); // 25000 - (2500 / 0.5) + + // hard case - time range adjusted for velocity exceeds control point duration + + // 1st multiplier point takes 10000 / 2500 = 4 scroll lengths + // 2nd multiplier point takes 10000 / (2500 / 2) = 8 scroll lengths + // 3rd multiplier point takes 2500 / (2500 * 2) = 0.5 scroll lengths up to hitobject start + + // absolute position of the hitobject = 1000 * (4 + 8 + 0.5) = 12500 + // minus one scroll length allowance = 12500 - 1000 = 11500 = 11.5 [scroll lengths] + // therefore the start time lies within the second multiplier point (because 11.5 < 4 + 8) + // its exact time position is = 10000 + 7.5 * (2500 / 2) = 19375 + Assert.AreEqual(19375, algorithm.GetDisplayStartTime(22500, 0, 2500, 1000)); } [Test] diff --git a/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs new file mode 100644 index 0000000000..e811979aed --- /dev/null +++ b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.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 NUnit.Framework; +using osu.Game.IO; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; +using osuTK.Graphics; + +namespace osu.Game.Tests.Skins +{ + [TestFixture] + public class LegacyManiaSkinDecoderTest + { + [Test] + public void TestParseSingleConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-single.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseMultipleConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-multiple.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(2)); + + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + + Assert.That(configs[1].Keys, Is.EqualTo(2)); + Assert.That(configs[1].ColumnWidth, Is.EquivalentTo(new float[] { 32, 32 })); + Assert.That(configs[1].HitPosition, Is.EqualTo(32)); + } + } + + [Test] + public void TestParseDuplicateConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-single.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseWithUnnecessaryExtraData() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-extra-data.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseColours() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-colours.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].CustomColours, Contains.Key("ColourBarline").And.ContainValue(new Color4(50, 50, 50, 50))); + } + } + + [Test] + public void TestMinimumColumnWidthFallsBackWhenZeroIsProvided() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-zero-minwidth.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].MinimumColumnWidth, Is.EqualTo(16)); + } + } + } +} diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index cef38bbbb8..c408d2f182 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -106,7 +106,17 @@ namespace osu.Game.Tests.Skins var decoder = new LegacySkinDecoder(); using (var resStream = TestResources.OpenResource("skin-empty.ini")) using (var stream = new LineBufferedReader(resStream)) - Assert.IsNull(decoder.Decode(stream).LegacyVersion); + Assert.That(decoder.Decode(stream).LegacyVersion, Is.EqualTo(1.0m)); + } + + [Test] + public void TestDecodeColourWithZeroAlpha() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("skin-zero-alpha-colour.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.That(decoder.Decode(stream).ComboColours[0].A, Is.EqualTo(1.0f)); } } } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs new file mode 100644 index 0000000000..4d3b73fb32 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.IO.Archives; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public class TestSceneBeatmapSkinResources : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private WorkingBeatmap beatmap; + + [BackgroundDependencyLoader] + private void load() + { + var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).Result; + beatmap = beatmaps.GetWorkingBeatmap(imported.Beatmaps[0]); + } + + [Test] + public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo("sample")) != null); + + [Test] + public void TestRetrieveOggTrack() => AddAssert("track is non-null", () => !(beatmap.Track is TrackVirtual)); + } +} diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index ed54cc982d..ad5b3ec0f6 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.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.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -9,10 +10,14 @@ 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.IO; +using osu.Game.Rulesets.Osu; using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -22,15 +27,15 @@ namespace osu.Game.Tests.Skins [HeadlessTest] public class TestSceneSkinConfigurationLookup : OsuTestScene { - private SkinSource source1; - private SkinSource source2; + private UserSkinSource userSource; + private BeatmapSkinSource beatmapSource; private SkinRequester requester; [SetUp] public void SetUp() => Schedule(() => { - Add(new SkinProvidingContainer(source1 = new SkinSource()) - .WithChild(new SkinProvidingContainer(source2 = new SkinSource()) + Add(new SkinProvidingContainer(userSource = new UserSkinSource()) + .WithChild(new SkinProvidingContainer(beatmapSource = new BeatmapSkinSource()) .WithChild(requester = new SkinRequester()))); }); @@ -39,31 +44,31 @@ namespace osu.Game.Tests.Skins { AddStep("Add config values", () => { - source1.Configuration.ConfigDictionary["Lookup"] = "source1"; - source2.Configuration.ConfigDictionary["Lookup"] = "source2"; + userSource.Configuration.ConfigDictionary["Lookup"] = "user skin"; + beatmapSource.Configuration.ConfigDictionary["Lookup"] = "beatmap skin"; }); - AddAssert("Check lookup finds source2", () => requester.GetConfig("Lookup")?.Value == "source2"); + AddAssert("Check lookup finds beatmap skin", () => requester.GetConfig("Lookup")?.Value == "beatmap skin"); } [Test] public void TestFloatLookup() { - AddStep("Add config values", () => source1.Configuration.ConfigDictionary["FloatTest"] = "1.1"); + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["FloatTest"] = "1.1"); AddAssert("Check float parse lookup", () => requester.GetConfig("FloatTest")?.Value == 1.1f); } [Test] public void TestBoolLookup() { - AddStep("Add config values", () => source1.Configuration.ConfigDictionary["BoolTest"] = "1"); + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["BoolTest"] = "1"); AddAssert("Check bool parse lookup", () => requester.GetConfig("BoolTest")?.Value == true); } [Test] public void TestEnumLookup() { - AddStep("Add config values", () => source1.Configuration.ConfigDictionary["Test"] = "Test2"); + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["Test"] = "Test2"); AddAssert("Check enum parse lookup", () => requester.GetConfig(LookupType.Test)?.Value == ValueType.Test2); } @@ -76,7 +81,7 @@ namespace osu.Game.Tests.Skins [Test] public void TestLookupNull() { - AddStep("Add config values", () => source1.Configuration.ConfigDictionary["Lookup"] = null); + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["Lookup"] = null); AddAssert("Check lookup null", () => { @@ -88,20 +93,20 @@ namespace osu.Game.Tests.Skins [Test] public void TestColourLookup() { - AddStep("Add config colour", () => source1.Configuration.CustomColours["Lookup"] = Color4.Red); + AddStep("Add config colour", () => userSource.Configuration.CustomColours["Lookup"] = Color4.Red); AddAssert("Check colour lookup", () => requester.GetConfig(new SkinCustomColourLookup("Lookup"))?.Value == Color4.Red); } [Test] public void TestGlobalLookup() { - AddAssert("Check combo colours", () => requester.GetConfig>(GlobalSkinConfiguration.ComboColours)?.Value?.Count > 0); + AddAssert("Check combo colours", () => requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.Count > 0); } [Test] public void TestWrongColourType() { - AddStep("Add config colour", () => source1.Configuration.CustomColours["Lookup"] = Color4.Red); + AddStep("Add config colour", () => userSource.Configuration.CustomColours["Lookup"] = Color4.Red); AddAssert("perform incorrect lookup", () => { @@ -121,32 +126,57 @@ namespace osu.Game.Tests.Skins public void TestEmptyComboColours() { AddAssert("Check retrieved combo colours is skin default colours", () => - requester.GetConfig>(GlobalSkinConfiguration.ComboColours)?.Value?.SequenceEqual(SkinConfiguration.DefaultComboColours) ?? false); + requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(SkinConfiguration.DefaultComboColours) ?? false); } [Test] public void TestEmptyComboColoursNoFallback() { - AddStep("Add custom combo colours to source1", () => source1.Configuration.AddComboColours( + AddStep("Add custom combo colours to user skin", () => userSource.Configuration.AddComboColours( new Color4(100, 150, 200, 255), new Color4(55, 110, 166, 255), new Color4(75, 125, 175, 255) )); - AddStep("Disallow default colours fallback in source2", () => source2.Configuration.AllowDefaultComboColoursFallback = false); + AddStep("Disallow default colours fallback in beatmap skin", () => beatmapSource.Configuration.AllowDefaultComboColoursFallback = false); - AddAssert("Check retrieved combo colours from source1", () => - requester.GetConfig>(GlobalSkinConfiguration.ComboColours)?.Value?.SequenceEqual(source1.Configuration.ComboColours) ?? false); + AddAssert("Check retrieved combo colours from user skin", () => + requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(userSource.Configuration.ComboColours) ?? false); } [Test] - public void TestLegacyVersionLookup() + public void TestNullBeatmapVersionFallsBackToUserSkin() { - AddStep("Set source1 version 2.3", () => source1.Configuration.LegacyVersion = 2.3m); - AddStep("Set source2 version null", () => source2.Configuration.LegacyVersion = null); + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = 2.3m); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = null); AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 2.3m); } + [Test] + public void TestSetBeatmapVersionFallsBackToUserSkin() + { + // completely ignoring beatmap versions for simplicity. + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = 2.3m); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = 1.7m); + AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 2.3m); + } + + [Test] + public void TestNullBeatmapAndUserVersionFallsBackToLatest() + { + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = null); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = null); + AddAssert("Check legacy version lookup", + () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == LegacySkinConfiguration.LATEST_VERSION); + } + + [Test] + public void TestIniWithNoVersionFallsBackTo1() + { + AddStep("Parse skin with no version", () => userSource.Configuration = new LegacySkinDecoder().Decode(new LineBufferedReader(new MemoryStream()))); + AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 1.0m); + } + public enum LookupType { Test @@ -159,14 +189,22 @@ namespace osu.Game.Tests.Skins Test3 } - public class SkinSource : LegacySkin + public class UserSkinSource : LegacySkin { - public SkinSource() + public UserSkinSource() : base(new SkinInfo(), null, null, string.Empty) { } } + public class BeatmapSkinSource : LegacyBeatmapSkin + { + public BeatmapSkinSource() + : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null, null) + { + } + } + public class SkinRequester : Drawable, ISkin { private ISkinSource skin; @@ -179,7 +217,7 @@ namespace osu.Game.Tests.Skins public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); - public Texture GetTexture(string componentName) => skin.GetTexture(componentName); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); public SampleChannel GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs new file mode 100644 index 0000000000..107a96292f --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.IO.Archives; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public class TestSceneSkinResources : OsuTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + private ISkin skin; + + [BackgroundDependencyLoader] + private void load() + { + var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result; + skin = skins.GetSkin(imported); + } + + [Test] + public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo("sample")) != null); + } +} diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs new file mode 100644 index 0000000000..80f1b02794 --- /dev/null +++ b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Configuration.Tracking; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Testing +{ + /// + /// A test scene ensuring the dependencies for the + /// provided ruleset below are cached at the base implementation. + /// + [HeadlessTest] + public class TestSceneRulesetDependencies : OsuTestScene + { + protected override Ruleset CreateRuleset() => new TestRuleset(); + + [Test] + public void TestRetrieveTexture() + { + AddAssert("ruleset texture retrieved", () => + Dependencies.Get().Get(@"test-image") != null); + } + + [Test] + public void TestRetrieveSample() + { + AddAssert("ruleset sample retrieved", () => + Dependencies.Get().Get(@"test-sample") != null); + } + + [Test] + public void TestResolveConfigManager() + { + AddAssert("ruleset config resolved", () => + Dependencies.Get() != null); + } + + private class TestRuleset : Ruleset + { + public override string Description => string.Empty; + public override string ShortName => string.Empty; + + public TestRuleset() + { + // temporary ID to let RulesetConfigCache pass our + // config manager to the ruleset dependencies. + RulesetInfo.ID = -1; + } + + public override IResourceStore CreateResourceStore() => new NamespacedResourceStore(TestResources.GetStore(), @"Resources"); + 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(WorkingBeatmap beatmap) => null; + } + + private class TestRulesetConfigManager : IRulesetConfigManager + { + public void Load() + { + } + + public bool Save() => true; + + public TrackedSettings CreateTrackedSettings() => new TrackedSettings(); + + public void LoadInto(TrackedSettings settings) + { + } + + public void Dispose() + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 589ec7e8aa..19294d12fc 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using NUnit.Framework; @@ -21,13 +19,16 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; @@ -36,20 +37,11 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Background { [TestFixture] - public class TestSceneUserDimBackgrounds : ManualInputManagerTestScene + public class TestSceneUserDimBackgrounds : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ScreenWithBeatmapBackground), - typeof(PlayerLoader), - typeof(Player), - typeof(UserDimContainer), - typeof(OsuScreen) - }; - private DummySongSelect songSelect; private TestPlayerLoader playerLoader; - private TestPlayer player; + private LoadBlockingTestPlayer player; private BeatmapManager manager; private RulesetStore rulesets; @@ -68,20 +60,20 @@ namespace osu.Game.Tests.Visual.Background [SetUp] public virtual void SetUp() => Schedule(() => { - Child = new OsuScreenStack(songSelect = new DummySongSelect()) - { - RelativeSizeAxes = Axes.Both - }; + var stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + Child = stack; + + stack.Push(songSelect = new DummySongSelect()); }); /// /// Check if properly triggers the visual settings preview when a user hovers over the visual settings panel. /// [Test] - public void PlayerLoaderSettingsHoverTest() + public void TestPlayerLoaderSettingsHover() { setupUserSettings(); - AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new TestPlayer { BlockLoad = true }))); + AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new LoadBlockingTestPlayer { BlockLoad = true }))); AddUntilStep("Wait for Player Loader to load", () => playerLoader?.IsLoaded ?? false); AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); AddStep("Trigger background preview", () => @@ -89,11 +81,9 @@ namespace osu.Game.Tests.Visual.Background InputManager.MoveMouseTo(playerLoader.ScreenPos); InputManager.MoveMouseTo(playerLoader.VisualSettingsPos); }); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Stop background preview", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); } /// @@ -102,20 +92,19 @@ namespace osu.Game.Tests.Visual.Background /// We need to check that in this scenario, the dim and blur is still properly applied after entering player. /// [Test] - public void PlayerLoaderTransitionTest() + public void TestPlayerLoaderTransition() { performFullSetup(); AddStep("Trigger hover event", () => playerLoader.TriggerOnHover()); AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); } /// /// Make sure the background is fully invisible (Alpha == 0) when the background should be disabled by the storyboard. /// [Test] - public void StoryboardBackgroundVisibilityTest() + public void TestStoryboardBackgroundVisibility() { performFullSetup(); createFakeStoryboard(); @@ -124,52 +113,46 @@ namespace osu.Game.Tests.Visual.Background player.ReplacesBackground.Value = true; player.StoryboardEnabled.Value = true; }); - waitForDim(); - AddAssert("Background is invisible, storyboard is visible", () => songSelect.IsBackgroundInvisible() && player.IsStoryboardVisible); + AddUntilStep("Background is invisible, storyboard is visible", () => songSelect.IsBackgroundInvisible() && player.IsStoryboardVisible); AddStep("Disable Storyboard", () => { player.ReplacesBackground.Value = false; player.StoryboardEnabled.Value = false; }); - waitForDim(); - AddAssert("Background is visible, storyboard is invisible", () => songSelect.IsBackgroundVisible() && !player.IsStoryboardVisible); + AddUntilStep("Background is visible, storyboard is invisible", () => songSelect.IsBackgroundVisible() && !player.IsStoryboardVisible); } /// /// When exiting player, the screen that it suspends/exits to needs to have a fully visible (Alpha == 1) background. /// [Test] - public void StoryboardTransitionTest() + public void TestStoryboardTransition() { performFullSetup(); createFakeStoryboard(); AddStep("Exit to song select", () => player.Exit()); - waitForDim(); - AddAssert("Background is visible", () => songSelect.IsBackgroundVisible()); + AddUntilStep("Background is visible", () => songSelect.IsBackgroundVisible()); } /// /// Ensure is properly accepting user-defined visual changes for a background. /// [Test] - public void DisableUserDimBackgroundTest() + public void TestDisableUserDimBackground() { performFullSetup(); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Enable user dim", () => songSelect.DimEnabled.Value = false); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsUserBlurDisabled()); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsUserBlurDisabled()); AddStep("Disable user dim", () => songSelect.DimEnabled.Value = true); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); } /// /// Ensure is properly accepting user-defined visual changes for a storyboard. /// [Test] - public void DisableUserDimStoryboardTest() + public void TestDisableUserDimStoryboard() { performFullSetup(); createFakeStoryboard(); @@ -180,41 +163,42 @@ namespace osu.Game.Tests.Visual.Background }); AddStep("Enable user dim", () => player.DimmableStoryboard.EnableUserDim.Value = true); AddStep("Set dim level to 1", () => songSelect.DimLevel.Value = 1f); - waitForDim(); - AddAssert("Storyboard is invisible", () => !player.IsStoryboardVisible); + AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); AddStep("Disable user dim", () => player.DimmableStoryboard.EnableUserDim.Value = false); - waitForDim(); - AddAssert("Storyboard is visible", () => player.IsStoryboardVisible); + AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); } /// /// Check if the visual settings container retains dim and blur when pausing /// [Test] - public void PauseTest() + public void TestPause() { performFullSetup(true); AddStep("Pause", () => player.Pause()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Unpause", () => player.Resume()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); } /// - /// Check if the visual settings container removes user dim when suspending for + /// Check if the visual settings container removes user dim when suspending for /// [Test] - public void TransitionTest() + public void TestTransition() { performFullSetup(); + FadeAccessibleResults results = null; - AddStep("Transition to Results", () => player.Push(results = - new FadeAccessibleResults(new ScoreInfo { User = new User { Username = "osu!" } }))); + + AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo + { + User = new User { Username = "osu!" }, + Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo + }))); + AddUntilStep("Wait for results is current", () => results.IsCurrentScreen()); - waitForDim(); - AddAssert("Screen is undimmed, original background retained", () => + AddUntilStep("Screen is undimmed, original background retained", () => songSelect.IsBackgroundUndimmed() && songSelect.IsBackgroundCurrent() && results.IsBlurCorrect()); } @@ -222,32 +206,27 @@ namespace osu.Game.Tests.Visual.Background /// Check if background gets undimmed and unblurred when leaving for /// [Test] - public void TransitionOutTest() + public void TestTransitionOut() { performFullSetup(); AddStep("Exit to song select", () => player.Exit()); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsBlurCorrect()); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsBlurCorrect()); } /// /// Check if hovering on the visual settings dialogue after resuming from player still previews the background dim. /// [Test] - public void ResumeFromPlayerTest() + public void TestResumeFromPlayer() { performFullSetup(); AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); AddStep("Resume PlayerLoader", () => player.Restart()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); } - private void waitForDim() => AddWaitStep("Wait for dim", 5); - private void createFakeStoryboard() => AddStep("Create storyboard", () => { player.StoryboardEnabled.Value = false; @@ -268,7 +247,7 @@ namespace osu.Game.Tests.Visual.Background { setupUserSettings(); - AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new TestPlayer(allowPause)))); + AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new LoadBlockingTestPlayer(allowPause)))); AddUntilStep("Wait for Player Loader to load", () => playerLoader.IsLoaded); AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); @@ -277,7 +256,8 @@ namespace osu.Game.Tests.Visual.Background private void setupUserSettings() { - AddUntilStep("Song select has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen()); + AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmap != null); AddStep("Set default user settings", () => { SelectedMods.Value = SelectedMods.Value.Concat(new[] { new OsuModNoFail() }).ToArray(); @@ -302,8 +282,8 @@ namespace osu.Game.Tests.Visual.Background } public readonly Bindable DimEnabled = new Bindable(); - public readonly Bindable DimLevel = new Bindable(); - public readonly Bindable BlurLevel = new Bindable(); + public readonly Bindable DimLevel = new BindableDouble(); + public readonly Bindable BlurLevel = new BindableDouble(); public new BeatmapCarousel Carousel => base.Carousel; @@ -335,7 +315,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundCurrent() => ((FadeAccessibleBackground)Background).IsCurrentScreen(); } - private class FadeAccessibleResults : SoloResults + private class FadeAccessibleResults : ResultsScreen { public FadeAccessibleResults(ScoreInfo score) : base(score) @@ -347,7 +327,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); } - private class TestPlayer : Visual.TestPlayer + private class LoadBlockingTestPlayer : TestPlayer { protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); @@ -360,7 +340,7 @@ namespace osu.Game.Tests.Visual.Background public readonly Bindable ReplacesBackground = new Bindable(); public readonly Bindable IsPaused = new Bindable(); - public TestPlayer(bool allowPause = true) + public LoadBlockingTestPlayer(bool allowPause = true) : base(allowPause) { } diff --git a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs index 55aaeed8bf..4d64c7d35d 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Components { [TestFixture] - public class TestSceneIdleTracker : ManualInputManagerTestScene + public class TestSceneIdleTracker : OsuManualInputManagerTestScene { private IdleTrackingBox box1; private IdleTrackingBox box2; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs new file mode 100644 index 0000000000..6cf5e6a987 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneBeatDivisorControl : OsuManualInputManagerTestScene + { + private BeatDivisorControl beatDivisorControl; + private BindableBeatDivisor bindableBeatDivisor; + + private SliderBar tickSliderBar; + private EquilateralTriangle tickMarkerHead; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = beatDivisorControl = new BeatDivisorControl(bindableBeatDivisor = new BindableBeatDivisor(16)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(90, 90) + }; + + tickSliderBar = beatDivisorControl.ChildrenOfType>().Single(); + tickMarkerHead = tickSliderBar.ChildrenOfType().Single(); + }); + + [Test] + public void TestBindableBeatDivisor() + { + AddRepeatStep("move previous", () => bindableBeatDivisor.Previous(), 4); + AddAssert("divisor is 4", () => bindableBeatDivisor.Value == 4); + AddRepeatStep("move next", () => bindableBeatDivisor.Next(), 3); + AddAssert("divisor is 12", () => bindableBeatDivisor.Value == 12); + } + + [Test] + public void TestMouseInput() + { + AddStep("hold marker", () => + { + InputManager.MoveMouseTo(tickMarkerHead.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("move to 8 and release", () => + { + InputManager.MoveMouseTo(tickSliderBar.ScreenSpaceDrawQuad.Centre); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor is 8", () => bindableBeatDivisor.Value == 8); + AddStep("hold marker", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move to 16", () => InputManager.MoveMouseTo(getPositionForDivisor(16))); + AddStep("move to ~10 and release", () => + { + InputManager.MoveMouseTo(getPositionForDivisor(10)); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); + } + + private Vector2 getPositionForDivisor(int divisor) + { + var relativePosition = (float)Math.Clamp(divisor, 0, 16) / 16; + var sliderDrawQuad = tickSliderBar.ScreenSpaceDrawQuad; + return new Vector2( + sliderDrawQuad.TopLeft.X + sliderDrawQuad.Width * relativePosition, + sliderDrawQuad.Centre.Y + ); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneComposeScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs similarity index 89% rename from osu.Game.Tests/Visual/Editor/TestSceneComposeScreen.cs rename to osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs index 3562689482..6f5655006e 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneComposeScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs @@ -3,17 +3,19 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneComposeScreen : EditorClockTestScene { [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap { diff --git a/osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs similarity index 70% rename from osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs rename to osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 847d168e51..8190cf5f89 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -13,7 +13,7 @@ using osu.Game.Screens.Edit.Compose.Components; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { public class TestSceneDistanceSnapGrid : EditorClockTestScene { @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.Editor [Cached(typeof(EditorBeatmap))] private readonly EditorBeatmap editorBeatmap; - [Cached(typeof(IDistanceSnapProvider))] + [Cached(typeof(IPositionSnapProvider))] private readonly SnapProvider snapProvider = new SnapProvider(); public TestSceneDistanceSnapGrid() @@ -85,64 +85,64 @@ namespace osu.Game.Tests.Visual.Editor { } - protected override void CreateContent(Vector2 startPosition) + protected override void CreateContent() { AddInternal(new Circle { Origin = Anchor.Centre, Size = new Vector2(5), - Position = startPosition + Position = StartPosition }); - int beatIndex = 0; + int indexFromPlacement = 0; - for (float s = startPosition.X + DistanceSpacing; s <= DrawWidth && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++) + for (float s = StartPosition.X + DistanceSpacing; s <= DrawWidth && indexFromPlacement < MaxIntervals; s += DistanceSpacing, indexFromPlacement++) { AddInternal(new Circle { Origin = Anchor.Centre, Size = new Vector2(5, 10), - Position = new Vector2(s, startPosition.Y), - Colour = GetColourForBeatIndex(beatIndex) + Position = new Vector2(s, StartPosition.Y), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) }); } - beatIndex = 0; + indexFromPlacement = 0; - for (float s = startPosition.X - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++) + for (float s = StartPosition.X - DistanceSpacing; s >= 0 && indexFromPlacement < MaxIntervals; s -= DistanceSpacing, indexFromPlacement++) { AddInternal(new Circle { Origin = Anchor.Centre, Size = new Vector2(5, 10), - Position = new Vector2(s, startPosition.Y), - Colour = GetColourForBeatIndex(beatIndex) + Position = new Vector2(s, StartPosition.Y), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) }); } - beatIndex = 0; + indexFromPlacement = 0; - for (float s = startPosition.Y + DistanceSpacing; s <= DrawHeight && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++) + for (float s = StartPosition.Y + DistanceSpacing; s <= DrawHeight && indexFromPlacement < MaxIntervals; s += DistanceSpacing, indexFromPlacement++) { AddInternal(new Circle { Origin = Anchor.Centre, Size = new Vector2(10, 5), - Position = new Vector2(startPosition.X, s), - Colour = GetColourForBeatIndex(beatIndex) + Position = new Vector2(StartPosition.X, s), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) }); } - beatIndex = 0; + indexFromPlacement = 0; - for (float s = startPosition.Y - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++) + for (float s = StartPosition.Y - DistanceSpacing; s >= 0 && indexFromPlacement < MaxIntervals; s -= DistanceSpacing, indexFromPlacement++) { AddInternal(new Circle { Origin = Anchor.Centre, Size = new Vector2(10, 5), - Position = new Vector2(startPosition.X, s), - Colour = GetColourForBeatIndex(beatIndex) + Position = new Vector2(StartPosition.X, s), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) }); } } @@ -151,9 +151,9 @@ namespace osu.Game.Tests.Visual.Editor => (Vector2.Zero, 0); } - private class SnapProvider : IDistanceSnapProvider + private class SnapProvider : IPositionSnapProvider { - public (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) => (position, time); + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public float GetBeatSnapDistanceAt(double referenceTime) => 10; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs new file mode 100644 index 0000000000..293a6e6869 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorChangeStates : EditorTestScene + { + private EditorBeatmap editorBeatmap; + + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("get beatmap", () => editorBeatmap = Editor.ChildrenOfType().Single()); + } + + [Test] + public void TestUndoFromInitialState() + { + int hitObjectCount = 0; + + AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count); + + addUndoSteps(); + + AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); + } + + [Test] + public void TestRedoFromInitialState() + { + int hitObjectCount = 0; + + AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count); + + addRedoSteps(); + + AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); + } + + [Test] + public void TestAddObjectAndUndo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + editorBeatmap.HitObjectAdded += h => addedObject = h; + editorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddAssert("hitobject added", () => addedObject == expectedObject); + + addUndoSteps(); + AddAssert("hitobject removed", () => removedObject == expectedObject); + } + + [Test] + public void TestAddObjectThenUndoThenRedo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + editorBeatmap.HitObjectAdded += h => addedObject = h; + editorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + addUndoSteps(); + + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addRedoSteps(); + AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) + AddAssert("no hitobject removed", () => removedObject == null); + } + + [Test] + public void TestRemoveObjectThenUndo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + editorBeatmap.HitObjectAdded += h => addedObject = h; + editorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddStep("remove object", () => editorBeatmap.Remove(expectedObject)); + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addUndoSteps(); + AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) + AddAssert("no hitobject removed", () => removedObject == null); + } + + [Test] + public void TestRemoveObjectThenUndoThenRedo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + editorBeatmap.HitObjectAdded += h => addedObject = h; + editorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddStep("remove object", () => editorBeatmap.Remove(expectedObject)); + addUndoSteps(); + + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addRedoSteps(); + AddAssert("hitobject removed", () => removedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance after undo) + AddAssert("no hitobject added", () => addedObject == null); + } + + private void addUndoSteps() => AddStep("undo", () => ((TestEditor)Editor).Undo()); + + private void addRedoSteps() => AddStep("redo", () => ((TestEditor)Editor).Redo()); + + protected override Editor CreateEditor() => new TestEditor(); + + private class TestEditor : Editor + { + public new void Undo() => base.Undo(); + + public new void Redo() => base.Redo(); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs similarity index 87% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 1709067d5d..e4d7e025a8 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -1,19 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Screens.Edit.Components.RadioButtons; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorComposeRadioButtons : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(DrawableRadioButton) }; - public TestSceneEditorComposeRadioButtons() { RadioButtonCollection collection; diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs similarity index 95% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs index 53c2d62067..3cb44d9ae8 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,13 +8,11 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Edit.Components.Menus; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorMenuBar : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(EditorMenuBar), typeof(ScreenSelectionTabControl) }; - public TestSceneEditorMenuBar() { Add(new Container diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs similarity index 99% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index 3118e0cabe..3a19eabe81 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -13,7 +13,7 @@ using osu.Game.Rulesets.Osu.Objects; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorSeekSnapping : EditorClockTestScene @@ -175,13 +175,13 @@ namespace osu.Game.Tests.Visual.Editor AddAssert("Time = 50", () => Clock.CurrentTime == 50); AddStep("Seek(49.999)", () => Clock.Seek(49.999)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); + 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 == 100); + AddAssert("Time = 100", () => Clock.CurrentTime == 150); AddStep("Seek(174)", () => Clock.Seek(174)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); AddAssert("Time = 175", () => Clock.CurrentTime == 175); diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs similarity index 81% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 2e04eb50ca..3adc1bd425 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,13 +8,11 @@ using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorSummaryTimeline : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(SummaryTimeline) }; - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs similarity index 72% rename from osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs rename to osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index c001c83877..7ca24346aa 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; -using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Timing; @@ -13,31 +11,15 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Edit; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneHitObjectComposer : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SelectionHandler), - typeof(DragBox), - typeof(HitObjectComposer), - typeof(OsuHitObjectComposer), - typeof(BlueprintContainer), - typeof(NotNullAttribute), - typeof(HitCirclePiece), - typeof(HitCircleSelectionBlueprint), - typeof(HitCirclePlacementBlueprint), - }; - [BackgroundDependencyLoader] private void load() { @@ -66,6 +48,7 @@ namespace osu.Game.Tests.Visual.Editor Dependencies.CacheAs(clock); Dependencies.CacheAs(clock); Dependencies.CacheAs(editorBeatmap); + Dependencies.CacheAs(editorBeatmap); Child = new OsuHitObjectComposer(new OsuRuleset()); } diff --git a/osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs similarity index 74% rename from osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs rename to osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs index 0d4fe4366d..6aa884a197 100644 --- a/osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs @@ -4,12 +4,12 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestScenePlaybackControl : OsuTestScene @@ -17,9 +17,8 @@ namespace osu.Game.Tests.Visual.Editor [BackgroundDependencyLoader] private void load() { - var clock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - Dependencies.CacheAs(clock); + var clock = new EditorClock { IsCoupled = false }; + Dependencies.CacheAs(clock); var playback = new PlaybackControl { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs new file mode 100644 index 0000000000..e931be044c --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Screens.Edit.Compose.Components.Timeline; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneTimelineBlueprintContainer : TimelineTestScene + { + public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(); + + protected override void LoadComplete() + { + base.LoadComplete(); + Clock.Seek(10000); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs new file mode 100644 index 0000000000..e33040acdc --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneTimelineTickDisplay : TimelineTestScene + { + public override Drawable CreateTestComponent() => new TimelineTickDisplay(); + + [BackgroundDependencyLoader] + private void load() + { + BeatDivisor.Value = 4; + + Add(new BeatDivisorControl(BeatDivisor) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(30), + Size = new Vector2(90) + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs similarity index 54% rename from osu.Game.Tests/Visual/Editor/TestSceneTimingScreen.cs rename to osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index adfed9a299..09f5ac2224 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -1,33 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneTimingScreen : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ControlPointTable), - typeof(ControlPointSettings), - typeof(Section<>), - typeof(TimingSection), - typeof(EffectSection), - typeof(SampleSection), - typeof(DifficultySection), - typeof(RowAttribute) - }; - [Cached(typeof(EditorBeatmap))] - private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap; + + public TestSceneTimingScreen() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + } [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs similarity index 98% rename from osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs rename to osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs index e2762f3d5f..0c1296b82c 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneWaveform : OsuTestScene diff --git a/osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs similarity index 97% rename from osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs rename to osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index da8702209c..082268d824 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -7,7 +7,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; @@ -15,9 +15,9 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { - public class TestSceneZoomableScrollContainer : ManualInputManagerTestScene + public class TestSceneZoomableScrollContainer : OsuManualInputManagerTestScene { private ZoomableScrollContainer scrollContainer; private Drawable innerBox; diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs similarity index 68% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs rename to osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index 29575cb42e..fdb8781563 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -1,47 +1,41 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using NUnit.Framework; 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.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { - [TestFixture] - public class TestSceneEditorComposeTimeline : EditorClockTestScene + public abstract class TimelineTestScene : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TimelineArea), - typeof(TimelineHitObjectDisplay), - typeof(Timeline), - typeof(TimelineButton), - typeof(CentreMarker) - }; + protected TimelineArea TimelineArea { get; private set; } [BackgroundDependencyLoader] private void load(AudioManager audio) { Beatmap.Value = new WaveformTestBeatmap(audio); - var editorBeatmap = new EditorBeatmap((Beatmap)Beatmap.Value.Beatmap); + var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); - Children = new Drawable[] + var editorBeatmap = new EditorBeatmap(playable); + + Dependencies.Cache(editorBeatmap); + Dependencies.CacheAs(editorBeatmap); + + AddRange(new Drawable[] { + editorBeatmap, new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -53,17 +47,19 @@ namespace osu.Game.Tests.Visual.Editor new AudioVisualiser(), } }, - new TimelineArea + TimelineArea = new TimelineArea { - Child = new TimelineHitObjectDisplay(editorBeatmap), + Child = CreateTestComponent(), Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, - Size = new Vector2(0.8f, 100) + Size = new Vector2(0.8f, 100), } - }; + }); } + public abstract Drawable CreateTestComponent(); + private class AudioVisualiser : CompositeDrawable { private readonly Drawable marker; @@ -72,7 +68,7 @@ namespace osu.Game.Tests.Visual.Editor private IBindable beatmap { get; set; } [Resolved] - private IAdjustableClock adjustableClock { get; set; } + private EditorClock editorClock { get; set; } public AudioVisualiser() { @@ -99,13 +95,15 @@ namespace osu.Game.Tests.Visual.Editor base.Update(); if (beatmap.Value.Track.IsLoaded) - marker.X = (float)(adjustableClock.CurrentTime / beatmap.Value.Track.Length); + marker.X = (float)(editorClock.CurrentTime / beatmap.Value.Track.Length); } } private class StartStopButton : OsuButton { - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } + private bool started; public StartStopButton() @@ -117,22 +115,16 @@ namespace osu.Game.Tests.Visual.Editor Action = onClick; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) - { - this.adjustableClock = adjustableClock; - } - private void onClick() { if (started) { - adjustableClock.Stop(); + editorClock.Stop(); Text = "Start"; } else { - adjustableClock.Start(); + editorClock.Start(); Text = "Stop"; } diff --git a/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.cs deleted file mode 100644 index 7531a7be2c..0000000000 --- a/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components; -using osuTK; - -namespace osu.Game.Tests.Visual.Editor -{ - public class TestSceneBeatDivisorControl : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] { typeof(BindableBeatDivisor) }; - - [BackgroundDependencyLoader] - private void load() - { - Child = new BeatDivisorControl(new BindableBeatDivisor()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(90, 90) - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs new file mode 100644 index 0000000000..cbf8515567 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.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.Game.Rulesets; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Tests.Visual.Gameplay +{ + /// + /// A with an arbitrary ruleset value to test with. + /// + public abstract class OsuPlayerTestScene : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game/Tests/Visual/AllPlayersTestScene.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs similarity index 58% rename from osu.Game/Tests/Visual/AllPlayersTestScene.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index dd65c8c382..b7dcad3825 100644 --- a/osu.Game/Tests/Visual/AllPlayersTestScene.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -2,49 +2,65 @@ // 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.Screens; using osu.Game.Configuration; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Play; -namespace osu.Game.Tests.Visual +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 AllPlayersTestScene : RateAdjustedBeatmapTestScene + public abstract class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene { protected Player Player; [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - foreach (var r in rulesets.AvailableRulesets) - { - Player p = null; - AddStep(r.Name, () => p = loadPlayerFor(r)); - AddUntilStep("player loaded", () => - { - if (p?.IsLoaded == true) - { - p = null; - return true; - } - - return false; - }); - - AddCheckSteps(); - } - OsuConfigManager manager; Dependencies.Cache(manager = new OsuConfigManager(LocalStorage)); manager.GetBindable(OsuSetting.DimLevel).Value = 1.0; } + [Test] + public void TestOsu() => runForRuleset(new OsuRuleset().RulesetInfo); + + [Test] + public void TestTaiko() => runForRuleset(new TaikoRuleset().RulesetInfo); + + [Test] + public void TestCatch() => runForRuleset(new CatchRuleset().RulesetInfo); + + [Test] + public void TestMania() => runForRuleset(new ManiaRuleset().RulesetInfo); + + private void runForRuleset(RulesetInfo ruleset) + { + Player p = null; + AddStep($"load {ruleset.Name} player", () => p = loadPlayerFor(ruleset)); + AddUntilStep("player loaded", () => + { + if (p?.IsLoaded == true) + { + p = null; + return true; + } + + return false; + }); + + AddCheckSteps(); + } + protected abstract void AddCheckSteps(); private Player loadPlayerFor(RulesetInfo rulesetInfo) @@ -57,9 +73,6 @@ namespace osu.Game.Tests.Visual Beatmap.Value = working; SelectedMods.Value = new[] { ruleset.GetAllMods().First(m => m is ModNoFail) }; - Player?.Exit(); - Player = null; - Player = CreatePlayer(ruleset); LoadScreen(Player); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 069b965d9b..4743317fdd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -3,53 +3,75 @@ using System.ComponentModel; using System.Linq; -using osu.Game.Beatmaps; +using osu.Framework.Testing; +using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; -using osu.Game.Storyboards; +using osu.Game.Screens.Play.Break; +using osu.Game.Screens.Ranking; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with an autoplay mod.")] - public class TestSceneAutoplay : AllPlayersTestScene + public class TestSceneAutoplay : TestSceneAllRulesetPlayers { - private ClockBackedTestWorkingBeatmap.TrackVirtualManual track; + protected new TestPlayer Player => (TestPlayer)base.Player; protected override Player CreatePlayer(Ruleset ruleset) { - SelectedMods.Value = SelectedMods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); - return new ScoreAccessiblePlayer(); + SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; + return new TestPlayer(false); } protected override void AddCheckSteps() { - AddUntilStep("score above zero", () => ((ScoreAccessiblePlayer)Player).ScoreProcessor.TotalScore.Value > 0); - AddUntilStep("key counter counted keys", () => ((ScoreAccessiblePlayer)Player).HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 2)); - AddStep("rewind", () => track.Seek(-10000)); - AddUntilStep("key counter reset", () => ((ScoreAccessiblePlayer)Player).HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + 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); + 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)); + + double? time = null; + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + + // test seek via keyboard + AddStep("seek with right arrow key", () => press(Key.Right)); + AddAssert("time seeked forward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime > time + 2000); + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + AddStep("seek with left arrow key", () => press(Key.Left)); + AddAssert("time seeked backward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < time); + + seekToBreak(0); + seekToBreak(1); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + AddUntilStep("results displayed", () => getResultsScreen() != null); + + AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); + AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0); + + ResultsScreen getResultsScreen() => Stack.CurrentScreen as ResultsScreen; } - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + private void seekToBreak(int breakIndex) { - var working = base.CreateWorkingBeatmap(beatmap, storyboard); + AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime)); + AddUntilStep("wait for seek to complete", () => Player.HUDOverlay.Progress.ReferenceClock.CurrentTime >= destBreak().StartTime); - track = (ClockBackedTestWorkingBeatmap.TrackVirtualManual)working.Track; - - return working; + BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); } - private class ScoreAccessiblePlayer : TestPlayer + private void press(Key key) { - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - public new HUDOverlay HUDOverlay => base.HUDOverlay; - - public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - - public ScoreAccessiblePlayer() - : base(false, false) - { - } + InputManager.PressKey(key); + InputManager.ReleaseKey(key); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs similarity index 74% rename from osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs index 19dce303ea..be17721b88 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Beatmaps.Timing; using osu.Game.Screens.Play; @@ -12,32 +12,35 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneBreakOverlay : OsuTestScene + public class TestSceneBreakTracker : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(BreakOverlay), - }; + private readonly BreakOverlay breakOverlay; - private readonly TestBreakOverlay breakOverlay; + private readonly TestBreakTracker breakTracker; private readonly IReadOnlyList testBreaks = new List { - new BreakPeriod - { - StartTime = 1000, - EndTime = 5000, - }, - new BreakPeriod - { - StartTime = 6000, - EndTime = 13500, - }, + new BreakPeriod(1000, 5000), + new BreakPeriod(6000, 13500), }; - public TestSceneBreakOverlay() + public TestSceneBreakTracker() { - Add(breakOverlay = new TestBreakOverlay(true)); + AddRange(new Drawable[] + { + breakTracker = new TestBreakTracker(), + breakOverlay = new BreakOverlay(true, null) + { + ProcessCustomClock = false, + } + }); + } + + protected override void Update() + { + base.Update(); + + breakOverlay.Clock = breakTracker.Clock; } [Test] @@ -53,7 +56,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNoEffectsBreak() { - var shortBreak = new BreakPeriod { EndTime = 500 }; + var shortBreak = new BreakPeriod(0, 500); setClock(true); loadBreaksStep("short break", new[] { shortBreak }); @@ -88,8 +91,6 @@ namespace osu.Game.Tests.Visual.Gameplay loadBreaksStep("multiple breaks", testBreaks); seekAndAssertBreak("seek to break start", testBreaks[1].StartTime, true); - AddAssert("is skipped to break #2", () => breakOverlay.CurrentBreakIndex == 1); - seekAndAssertBreak("seek to break middle", testBreaks[1].StartTime + testBreaks[1].Duration / 2, true); seekAndAssertBreak("seek to break end", testBreaks[1].EndTime, false); seekAndAssertBreak("seek to break after end", testBreaks[1].EndTime + 500, false); @@ -110,24 +111,23 @@ namespace osu.Game.Tests.Visual.Gameplay private void addShowBreakStep(double seconds) { - AddStep($"show '{seconds}s' break", () => breakOverlay.Breaks = new List + AddStep($"show '{seconds}s' break", () => { - new BreakPeriod + breakOverlay.Breaks = breakTracker.Breaks = new List { - StartTime = Clock.CurrentTime, - EndTime = Clock.CurrentTime + seconds * 1000, - } + new BreakPeriod(Clock.CurrentTime, Clock.CurrentTime + seconds * 1000) + }; }); } private void setClock(bool useManual) { - AddStep($"set {(useManual ? "manual" : "realtime")} clock", () => breakOverlay.SwitchClock(useManual)); + AddStep($"set {(useManual ? "manual" : "realtime")} clock", () => breakTracker.SwitchClock(useManual)); } private void loadBreaksStep(string breakDescription, IReadOnlyList breaks) { - AddStep($"load {breakDescription}", () => breakOverlay.Breaks = breaks); + AddStep($"load {breakDescription}", () => breakOverlay.Breaks = breakTracker.Breaks = breaks); seekAndAssertBreak("seek back to 0", 0, false); } @@ -151,42 +151,40 @@ namespace osu.Game.Tests.Visual.Gameplay private void seekAndAssertBreak(string seekStepDescription, double time, bool shouldBeBreak) { - AddStep(seekStepDescription, () => breakOverlay.ManualClockTime = time); + AddStep(seekStepDescription, () => breakTracker.ManualClockTime = time); AddAssert($"is{(!shouldBeBreak ? " not" : string.Empty)} break time", () => { - breakOverlay.ProgressTime(); - return breakOverlay.IsBreakTime.Value == shouldBeBreak; + breakTracker.ProgressTime(); + return breakTracker.IsBreakTime.Value == shouldBeBreak; }); } - private class TestBreakOverlay : BreakOverlay + private class TestBreakTracker : BreakTracker { - private readonly FramedClock framedManualClock; + public readonly FramedClock FramedManualClock; + private readonly ManualClock manualClock; private IFrameBasedClock originalClock; - public new int CurrentBreakIndex => base.CurrentBreakIndex; - public double ManualClockTime { get => manualClock.CurrentTime; set => manualClock.CurrentTime = value; } - public TestBreakOverlay(bool letterboxing) - : base(letterboxing) + public TestBreakTracker() { - framedManualClock = new FramedClock(manualClock = new ManualClock()); + FramedManualClock = new FramedClock(manualClock = new ManualClock()); ProcessCustomClock = false; } public void ProgressTime() { - framedManualClock.ProcessFrame(); + FramedManualClock.ProcessFrame(); Update(); } - public void SwitchClock(bool setManual) => Clock = setManual ? framedManualClock : originalClock; + public void SwitchClock(bool setManual) => Clock = setManual ? FramedManualClock : originalClock; protected override void LoadComplete() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs new file mode 100644 index 0000000000..79275d70a7 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Storyboards; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneCompletionCancellation : OsuPlayerTestScene + { + private Track track; + + [Resolved] + private AudioManager audio { get; set; } + + private int resultsDisplayWaitCount => + (int)((Screens.Play.Player.RESULTS_DISPLAY_DELAY / TimePerAction) * 2); + + protected override bool AllowFail => false; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + // Ensure track has actually running before attempting to seek + AddUntilStep("wait for track to start running", () => track.IsRunning); + } + + [Test] + public void TestCancelCompletionOnRewind() + { + complete(); + cancel(); + + checkNoRanking(); + } + + [Test] + public void TestReCompleteAfterCancellation() + { + complete(); + cancel(); + complete(); + + AddUntilStep("attempted to push ranking", () => ((FakeRankingPushPlayer)Player).GotoRankingInvoked); + } + + /// + /// Tests whether can still pause after cancelling completion by reverting back to true. + /// + [Test] + public void TestCanPauseAfterCancellation() + { + complete(); + cancel(); + + AddStep("pause", () => Player.Pause()); + AddAssert("paused successfully", () => Player.GameplayClockContainer.IsPaused.Value); + + checkNoRanking(); + } + + private void complete() + { + AddStep("seek to completion", () => track.Seek(5000)); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + } + + private void cancel() + { + AddStep("rewind to cancel", () => track.Seek(4000)); + AddUntilStep("completion cleared by processor", () => !Player.ScoreProcessor.HasCompleted.Value); + } + + private void checkNoRanking() + { + // wait to ensure there was no attempt of pushing the results screen. + AddWaitStep("wait", resultsDisplayWaitCount); + AddAssert("no attempt to push ranking", () => !((FakeRankingPushPlayer)Player).GotoRankingInvoked); + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + var working = new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audio); + track = working.Track; + return working; + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap(); + + for (int i = 1; i <= 19; i++) + { + beatmap.HitObjects.Add(new HitCircle + { + Position = new Vector2(256, 192), + StartTime = i * 250, + }); + } + + return beatmap; + } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new FakeRankingPushPlayer(); + + public class FakeRankingPushPlayer : TestPlayer + { + public bool GotoRankingInvoked; + + public FakeRankingPushPlayer() + : base(true, true) + { + } + + protected override void GotoRanking() + { + GotoRankingInvoked = true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index 36235a4418..bd7e894cf8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; @@ -280,7 +281,7 @@ namespace osu.Game.Tests.Visual.Gameplay yield return new TestHitObject { StartTime = original.StartTime, - EndTime = (original as IHasEndTime)?.EndTime ?? (original.StartTime + 100) + Duration = (original as IHasDuration)?.Duration ?? 100 }; } } @@ -289,11 +290,11 @@ namespace osu.Game.Tests.Visual.Gameplay #region HitObject - private class TestHitObject : HitObject, IHasEndTime + private class TestHitObject : ConvertHitObject, IHasDuration { - public double EndTime { get; set; } + public double EndTime => StartTime + Duration; - public double Duration => EndTime - StartTime; + public double Duration { get; set; } } private class DrawableTestHitObject : DrawableHitObject diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 81050b1637..85aaf20a19 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -10,7 +9,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailAnimation : AllPlayersTestScene + public class TestSceneFailAnimation : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { @@ -18,13 +17,6 @@ namespace osu.Game.Tests.Visual.Gameplay return new FailPlayer(); } - public override IReadOnlyList RequiredTypes => new[] - { - typeof(AllPlayersTestScene), - typeof(TestPlayer), - typeof(Player), - }; - protected override void AddCheckSteps() { AddUntilStep("wait for fail", () => Player.HasFailed); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs index 2045072c79..d80efb2c6e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs @@ -10,7 +10,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailJudgement : AllPlayersTestScene + public class TestSceneFailJudgement : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs new file mode 100644 index 0000000000..1c55595c97 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneFailingLayer : OsuTestScene + { + private FailingLayer layer; + + private readonly Bindable showHealth = new Bindable(); + + [Resolved] + private OsuConfigManager config { get; set; } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create layer", () => + { + Child = layer = new FailingLayer(); + layer.BindHealthProcessor(new DrainingHealthProcessor(1)); + layer.ShowHealth.BindTo(showHealth); + }); + + AddStep("show health", () => showHealth.Value = true); + AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer is visible", () => layer.IsPresent); + } + + [Test] + public void TestLayerFading() + { + AddSliderStep("current health", 0.0, 1.0, 1.0, val => + { + if (layer != null) + layer.Current.Value = val; + }); + + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer fade is visible", () => layer.Child.Alpha > 0.1f); + AddStep("set health to 1", () => layer.Current.Value = 1f); + AddUntilStep("layer fade is invisible", () => !layer.Child.IsPresent); + } + + [Test] + public void TestLayerDisabledViaConfig() + { + AddStep("disable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer is not visible", () => !layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithAccumulatingProcessor() + { + AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new AccumulatingHealthProcessor(1))); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer is not visible", () => !layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithDrainingProcessor() + { + AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new DrainingHealthProcessor(1))); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddWaitStep("wait for potential fade", 10); + AddAssert("layer is still visible", () => layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithDifferentOptions() + { + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + + AddStep("don't show health", () => showHealth.Value = false); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("don't show health", () => showHealth.Value = false); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is visible", () => layer.IsPresent); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs index c1635ffc83..e8b8c7c8e9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -18,10 +17,8 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("player pause/fail screens")] - public class TestSceneGameplayMenuOverlay : ManualInputManagerTestScene + public class TestSceneGameplayMenuOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(FailOverlay), typeof(PauseOverlay) }; - 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 5336c720a1..2a119f5199 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -1,39 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; using osu.Game.Storyboards; using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneGameplayRewinding : PlayerTestScene + public class TestSceneGameplayRewinding : OsuPlayerTestScene { - private RulesetExposingPlayer player => (RulesetExposingPlayer)Player; - [Resolved] private AudioManager audioManager { get; set; } - public TestSceneGameplayRewinding() - : base(new OsuRuleset()) - { - } - private Track track; protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) @@ -48,13 +35,13 @@ namespace osu.Game.Tests.Visual.Gameplay { AddUntilStep("wait for track to start running", () => 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)); - AddStep("clear results", () => player.AppliedResults.Clear()); + 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)); + 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)); - AddAssert("no results triggered", () => player.AppliedResults.Count == 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)); + AddAssert("no results triggered", () => Player.Results.Count == 0); } private void addSeekStep(double time) @@ -62,13 +49,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep($"seek to {time}", () => track.Seek(time)); // Allow a few frames of lenience - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { SelectedMods.Value = SelectedMods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); - return new RulesetExposingPlayer(); + return base.CreatePlayer(ruleset); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) @@ -89,29 +76,5 @@ namespace osu.Game.Tests.Visual.Gameplay return beatmap; } - - private class RulesetExposingPlayer : Player - { - public readonly List AppliedResults = new List(); - - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - - public new HUDOverlay HUDOverlay => base.HUDOverlay; - - public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - - public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; - - public RulesetExposingPlayer() - : base(false, false) - { - } - - [BackgroundDependencyLoader] - private void load() - { - ScoreProcessor.NewJudgement += r => AppliedResults.Add(r); - } - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index ee58219cd3..c192a7b0e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -2,20 +2,26 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneHUDOverlay : ManualInputManagerTestScene + public class TestSceneHUDOverlay : OsuManualInputManagerTestScene { private HUDOverlay hudOverlay; - private Drawable hideTarget => hudOverlay.KeyCounter; // best way of checking hideTargets without exposing. + // best way to check without exposing. + private Drawable hideTarget => hudOverlay.KeyCounter; + private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); [Resolved] private OsuConfigManager config { get; set; } @@ -28,6 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("showhud is set", () => hudOverlay.ShowHud.Value); AddAssert("hidetarget is visible", () => hideTarget.IsPresent); + AddAssert("key counter flow is visible", () => keyCounterFlow.IsPresent); AddAssert("pause button is visible", () => hudOverlay.HoldToQuit.IsPresent); } @@ -50,6 +57,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); AddAssert("pause button is still visible", () => hudOverlay.HoldToQuit.IsPresent); + + // Key counter flow container should not be affected by this, only the key counter display will be hidden as checked above. + AddAssert("key counter flow not affected", () => keyCounterFlow.IsPresent); } [Test] @@ -68,12 +78,40 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("config unchanged", () => originalConfigValue == config.Get(OsuSetting.ShowInterface)); } + [Test] + public void TestChangeHUDVisibilityOnHiddenKeyCounter() + { + bool keyCounterVisibleValue = false; + + createNew(); + AddStep("save keycounter visible value", () => keyCounterVisibleValue = config.Get(OsuSetting.KeyOverlay)); + + AddStep("set keycounter visible false", () => + { + config.Set(OsuSetting.KeyOverlay, false); + hudOverlay.KeyCounter.AlwaysVisible.Value = false; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + AddAssert("key counters hidden", () => !keyCounterFlow.IsPresent); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); + AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); + + AddStep("return value", () => config.Set(OsuSetting.KeyOverlay, keyCounterVisibleValue)); + } + private void createNew(Action action = null) { AddStep("create overlay", () => { Child = hudOverlay = new HUDOverlay(null, null, null, Array.Empty()); + // Add any key just to display the key counter visually. + hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + action?.Invoke(hudOverlay); }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBarHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs similarity index 67% rename from osu.Game.Tests/Visual/Gameplay/TestSceneBarHitErrorMeter.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index e3688c276f..253b8d9c55 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBarHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -2,16 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Game.Rulesets.Objects; -using System; -using System.Collections.Generic; using osu.Game.Rulesets.Judgements; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Scoring; @@ -19,18 +18,15 @@ using osu.Game.Screens.Play.HUD.HitErrorMeters; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneBarHitErrorMeter : OsuTestScene + public class TestSceneHitErrorMeter : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(HitErrorMeter), - }; - - private HitErrorMeter meter; - private HitErrorMeter meter2; + private BarHitErrorMeter barMeter; + private BarHitErrorMeter barMeter2; + private ColourHitErrorMeter colourMeter; + private ColourHitErrorMeter colourMeter2; private HitWindows hitWindows; - public TestSceneBarHitErrorMeter() + public TestSceneHitErrorMeter() { recreateDisplay(new OsuHitWindows(), 5); @@ -39,6 +35,22 @@ namespace osu.Game.Tests.Visual.Gameplay AddRepeatStep("New max negative", () => newJudgement(-hitWindows.WindowFor(HitResult.Meh)), 20); AddRepeatStep("New max positive", () => newJudgement(hitWindows.WindowFor(HitResult.Meh)), 20); AddStep("New fixed judgement (50ms)", () => newJudgement(50)); + + AddStep("Judgement barrage", () => + { + int runCount = 0; + + ScheduledDelegate del = null; + + del = Scheduler.AddDelayed(() => + { + newJudgement(runCount++ / 10f); + + if (runCount == 500) + // ReSharper disable once AccessToModifiedClosure + del?.Cancel(); + }, 10, true); + }); } [Test] @@ -91,17 +103,31 @@ namespace osu.Game.Tests.Visual.Gameplay } }); - Add(meter = new BarHitErrorMeter(hitWindows, true) + Add(barMeter = new BarHitErrorMeter(hitWindows, true) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); - Add(meter2 = new BarHitErrorMeter(hitWindows, false) + Add(barMeter2 = new BarHitErrorMeter(hitWindows, false) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }); + + Add(colourMeter = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 50 } + }); + + Add(colourMeter2 = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 50 } + }); } private void newJudgement(double offset = 0) @@ -112,8 +138,10 @@ namespace osu.Game.Tests.Visual.Gameplay Type = HitResult.Perfect, }; - meter.OnNewJudgement(judgement); - meter2.OnNewJudgement(judgement); + barMeter.OnNewJudgement(judgement); + barMeter2.OnNewJudgement(judgement); + colourMeter.OnNewJudgement(judgement); + colourMeter2.OnNewJudgement(judgement); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs index 0c5ead10cf..235842acc9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs @@ -13,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("'Hold to Quit' UI element")] - public class TestSceneHoldForMenuButton : ManualInputManagerTestScene + public class TestSceneHoldForMenuButton : OsuManualInputManagerTestScene { private bool exitAction; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs new file mode 100644 index 0000000000..db65e91d17 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.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.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public class TestSceneKeyBindings : OsuManualInputManagerTestScene + { + private readonly ActionReceiver receiver; + + public TestSceneKeyBindings() + { + Add(new TestKeyBindingContainer + { + Child = receiver = new ActionReceiver() + }); + } + + [Test] + public void TestDefaultsWhenNotDatabased() + { + AddStep("fire key", () => + { + InputManager.PressKey(Key.A); + InputManager.ReleaseKey(Key.A); + }); + + AddAssert("received key", () => receiver.ReceivedAction); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => + throw new System.NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => + throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) + { + return new[] + { + new KeyBinding(InputKey.A, TestAction.Down), + }; + } + + public override string Description => "test"; + public override string ShortName => "test"; + } + + private enum TestAction + { + Down, + } + + private class TestKeyBindingContainer : DatabasedKeyBindingContainer + { + public TestKeyBindingContainer() + : base(new TestRuleset().RulesetInfo, 0) + { + } + } + + private class ActionReceiver : CompositeDrawable, IKeyBindingHandler + { + public bool ReceivedAction; + + public bool OnPressed(TestAction action) + { + ReceivedAction = action == TestAction.Down; + return true; + } + + public void OnReleased(TestAction action) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs index ad747e88e1..d7a3f80256 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs @@ -1,27 +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 NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneKeyCounter : ManualInputManagerTestScene + public class TestSceneKeyCounter : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(KeyCounterKeyboard), - typeof(KeyCounterMouse), - typeof(KeyCounterDisplay) - }; - public TestSceneKeyCounter() { KeyCounterKeyboard testCounter; @@ -47,21 +38,22 @@ namespace osu.Game.Tests.Visual.Gameplay Key testKey = ((KeyCounterKeyboard)kc.Children.First()).Key; - AddStep($"Press {testKey} key", () => + void addPressKeyStep() { - InputManager.PressKey(testKey); - InputManager.ReleaseKey(testKey); - }); + AddStep($"Press {testKey} key", () => + { + InputManager.PressKey(testKey); + InputManager.ReleaseKey(testKey); + }); + } + addPressKeyStep(); AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses == 1); - - AddStep($"Press {testKey} key", () => - { - InputManager.PressKey(testKey); - InputManager.ReleaseKey(testKey); - }); - + 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(kc); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 0150c6ea74..563d6be0da 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -5,7 +5,7 @@ using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs index 41722b430e..0ada3cf05f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs @@ -1,11 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Game.Overlays; -using osu.Game.Overlays.MedalSplash; using osu.Game.Users; namespace osu.Game.Tests.Visual.Gameplay @@ -13,12 +10,6 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneMedalOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MedalOverlay), - typeof(DrawableMedal), - }; - public TestSceneMedalOverlay() { AddStep(@"display", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs index 3473b03eaf..951ee1489d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps.Timing; @@ -15,11 +13,6 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneNightcoreBeatContainer : TestSceneBeatSyncedContainer { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ModNightcore<>) - }; - protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 1a83e35e4f..1961a224c1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -10,15 +10,13 @@ using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePause : PlayerTestScene + public class TestScenePause : OsuPlayerTestScene { protected new PausePlayer Player => (PausePlayer)base.Player; @@ -27,7 +25,6 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Container Content => content; public TestScenePause() - : base(new OsuRuleset()) { base.Content.Add(content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }); } @@ -36,6 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override void SetUpSteps() { base.SetUpSteps(); + AddStep("resume player", () => Player.GameplayClockContainer.Start()); confirmClockRunning(true); } @@ -176,9 +174,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestExitFromGameplay() { - AddStep("exit", () => Player.Exit()); - confirmPaused(); - + // an externally triggered exit should immediately exit, skipping all pause logic. AddStep("exit", () => Player.Exit()); confirmExited(); } @@ -281,14 +277,10 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool AllowFail => true; - protected override Player CreatePlayer(Ruleset ruleset) => new PausePlayer(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new PausePlayer(); protected class PausePlayer : TestPlayer { - public new HealthProcessor HealthProcessor => base.HealthProcessor; - - public new HUDOverlay HUDOverlay => base.HUDOverlay; - public bool FailOverlayVisible => FailOverlay.State.Value == Visibility.Visible; public bool PauseOverlayVisible => PauseOverlay.State.Value == Visibility.Visible; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs index 3513b6c25a..e43e5ba3ce 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs @@ -8,16 +8,12 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [HeadlessTest] // we alter unsafe properties on the game host to test inactive window state. - public class TestScenePauseWhenInactive : PlayerTestScene + public class TestScenePauseWhenInactive : OsuPlayerTestScene { - protected new TestPlayer Player => (TestPlayer)base.Player; - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = (Beatmap)base.CreateBeatmap(ruleset); @@ -30,11 +26,6 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private GameHost host { get; set; } - public TestScenePauseWhenInactive() - : base(new OsuRuleset()) - { - } - [Test] public void TestDoesntPauseDuringIntro() { @@ -46,6 +37,6 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("time of pause is after gameplay start time", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= Player.DrawableRuleset.GameplayStartTime); } - protected override Player CreatePlayer(Ruleset ruleset) => new TestPlayer(true, true, true); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, true, true); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index f68f4b8b83..4c73065087 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -9,10 +9,9 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Screens; using osu.Game.Configuration; using osu.Game.Graphics.Containers; @@ -30,7 +29,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerLoader : ManualInputManagerTestScene + public class TestScenePlayerLoader : OsuManualInputManagerTestScene { private TestPlayerLoader loader; private TestPlayerLoaderContainer container; @@ -91,9 +90,44 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("load dummy beatmap", () => ResetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddRepeatStep("move mouse", () => InputManager.MoveMouseTo(loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) * RNG.NextSingle()), 20); + + AddUntilStep("wait for load ready", () => + { + moveMouse(); + return player.LoadState == LoadState.Ready; + }); + AddRepeatStep("move mouse", moveMouse, 20); + AddAssert("loader still active", () => loader.IsCurrentScreen()); AddUntilStep("loads after idle", () => !loader.IsCurrentScreen()); + + void moveMouse() + { + InputManager.MoveMouseTo( + loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) + * RNG.NextSingle()); + } + } + + [Test] + public void TestBlockLoadViaFocus() + { + OsuFocusedOverlayContainer overlay = null; + + AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddStep("show focused overlay", () => { container.Add(overlay = new ChangelogOverlay { State = { Value = Visibility.Visible } }); }); + AddUntilStep("overlay visible", () => overlay.IsPresent); + + AddUntilStep("wait for load ready", () => player.LoadState == LoadState.Ready); + AddRepeatStep("twiddle thumbs", () => { }, 20); + + AddAssert("loader still active", () => loader.IsCurrentScreen()); + + AddStep("hide overlay", () => overlay.Hide()); + AddUntilStep("loads after idle", () => !loader.IsCurrentScreen()); } [Test] @@ -147,13 +181,34 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestMutedNotificationMasterVolume() => addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, null, () => audioManager.Volume.IsDefault); + public void TestModDisplayChanges() + { + var testMod = new TestMod(); + + AddStep("load player", () => ResetPlayer(true)); + + AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); + AddStep("set test mod in loader", () => loader.Mods.Value = new[] { testMod }); + AddAssert("test mod is displayed", () => (TestMod)loader.DisplayedMods.Single() == testMod); + } [Test] - public void TestMutedNotificationTrackVolume() => addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, null, () => audioManager.VolumeTrack.IsDefault); + public void TestMutedNotificationMasterVolume() + { + addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, null, () => audioManager.Volume.IsDefault); + } [Test] - public void TestMutedNotificationMuteButton() => addVolumeSteps("mute button", null, () => container.VolumeOverlay.IsMuted.Value = true, () => !container.VolumeOverlay.IsMuted.Value); + public void TestMutedNotificationTrackVolume() + { + addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, null, () => audioManager.VolumeTrack.IsDefault); + } + + [Test] + public void TestMutedNotificationMuteButton() + { + addVolumeSteps("mute button", null, () => container.VolumeOverlay.IsMuted.Value = true, () => !container.VolumeOverlay.IsMuted.Value); + } /// /// Created for avoiding copy pasting code for the same steps. @@ -167,7 +222,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).Value = false); AddStep("load player", () => ResetPlayer(false, beforeLoad, afterLoad)); - AddUntilStep("wait for player", () => player.IsLoaded); + AddUntilStep("wait for player", () => player.LoadState == LoadState.Ready); AddAssert("check for notification", () => container.NotificationOverlay.UnreadCount.Value == 1); AddStep("click notification", () => @@ -181,6 +236,8 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddAssert("check " + volumeName, assert); + + AddUntilStep("wait for player load", () => player.IsLoaded); } private class TestPlayerLoaderContainer : Container @@ -195,9 +252,11 @@ namespace osu.Game.Tests.Visual.Gameplay { RelativeSizeAxes = Axes.Both; + OsuScreenStack stack; + InternalChildren = new Drawable[] { - new OsuScreenStack(screen) + stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both, }, @@ -212,6 +271,8 @@ namespace osu.Game.Tests.Visual.Gameplay Origin = Anchor.TopLeft, } }; + + stack.Push(screen); } } @@ -221,6 +282,8 @@ namespace osu.Game.Tests.Visual.Gameplay public new Task DisposalTask => base.DisposalTask; + public IReadOnlyList DisplayedMods => MetadataInfo.Mods.Value; + public TestPlayerLoader(Func createPlayer) : base(createPlayer) { @@ -243,17 +306,7 @@ namespace osu.Game.Tests.Visual.Gameplay public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; } - private class TestPlayer : Visual.TestPlayer - { - public new Bindable> Mods => base.Mods; - - public TestPlayer(bool allowPause = true, bool showResults = true) - : base(allowPause, showResults) - { - } - } - - protected class SlowLoadPlayer : Visual.TestPlayer + protected class SlowLoadPlayer : TestPlayer { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(false); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs index 4d701f56a9..8f767659c6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs @@ -10,7 +10,7 @@ using osu.Game.Storyboards; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerReferenceLeaking : AllPlayersTestScene + public class TestScenePlayerReferenceLeaking : TestSceneAllRulesetPlayers { private readonly WeakList workingWeakReferences = new WeakList(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index 36335bc54a..3a71d4ca54 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -13,13 +13,13 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with a replay.")] - public class TestSceneReplay : AllPlayersTestScene + public class TestSceneReplay : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { var beatmap = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty()); - return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod().CreateReplayScore(beatmap)); + return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap)); } protected override void AddCheckSteps() @@ -33,7 +33,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; public new HUDOverlay HUDOverlay => base.HUDOverlay; - public new bool AllowFail => base.AllowFail; + + public bool AllowFail => base.CheckModsAllowFailure(); protected override bool PauseOnFocusLost => false; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 8cb44de8cb..1809332bce 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -7,11 +7,9 @@ using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; using osu.Game.Users; -using System; -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Game.Rulesets; -using osu.Game.Screens.Ranking.Pages; +using osu.Game.Screens.Ranking; namespace osu.Game.Tests.Visual.Gameplay { @@ -21,11 +19,6 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private RulesetStore rulesets { get; set; } - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ReplayDownloadButton) - }; - private TestReplayDownloadButton downloadButton; public TestSceneReplayDownloadButton() @@ -35,6 +28,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep(@"locally available state", () => downloadButton.SetDownloadState(DownloadState.LocallyAvailable)); AddStep(@"not downloaded state", () => downloadButton.SetDownloadState(DownloadState.NotDownloaded)); createButton(false); + createButtonNoScore(); } private void createButton(bool withReplay) @@ -47,6 +41,22 @@ namespace osu.Game.Tests.Visual.Gameplay Origin = Anchor.Centre, }; }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); + } + + private void createButtonNoScore() + { + AddStep("create button with null score", () => + { + Child = downloadButton = new TestReplayDownloadButton(null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); } private ScoreInfo getScoreInfo(bool replayAvailable) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs new file mode 100644 index 0000000000..c7455583e4 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -0,0 +1,281 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Game.Graphics.Sprites; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneReplayRecorder : OsuManualInputManagerTestScene + { + private TestRulesetInputManager playbackManager; + private TestRulesetInputManager recordingManager; + + private Replay replay; + + private TestReplayRecorder recorder; + + [SetUp] + public void SetUp() => Schedule(() => + { + replay = new Replay(); + + Add(new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = recorder = new TestReplayRecorder(replay) + { + 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(new TestSceneModSettings.TestRulesetInfo(), 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() + } + }, + } + } + } + }); + }); + + [Test] + public void TestBasic() + { + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddUntilStep("one frame recorded", () => replay.Frames.Count == 1); + AddAssert("position matches", () => playbackManager.ChildrenOfType().First().Position == recordingManager.ChildrenOfType().First().Position); + } + + [Test] + public void TestHighFrameRate() + { + ScheduledDelegate moveFunction = null; + + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move", () => moveFunction = Scheduler.AddDelayed(() => + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + } + + [Test] + public void TestLimitedFrameRate() + { + ScheduledDelegate moveFunction = null; + + AddStep("lower rate", () => recorder.RecordFrameRate = 2); + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move", () => moveFunction = Scheduler.AddDelayed(() => + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("less than 10 frames recorded", () => replay.Frames.Count < 10); + } + + [Test] + public void TestLimitedFrameRateWithImportantFrames() + { + ScheduledDelegate moveFunction = null; + + AddStep("lower rate", () => recorder.RecordFrameRate = 2); + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move with press", () => moveFunction = Scheduler.AddDelayed(() => + { + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)); + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }, 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + } + + protected override void Update() + { + base.Update(); + playbackManager?.ReplayInputHandler.SetFrameFromTime(Time.Current - 100); + } + + public class TestFramedReplayInputHandler : FramedReplayInputHandler + { + public TestFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + public override List GetPendingInputs() + { + return new List + { + new MousePositionAbsoluteInput + { + Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) + }, + new ReplayState + { + PressedActions = CurrentFrame?.Actions ?? new List() + } + }; + } + } + + public class TestInputConsumer : CompositeDrawable, IKeyBindingHandler + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); + + private readonly Box box; + + public TestInputConsumer() + { + Size = new Vector2(30); + + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Position = e.MousePosition; + return base.OnMouseMove(e); + } + + public bool OnPressed(TestAction action) + { + box.Colour = Color4.White; + return true; + } + + public void OnReleased(TestAction action) + { + box.Colour = Color4.Black; + } + } + + public class TestRulesetInputManager : RulesetInputManager + { + public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + : base(ruleset, variant, unique) + { + } + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + => new TestKeyBindingContainer(); + + internal class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.MouseLeft, TestAction.Down), + }; + } + } + + public class TestReplayFrame : ReplayFrame + { + public Vector2 Position; + + public List Actions = new List(); + + public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) + : base(time) + { + Position = position; + Actions.AddRange(actions); + } + } + + public enum TestAction + { + Down, + } + + internal class TestReplayRecorder : ReplayRecorder + { + public TestReplayRecorder(Replay target) + : base(target) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs new file mode 100644 index 0000000000..7822f07957 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs @@ -0,0 +1,220 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Game.Graphics.Sprites; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneReplayRecording : OsuTestScene + { + private readonly TestRulesetInputManager playbackManager; + + private readonly TestRulesetInputManager recordingManager; + + public TestSceneReplayRecording() + { + Replay replay = new Replay(); + + Add(new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = new TestReplayRecorder(replay) + { + 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 TestConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 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 TestConsumer() + } + }, + } + } + } + }); + } + + protected override void Update() + { + base.Update(); + + playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500); + } + } + + public class TestFramedReplayInputHandler : FramedReplayInputHandler + { + public TestFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + public override List GetPendingInputs() + { + return new List + { + new MousePositionAbsoluteInput + { + Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) + }, + new ReplayState + { + PressedActions = CurrentFrame?.Actions ?? new List() + } + }; + } + } + + public class TestConsumer : CompositeDrawable, IKeyBindingHandler + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); + + private readonly Box box; + + public TestConsumer() + { + Size = new Vector2(30); + + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Position = e.MousePosition; + return base.OnMouseMove(e); + } + + public bool OnPressed(TestAction action) + { + box.Colour = Color4.White; + return true; + } + + public void OnReleased(TestAction action) + { + box.Colour = Color4.Black; + } + } + + public class TestRulesetInputManager : RulesetInputManager + { + public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + : base(ruleset, variant, unique) + { + } + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + => new TestKeyBindingContainer(); + + internal class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.MouseLeft, TestAction.Down), + }; + } + } + + public class TestReplayFrame : ReplayFrame + { + public Vector2 Position; + + public List Actions = new List(); + + public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) + : base(time) + { + Position = position; + Actions.AddRange(actions); + } + } + + public enum TestAction + { + Down, + } + + internal class TestReplayRecorder : ReplayRecorder + { + public TestReplayRecorder(Replay target) + : base(target) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => + new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs deleted file mode 100644 index 7790126db5..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Screens; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Screens; -using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Pages; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneResults : ScreenTestScene - { - private BeatmapManager beatmaps; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Results), - typeof(ResultsPage), - typeof(ScoreResultsPage), - typeof(RetryButton), - typeof(ReplayDownloadButton), - typeof(LocalLeaderboardPage), - typeof(TestPlayer) - }; - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps) - { - this.beatmaps = beatmaps; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); - if (beatmapInfo != null) - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); - } - - private TestSoloResults createResultsScreen() => new TestSoloResults(new ScoreInfo - { - TotalScore = 2845370, - Accuracy = 0.98, - MaxCombo = 123, - Rank = ScoreRank.A, - Date = DateTimeOffset.Now, - Statistics = new Dictionary - { - { HitResult.Great, 50 }, - { HitResult.Good, 20 }, - { HitResult.Meh, 50 }, - { HitResult.Miss, 1 } - }, - User = new User - { - Username = "peppy", - } - }); - - [Test] - public void ResultsWithoutPlayer() - { - TestSoloResults screen = null; - - AddStep("load results", () => Child = new OsuScreenStack(screen = createResultsScreen()) - { - RelativeSizeAxes = Axes.Both - }); - AddUntilStep("wait for loaded", () => screen.IsLoaded); - AddAssert("retry overlay not present", () => screen.RetryOverlay == null); - } - - [Test] - public void ResultsWithPlayer() - { - TestSoloResults screen = null; - - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); - AddAssert("retry overlay present", () => screen.RetryOverlay != null); - } - - private class TestResultsContainer : Container - { - [Cached(typeof(Player))] - private readonly Player player = new TestPlayer(); - - public TestResultsContainer(IScreen screen) - { - RelativeSizeAxes = Axes.Both; - - InternalChild = new OsuScreenStack(screen) - { - RelativeSizeAxes = Axes.Both, - }; - } - } - - private class TestSoloResults : SoloResults - { - public HotkeyRetryOverlay RetryOverlay; - - public TestSoloResults(ScoreInfo score) - : base(score) - { - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - RetryOverlay = InternalChildren.OfType().SingleOrDefault(); - } - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs index 080a287b48..030d420ec0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs @@ -3,9 +3,6 @@ using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play.HUD; using osuTK; @@ -45,32 +42,12 @@ namespace osu.Game.Tests.Visual.Gameplay }; Add(accuracyCounter); - StarCounter stars = new StarCounter - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Position = new Vector2(20, -160), - CountStars = 5, - }; - Add(stars); - - SpriteText starsLabel = new OsuSpriteText - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Position = new Vector2(20, -190), - Text = stars.CountStars.ToString("0.00"), - }; - Add(starsLabel); - AddStep(@"Reset all", delegate { score.Current.Value = 0; comboCounter.Current.Value = 0; numerator = denominator = 0; accuracyCounter.SetFraction(0, 0); - stars.CountStars = 0; - starsLabel.Text = stars.CountStars.ToString("0.00"); }); AddStep(@"Hit! :D", delegate @@ -88,20 +65,6 @@ namespace osu.Game.Tests.Visual.Gameplay denominator++; accuracyCounter.SetFraction(numerator, denominator); }); - - AddStep(@"Alter stars", delegate - { - stars.CountStars = RNG.NextSingle() * (stars.StarCount + 1); - starsLabel.Text = stars.CountStars.ToString("0.00"); - }); - - AddStep(@"Stop counters", delegate - { - score.StopRolling(); - comboCounter.StopRolling(); - accuracyCounter.StopRolling(); - stars.StopAnimation(); - }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 8629522dc2..2f15e549f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -11,26 +11,27 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Timing; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] public class TestSceneScrollingHitObjects : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(Playfield) }; - [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); - private const int spawn_interval = 5000; + private const int time_range = 5000; + private const int spawn_rate = time_range / 10; private readonly ScrollingTestContainer[] scrollContainers = new ScrollingTestContainer[4]; private readonly TestPlayfield[] playfields = new TestPlayfield[4]; @@ -50,13 +51,13 @@ namespace osu.Game.Tests.Visual.Gameplay { RelativeSizeAxes = Axes.Both, Child = playfields[0] = new TestPlayfield(), - TimeRange = spawn_interval + TimeRange = time_range }, scrollContainers[1] = new ScrollingTestContainer(ScrollingDirection.Down) { RelativeSizeAxes = Axes.Both, Child = playfields[1] = new TestPlayfield(), - TimeRange = spawn_interval + TimeRange = time_range }, }, new Drawable[] @@ -65,50 +66,103 @@ namespace osu.Game.Tests.Visual.Gameplay { RelativeSizeAxes = Axes.Both, Child = playfields[2] = new TestPlayfield(), - TimeRange = spawn_interval + TimeRange = time_range }, scrollContainers[3] = new ScrollingTestContainer(ScrollingDirection.Right) { RelativeSizeAxes = Axes.Both, Child = playfields[3] = new TestPlayfield(), - TimeRange = spawn_interval + TimeRange = time_range } } } }; - setUpHitObjects(); + hitObjectSpawnDelegate?.Cancel(); }); - private void setUpHitObjects() + private void setUpHitObjects() => AddStep("set up hit objects", () => { scrollContainers.ForEach(c => c.ControlPoints.Add(new MultiplierControlPoint(0))); - for (int i = 0; i <= spawn_interval; i += 1000) + for (int i = spawn_rate / 2; i <= time_range; i += spawn_rate) addHitObject(Time.Current + i); - hitObjectSpawnDelegate?.Cancel(); - hitObjectSpawnDelegate = Scheduler.AddDelayed(() => addHitObject(Time.Current + spawn_interval), 1000, true); - } + hitObjectSpawnDelegate = Scheduler.AddDelayed(() => addHitObject(Time.Current + time_range), spawn_rate, true); + }); + + private IList testControlPoints => new List + { + new MultiplierControlPoint(time_range) { DifficultyPoint = { SpeedMultiplier = 1.25 } }, + new MultiplierControlPoint(1.5 * time_range) { DifficultyPoint = { SpeedMultiplier = 1 } }, + new MultiplierControlPoint(2 * time_range) { DifficultyPoint = { SpeedMultiplier = 1.5 } } + }; [Test] public void TestScrollAlgorithms() { - AddStep("Constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); - AddStep("Overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); - AddStep("Sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + setUpHitObjects(); - AddSliderStep("Time range", 100, 10000, spawn_interval, v => scrollContainers.Where(c => c != null).ForEach(c => c.TimeRange = v)); - AddStep("Add control point", () => addControlPoint(Time.Current + spawn_interval)); + AddStep("constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); + AddStep("overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); + AddStep("sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + + AddSliderStep("time range", 100, 10000, time_range, v => scrollContainers.Where(c => c != null).ForEach(c => c.TimeRange = v)); + + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } [Test] - public void TestScrollLifetime() + public void TestConstantScrollLifetime() { - AddStep("Set constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); + setUpHitObjects(); + + AddStep("set constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); // scroll container time range must be less than the rate of spawning hitobjects // otherwise the hitobjects will spawn already partly visible on screen and look wrong - AddStep("Set time range", () => scrollContainers.ForEach(c => c.TimeRange = spawn_interval / 2.0)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + } + + [Test] + public void TestSequentialScrollLifetime() + { + setUpHitObjects(); + + AddStep("set sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); + } + + [Test] + public void TestSlowSequentialScroll() + { + AddStep("set sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range)); + AddStep("add control points", () => addControlPoints( + new List + { + new MultiplierControlPoint { Velocity = 0.1 } + }, + Time.Current + time_range)); + + // All of the hit objects added below should be immediately visible on screen + AddStep("add hit objects", () => + { + for (int i = 0; i < 20; ++i) + { + addHitObject(Time.Current + time_range * (2 + 0.1 * i)); + } + }); + } + + [Test] + public void TestOverlappingScrollLifetime() + { + setUpHitObjects(); + + AddStep("set overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } private void addHitObject(double time) @@ -122,28 +176,27 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void addControlPoint(double time) + private TestDrawableControlPoint createDrawablePoint(TestPlayfield playfield, double t) { - scrollContainers.ForEach(c => + var obj = new TestDrawableControlPoint(playfield.Direction, t); + setAnchor(obj, playfield); + return obj; + } + + private void addControlPoints(IList controlPoints, double sequenceStartTime) + { + controlPoints.ForEach(point => point.StartTime += sequenceStartTime); + + scrollContainers.ForEach(container => { - c.ControlPoints.Add(new MultiplierControlPoint(time) { DifficultyPoint = { SpeedMultiplier = 3 } }); - c.ControlPoints.Add(new MultiplierControlPoint(time + 2000) { DifficultyPoint = { SpeedMultiplier = 2 } }); - c.ControlPoints.Add(new MultiplierControlPoint(time + 3000) { DifficultyPoint = { SpeedMultiplier = 1 } }); + container.ControlPoints.AddRange(controlPoints); }); - playfields.ForEach(p => + foreach (var playfield in playfields) { - TestDrawableControlPoint createDrawablePoint(double t) - { - var obj = new TestDrawableControlPoint(p.Direction, t); - setAnchor(obj, p); - return obj; - } - - p.Add(createDrawablePoint(time)); - p.Add(createDrawablePoint(time + 2000)); - p.Add(createDrawablePoint(time + 3000)); - }); + foreach (var controlPoint in controlPoints) + playfield.Add(createDrawablePoint(playfield, controlPoint.StartTime)); + } } private void setAnchor(DrawableHitObject obj, TestPlayfield playfield) @@ -198,7 +251,7 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestDrawableControlPoint : DrawableHitObject { public TestDrawableControlPoint(ScrollingDirection direction, double time) - : base(new HitObject { StartTime = time }) + : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) { Origin = Anchor.Centre; @@ -229,14 +282,18 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestDrawableHitObject : DrawableHitObject { public TestDrawableHitObject(double time) - : base(new HitObject { StartTime = time }) + : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) { Origin = Anchor.Custom; OriginPosition = new Vector2(75 / 4.0f); AutoSizeAxes = Axes.Both; - AddInternal(new Box { Size = new Vector2(75) }); + AddInternal(new Box + { + Size = new Vector2(75), + Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) + }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index ec94053679..bed48f3d86 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -10,6 +10,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.Shapes; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -43,16 +44,15 @@ namespace osu.Game.Tests.Visual.Gameplay { new ExposedSkinnableDrawable("default", _ => new DefaultBox(), _ => true), new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true), - new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.ScaleToFit), new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.NoScaling) } }, }; }); - AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 30, 50 })); + AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 50 })); AddStep("adjust scale", () => fill.Scale = new Vector2(2)); - AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 30, 50 })); + AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 50 })); } [Test] @@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual.Gameplay Children = new[] { new ExposedSkinnableDrawable("default", _ => new DefaultBox(), _ => true), - new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true), new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.ScaleToFit), new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.NoScaling) } @@ -82,9 +81,9 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); - AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 30, 50, 30 })); + AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 50, 30 })); AddStep("adjust scale", () => fill.Scale = new Vector2(2)); - AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 30, 50, 30 })); + AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 50, 30 })); } [Test] @@ -182,7 +181,7 @@ namespace osu.Game.Tests.Visual.Gameplay public new Drawable Drawable => base.Drawable; public ExposedSkinnableDrawable(string name, Func defaultImplementation, Func allowFallback = null, - ConfineMode confineMode = ConfineMode.ScaleDownToFit) + ConfineMode confineMode = ConfineMode.ScaleToFit) : base(new TestSkinComponent(name), defaultImplementation, allowFallback, confineMode) { } @@ -297,7 +296,7 @@ namespace osu.Game.Tests.Visual.Gameplay } : null; - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); @@ -308,7 +307,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public Drawable GetDrawableComponent(ISkinComponent componentName) => new SecondarySourceBox(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); @@ -320,7 +319,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public Drawable GetDrawableComponent(ISkinComponent componentName) => new BaseSourceBox(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 4c5c18f38a..7ed7a116b4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; @@ -14,9 +14,9 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneSkipOverlay : ManualInputManagerTestScene + public class TestSceneSkipOverlay : OsuManualInputManagerTestScene { - private SkipOverlay skip; + private TestSkipOverlay skip; private int requestCount; private double increment; @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Gameplay RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - skip = new SkipOverlay(skip_time) + skip = new TestSkipOverlay(skip_time) { RequestSkip = () => { @@ -56,19 +56,19 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestFadeOnIdle() { AddStep("move mouse", () => InputManager.MoveMouseTo(Vector2.Zero)); - AddUntilStep("fully visible", () => skip.Children.First().Alpha == 1); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha < 1); + AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("fully visible", () => skip.Children.First().Alpha == 1); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha < 1); + AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); } [Test] public void TestClickableAfterFade() { AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha == 0); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha == 0); AddStep("click", () => InputManager.Click(MouseButton.Left)); checkRequestCount(1); } @@ -105,13 +105,25 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("button down", () => InputManager.PressButton(MouseButton.Left)); - AddUntilStep("wait for overlay disappear", () => !skip.IsPresent); - AddAssert("ensure button didn't disappear", () => skip.Children.First().Alpha > 0); + AddUntilStep("wait for overlay disappear", () => !skip.OverlayContent.IsPresent); + AddAssert("ensure button didn't disappear", () => skip.FadingContent.Alpha > 0); AddStep("button up", () => InputManager.ReleaseButton(MouseButton.Left)); checkRequestCount(0); } private void checkRequestCount(int expected) => AddAssert($"request count is {expected}", () => requestCount == expected); + + private class TestSkipOverlay : SkipOverlay + { + public TestSkipOverlay(double startTime) + : base(startTime) + { + } + + public Drawable OverlayContent => InternalChild; + + public Drawable FadingContent => (OverlayContent as Container)?.Child; + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs index af21007efe..733e8f4290 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs @@ -5,8 +5,12 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +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.Screens.Play; @@ -15,63 +19,120 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneSongProgress : OsuTestScene { - private readonly SongProgress progress; - private readonly TestSongProgressGraph graph; + private SongProgress progress; + private TestSongProgressGraph graph; + private readonly Container progressContainer; private readonly StopwatchClock clock; + private readonly FramedClock framedClock; [Cached] private readonly GameplayClock gameplayClock; - private readonly FramedClock framedClock; - public TestSceneSongProgress() { - clock = new StopwatchClock(true); - + clock = new StopwatchClock(); gameplayClock = new GameplayClock(framedClock = new FramedClock(clock)); - Add(progress = new SongProgress + Add(progressContainer = new Container { RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 100, + Y = -100, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(1), + } }); - - Add(graph = new TestSongProgressGraph - { - RelativeSizeAxes = Axes.X, - Height = 200, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }); - - AddWaitStep("wait some", 5); - AddAssert("ensure not created", () => graph.CreationCount == 0); - - AddStep("display values", displayNewValues); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - - AddStep("Toggle Bar", () => progress.AllowSeeking = !progress.AllowSeeking); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - - AddStep("Toggle Bar", () => progress.AllowSeeking = !progress.AllowSeeking); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - AddRepeatStep("New Values", displayNewValues, 5); - - AddWaitStep("wait some", 5); - AddAssert("ensure debounced", () => graph.CreationCount == 2); } - private void displayNewValues() + [SetUpSteps] + public void SetupSteps() { - List objects = new List(); + AddStep("add new song progress", () => + { + if (progress != null) + { + progress.Expire(); + progress = null; + } + + progressContainer.Add(progress = new SongProgress + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }); + }); + + 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); + } + + [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); + } + + [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; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs new file mode 100644 index 0000000000..709e71d195 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneStarCounter : OsuTestScene + { + public TestSceneStarCounter() + { + StarCounter stars = new StarCounter + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Current = 5, + }; + + Add(stars); + + SpriteText starsLabel = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Scale = new Vector2(2), + Y = 50, + Text = stars.Current.ToString("0.00"), + }; + + Add(starsLabel); + + AddRepeatStep(@"random value", delegate + { + stars.Current = RNG.NextSingle() * (stars.StarCount + 1); + starsLabel.Text = stars.Current.ToString("0.00"); + }, 10); + + AddStep(@"Stop animation", delegate + { + stars.StopAnimation(); + }); + + AddStep(@"Reset", delegate + { + stars.Current = 0; + starsLabel.Text = stars.Current.ToString("0.00"); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index ff8437311e..9f1492a25f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -9,8 +9,12 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Overlays; +using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Resources; using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay @@ -54,7 +58,11 @@ namespace osu.Game.Tests.Visual.Gameplay State = { Value = Visibility.Visible }, } }); + } + [Test] + public void TestStoryboard() + { AddStep("Restart", restart); AddToggleStep("Passing", passing => { @@ -62,6 +70,12 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [Test] + public void TestStoryboardMissingVideo() + { + AddStep("Load storyboard with missing video", loadStoryboardNoVideo); + } + [BackgroundDependencyLoader] private void load() { @@ -94,5 +108,28 @@ namespace osu.Game.Tests.Visual.Gameplay 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(Beatmap.Value); + + storyboardContainer.Add(storyboard); + decoupledClock.ChangeSource(Beatmap.Value.Track); + } } } diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index d03d341ee4..f71d13ed35 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -18,21 +16,13 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public abstract class IntroTestScene : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(StartupScreen), - typeof(IntroScreen), - typeof(OsuScreen), - typeof(IntroTestScene), - }; - [Cached] private OsuLogo logo; + protected OsuScreenStack IntroStack; + protected IntroTestScene() { - Drawable introStack = null; - Children = new Drawable[] { new Box @@ -55,13 +45,17 @@ namespace osu.Game.Tests.Visual.Menus logo.FinishTransforms(); logo.IsTracking = false; - introStack?.Expire(); + IntroStack?.Expire(); - Add(introStack = new OsuScreenStack(CreateScreen()) + Add(IntroStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both, }); + + IntroStack.Push(CreateScreen()); }); + + AddUntilStep("wait for menu", () => IntroStack.CurrentScreen is MainMenu); } protected abstract IScreen CreateScreen(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs index 681bf1b40b..49fab08ded 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Menus API.LocalUser.Value = new User { Username = API.LocalUser.Value.Username, - Id = API.LocalUser.Value.Id, + Id = API.LocalUser.Value.Id + 1, IsSupporter = !API.LocalUser.Value.IsSupporter, }; }); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs new file mode 100644 index 0000000000..8f20e38494 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Audio.Track; +using osu.Framework.Screens; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneIntroWelcome : IntroTestScene + { + protected override IScreen CreateScreen() => new IntroWelcome(); + + public TestSceneIntroWelcome() + { + AddUntilStep("wait for load", () => getTrack() != null); + + AddAssert("check if menu music loops", () => getTrack().Looping); + } + + private Track getTrack() => (IntroStack?.CurrentScreen as MainMenu)?.Track; + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs similarity index 73% rename from osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs rename to osu.Game.Tests/Visual/Menus/TestSceneLoader.cs index 61fed3013e..c44363d9ea 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoader.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 System.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Screens; using osu.Game.Screens.Menu; using osuTK.Graphics; @@ -14,14 +17,14 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneLoaderAnimation : ScreenTestScene + public class TestSceneLoader : ScreenTestScene { private TestLoader loader; [Cached] private OsuLogo logo; - public TestSceneLoaderAnimation() + public TestSceneLoader() { Child = logo = new OsuLogo { @@ -33,8 +36,6 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestInstantLoad() { - // visual only, very impossible to test this using asserts. - AddStep("load immediately", () => { loader = new TestLoader(); @@ -42,33 +43,38 @@ namespace osu.Game.Tests.Visual.Menus LoadScreen(loader); }); + + spinnerNotPresentOrHidden(); + + AddUntilStep("loaded", () => loader.ScreenLoaded); + AddUntilStep("not current", () => !loader.IsCurrentScreen()); + + spinnerNotPresentOrHidden(); } + private void spinnerNotPresentOrHidden() => + AddAssert("spinner did not display", () => loader.LoadingSpinner == null || loader.LoadingSpinner.Alpha == 0); + [Test] public void TestDelayedLoad() { AddStep("begin loading", () => LoadScreen(loader = new TestLoader())); - AddUntilStep("wait for logo visible", () => loader.Logo?.Alpha > 0); + AddUntilStep("wait for spinner visible", () => loader.LoadingSpinner?.Alpha > 0); AddStep("finish loading", () => loader.AllowLoad.Set()); - AddUntilStep("loaded", () => loader.Logo != null && loader.ScreenLoaded); - AddUntilStep("logo gone", () => loader.Logo?.Alpha == 0); + AddUntilStep("spinner gone", () => loader.LoadingSpinner?.Alpha == 0); + AddUntilStep("loaded", () => loader.ScreenLoaded); + AddUntilStep("not current", () => !loader.IsCurrentScreen()); } private class TestLoader : Loader { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(); - public OsuLogo Logo; + public LoadingSpinner LoadingSpinner => this.ChildrenOfType().FirstOrDefault(); private TestScreen screen; public bool ScreenLoaded => screen.IsCurrentScreen(); - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - Logo = logo; - base.LogoArriving(logo, resuming); - } - protected override OsuScreen CreateLoadableScreen() => screen = new TestScreen(); protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(AllowLoad); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs new file mode 100644 index 0000000000..d7f23f5cc0 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.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.Overlays; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + public class TestSceneSongTicker : OsuTestScene + { + [Cached] + private MusicController musicController = new MusicController(); + + public TestSceneSongTicker() + { + AddRange(new Drawable[] + { + musicController, + new SongTicker + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new NowPlayingOverlay + { + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + State = { Value = Visibility.Visible } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index f24589ed35..b4985cad9f 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -1,44 +1,76 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osuTK.Input; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneToolbar : OsuTestScene + public class TestSceneToolbar : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ToolbarButton), - typeof(ToolbarRulesetSelector), - typeof(ToolbarRulesetTabButton), - typeof(ToolbarNotificationButton), - }; + private Toolbar toolbar; - public TestSceneToolbar() + [Resolved] + private RulesetStore rulesets { get; set; } + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = toolbar = new Toolbar { State = { Value = Visibility.Visible } }; + }); + + [Test] + public void TestNotificationCounter() { - var toolbar = new Toolbar { State = { Value = Visibility.Visible } }; ToolbarNotificationButton notificationButton = null; - AddStep("create toolbar", () => - { - Add(toolbar); - notificationButton = toolbar.Children.OfType().Last().Children.OfType().First(); - }); - - void setNotifications(int count) => AddStep($"set notification count to {count}", () => notificationButton.NotificationCount.Value = count); + AddStep("retrieve notification button", () => notificationButton = toolbar.ChildrenOfType().Single()); setNotifications(1); setNotifications(2); setNotifications(3); setNotifications(0); setNotifications(144); + + void setNotifications(int count) + => AddStep($"set notification count to {count}", + () => notificationButton.NotificationCount.Value = count); + } + + [TestCase(false)] + [TestCase(true)] + public void TestRulesetSwitchingShortcut(bool toolbarHidden) + { + ToolbarRulesetSelector rulesetSelector = null; + + if (toolbarHidden) + AddStep("hide toolbar", () => toolbar.Hide()); + + AddStep("retrieve ruleset selector", () => rulesetSelector = toolbar.ChildrenOfType().Single()); + + for (int i = 0; i < 4; i++) + { + var expected = rulesets.AvailableRulesets.ElementAt(i); + var numberKey = Key.Number1 + i; + + AddStep($"switch to ruleset {i} via shortcut", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(numberKey); + + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(numberKey); + }); + + AddUntilStep("ruleset switched", () => rulesetSelector.Current.Value.Equals(expected)); + } } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs new file mode 100644 index 0000000000..8b7e0fd9da --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.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.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Screens.Multi; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public abstract class RoomManagerTestScene : MultiplayerTestScene + { + [Cached(Type = typeof(IRoomManager))] + protected TestRoomManager RoomManager { get; } = new TestRoomManager(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("clear rooms", () => RoomManager.Rooms.Clear()); + } + + protected void AddRooms(int count, RulesetInfo ruleset = null) + { + AddStep("add rooms", () => + { + for (int i = 0; i < count; i++) + { + var room = new Room + { + RoomID = { Value = i }, + Name = { Value = $"Room {i}" }, + Host = { Value = new User { Username = "Host" } }, + EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, + Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal } + }; + + if (ruleset != null) + { + room.Playlist.Add(new PlaylistItem + { + Ruleset = { Value = ruleset }, + Beatmap = + { + Value = new BeatmapInfo + { + Metadata = new BeatmapMetadata() + } + } + }); + } + + RoomManager.Rooms.Add(room); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs new file mode 100644 index 0000000000..67a53307fc --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.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.Framework.Bindables; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add { } + remove { } + } + + public readonly BindableList Rooms = new BindableList(); + + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + IBindableList IRoomManager.Rooms => Rooms; + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); + + public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + { + } + + public void PartRoom() + { + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs new file mode 100644 index 0000000000..55b026eff6 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -0,0 +1,321 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Multi; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneDrawableRoomPlaylist : OsuManualInputManagerTestScene + { + private TestPlaylist playlist; + + private BeatmapManager manager; + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + + manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); + } + + [Test] + public void TestNonEditableNonSelectable() + { + createPlaylist(false, false); + + moveToItem(0); + assertHandleVisibility(0, false); + assertDeleteButtonVisibility(0, false); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + [Test] + public void TestEditable() + { + createPlaylist(true, false); + + moveToItem(0); + assertHandleVisibility(0, true); + assertDeleteButtonVisibility(0, true); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + [Test] + public void TestSelectable() + { + createPlaylist(false, true); + + moveToItem(0); + assertHandleVisibility(0, false); + assertDeleteButtonVisibility(0, false); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestEditableSelectable() + { + createPlaylist(true, true); + + moveToItem(0); + assertHandleVisibility(0, true); + assertDeleteButtonVisibility(0, true); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestSelectionNotLostAfterRearrangement() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDragger(0); + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveToDragger(1, new Vector2(0, 5)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]); + } + + [Test] + public void TestItemRemovedOnDeletion() + { + PlaylistItem selectedItem = null; + + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item removed", () => !playlist.Items.Contains(selectedItem)); + } + + [Test] + public void TestNextItemSelectedAfterDeletion() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestLastItemSelectedAfterLastItemDeleted() + { + createPlaylist(true, true); + + AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired. + AddStep("scroll to bottom", () => playlist.ChildrenOfType>().First().ScrollToEnd(false)); + + moveToItem(19); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(19); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]); + } + + [Test] + public void TestSelectionResetWhenAllItemsDeleted() + { + createPlaylist(true, true); + + AddStep("remove all but one item", () => + { + playlist.Items.RemoveRange(1, playlist.Items.Count - 1); + }); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + // Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081) + // [Test] + public void TestNextItemSelectedAfterExternalDeletion() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("remove item 0", () => playlist.Items.RemoveAt(0)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestChangeBeatmapAndRemove() + { + createPlaylist(true, true); + + AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + } + + [Test] + public void TestDownloadButtonHiddenInitiallyWhenBeatmapExists() + { + createPlaylist(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo); + + AddAssert("download button hidden", () => !playlist.ChildrenOfType().Single().IsPresent); + } + + [Test] + public void TestDownloadButtonVisibleInitiallyWhenBeatmapDoesNotExist() + { + var byOnlineId = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; + byOnlineId.BeatmapSet.OnlineBeatmapSetID = 1337; // Some random ID that does not exist locally. + + var byChecksum = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; + byChecksum.MD5Hash = "1337"; // Some random checksum that does not exist locally. + + createPlaylist(byOnlineId, byChecksum); + + AddAssert("download buttons shown", () => playlist.ChildrenOfType().All(d => d.IsPresent)); + } + + private void moveToItem(int index, Vector2? offset = null) + => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType>().ElementAt(index), offset)); + + private void moveToDragger(int index, Vector2? offset = null) => AddStep($"move mouse to dragger {index}", () => + { + var item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType.PlaylistItemHandle>().Single(), offset); + }); + + private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => + { + var item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); + }); + + private void assertHandleVisibility(int index, bool visible) + => AddAssert($"handle {index} {(visible ? "is" : "is not")} visible", + () => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible); + + private void assertDeleteButtonVisibility(int index, bool visible) + => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + + private void createPlaylist(bool allowEdit, bool allowSelection) + { + AddStep("create playlist", () => + { + Child = playlist = new TestPlaylist(allowEdit, allowSelection) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }; + + for (int i = 0; i < 20; i++) + { + playlist.Items.Add(new PlaylistItem + { + ID = i, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + }); + + AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); + } + + private void createPlaylist(params BeatmapInfo[] beatmaps) + { + AddStep("create playlist", () => + { + Child = playlist = new TestPlaylist(false, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }; + + int index = 0; + + foreach (var b in beatmaps) + { + playlist.Items.Add(new PlaylistItem + { + ID = index++, + Beatmap = { Value = b }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + }); + + AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); + } + + private class TestPlaylist : DrawableRoomPlaylist + { + public new IReadOnlyDictionary> ItemMap => base.ItemMap; + + public TestPlaylist(bool allowEdit, bool allowSelection) + : base(allowEdit, allowSelection) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs new file mode 100644 index 0000000000..7c0c2797f5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeFilterControl : OsuTestScene + { + public TestSceneLoungeFilterControl() + { + Child = new FilterControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs new file mode 100644 index 0000000000..cdad37a9ad --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.RoomStatuses; +using osu.Game.Screens.Multi.Lounge.Components; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeRoomInfo : MultiplayerTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + + Child = new RoomInfo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500 + }; + }); + + public override void SetUpSteps() + { + // Todo: Temp + } + + [Test] + public void TestNonSelectedRoom() + { + AddStep("set null room", () => Room.RoomID.Value = null); + } + + [Test] + public void TestOpenRoom() + { + AddStep("set open room", () => + { + Room.RoomID.Value = 0; + Room.Name.Value = "Room 0"; + Room.Host.Value = new User { Username = "peppy", Id = 2 }; + Room.EndDate.Value = DateTimeOffset.Now.AddMonths(1); + Room.Status.Value = new RoomStatusOpen(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index eb4dc909df..b1f6ee3e3a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -1,37 +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.Collections.Generic; using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; -using osu.Game.Screens.Multi; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Multi.Lounge.Components; -using osu.Game.Users; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomsContainer : MultiplayerTestScene + public class TestSceneLoungeRoomsContainer : RoomManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RoomsContainer), - typeof(DrawableRoom) - }; - - [Cached(Type = typeof(IRoomManager))] - private TestRoomManager roomManager = new TestRoomManager(); + private RoomsContainer container; [BackgroundDependencyLoader] private void load() { - RoomsContainer container; - Child = container = new RoomsContainer { Anchor = Anchor.Centre, @@ -39,59 +29,92 @@ namespace osu.Game.Tests.Visual.Multiplayer Width = 0.5f, JoinRequested = joinRequested }; + } - AddStep("clear rooms", () => roomManager.Rooms.Clear()); + [Test] + public void TestBasicListChanges() + { + AddRooms(3); - AddStep("add rooms", () => - { - for (int i = 0; i < 3; i++) - { - roomManager.Rooms.Add(new Room - { - RoomID = { Value = i }, - Name = { Value = $"Room {i}" }, - Host = { Value = new User { Username = "Host" } }, - EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) } - }); - } - }); - - AddAssert("has 2 rooms", () => container.Rooms.Count == 3); - AddStep("remove first room", () => roomManager.Rooms.Remove(roomManager.Rooms.FirstOrDefault())); + AddAssert("has 3 rooms", () => container.Rooms.Count == 3); + AddStep("remove first room", () => RoomManager.Rooms.Remove(RoomManager.Rooms.FirstOrDefault())); AddAssert("has 2 rooms", () => container.Rooms.Count == 2); AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0)); AddStep("select first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room selected", () => Room == roomManager.Rooms.First()); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); AddStep("join first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room joined", () => roomManager.Rooms.First().Status.Value is JoinedRoomStatus); + AddAssert("first room joined", () => RoomManager.Rooms.First().Status.Value is JoinedRoomStatus); } + [Test] + public void TestKeyboardNavigation() + { + AddRooms(3); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Up); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Down); + press(Key.Down); + AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + + press(Key.Enter); + AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); + } + + private void press(Key down) + { + AddStep($"press {down}", () => + { + InputManager.PressKey(down); + InputManager.ReleaseKey(down); + }); + } + + [Test] + public void TestStringFiltering() + { + AddRooms(4); + + AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + + AddStep("filter one room", () => container.Filter(new FilterCriteria { SearchString = "1" })); + + AddUntilStep("1 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 1); + + AddStep("remove filter", () => container.Filter(null)); + + AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + } + + [Test] + public void TestRulesetFiltering() + { + AddRooms(2, new OsuRuleset().RulesetInfo); + AddRooms(3, new CatchRuleset().RulesetInfo); + + AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); + + AddStep("filter osu! rooms", () => container.Filter(new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo })); + + AddUntilStep("2 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + + AddStep("filter catch rooms", () => container.Filter(new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo })); + + AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); + } + + private bool checkRoomSelected(Room room) => Room == room; + private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus(); - private class TestRoomManager : IRoomManager - { - public event Action RoomsUpdated - { - add { } - remove { } - } - - public readonly BindableList Rooms = new BindableList(); - IBindableList IRoomManager.Rooms => Rooms; - - public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); - - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) - { - } - - public void PartRoom() - { - } - } - private class JoinedRoomStatus : RoomStatus { public override string Message => "Joined"; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs new file mode 100644 index 0000000000..68987127d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Multi.Lounge; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeSubScreen : RoomManagerTestScene + { + private LoungeSubScreen loungeScreen; + + [BackgroundDependencyLoader] + private void load() + { + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(loungeScreen = new LoungeSubScreen + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + })); + + AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); + } + + private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + + [Test] + public void TestScrollSelectedIntoView() + { + AddRooms(30); + + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); + + AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + + AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); + } + + private bool checkRoomVisible(DrawableRoom room) => + loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad + .Contains(room.ScreenSpaceDrawQuad.Centre); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs new file mode 100644 index 0000000000..01cd26fbe5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Multi.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchBeatmapDetailArea : MultiplayerTestScene + { + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + + Child = new MatchBeatmapDetailArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + CreateNewItem = createNewItem + }; + }); + + private void createNewItem() + { + Room.Playlist.Add(new PlaylistItem + { + ID = Room.Playlist.Count, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapPanel.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapPanel.cs deleted file mode 100644 index 68ad0b42b4..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapPanel.cs +++ /dev/null @@ -1,53 +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.Online.Multiplayer; -using osu.Game.Screens.Multi.Match.Components; -using osu.Framework.Graphics; -using osu.Framework.MathUtils; -using osu.Game.Audio; -using osu.Framework.Allocation; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - [Cached(typeof(IPreviewTrackOwner))] - public class TestSceneMatchBeatmapPanel : MultiplayerTestScene, IPreviewTrackOwner - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MatchBeatmapPanel) - }; - - [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } - - public TestSceneMatchBeatmapPanel() - { - Add(new MatchBeatmapPanel - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); - - Room.Playlist.Add(new PlaylistItem { Beatmap = new BeatmapInfo { OnlineBeatmapID = 1763072 } }); - Room.Playlist.Add(new PlaylistItem { Beatmap = new BeatmapInfo { OnlineBeatmapID = 2101557 } }); - Room.Playlist.Add(new PlaylistItem { Beatmap = new BeatmapInfo { OnlineBeatmapID = 1973466 } }); - Room.Playlist.Add(new PlaylistItem { Beatmap = new BeatmapInfo { OnlineBeatmapID = 2109801 } }); - Room.Playlist.Add(new PlaylistItem { Beatmap = new BeatmapInfo { OnlineBeatmapID = 1922035 } }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - AddStep("Select random beatmap", () => - { - Room.CurrentItem.Value = Room.Playlist[RNG.Next(Room.Playlist.Count)]; - previewTrackManager.StopAnyPlaying(this); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs index e42042f2ea..e5943105b7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs @@ -1,38 +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.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Multiplayer.GameTypes; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchHeader : MultiplayerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Header) - }; - public TestSceneMatchHeader() { + Room = new Room(); Room.Playlist.Add(new PlaylistItem { - Beatmap = new BeatmapInfo + Beatmap = { - Metadata = new BeatmapMetadata + Value = new BeatmapInfo { - Title = "Title", - Artist = "Artist", - AuthorString = "Author", - }, - Version = "Version", - Ruleset = new OsuRuleset().RulesetInfo + Metadata = new BeatmapMetadata + { + Title = "Title", + Artist = "Artist", + AuthorString = "Author", + }, + Version = "Version", + Ruleset = new OsuRuleset().RulesetInfo + } }, RequiredMods = { @@ -42,7 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - Room.Type.Value = new GameTypeTimeshift(); + Room.Name.Value = "A very awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; Child = new Header(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs deleted file mode 100644 index 808a45cdf0..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMatchHostInfo : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(HostInfo) - }; - - private readonly Bindable host = new Bindable(new User { Username = "SomeHost" }); - - public TestSceneMatchHostInfo() - { - HostInfo hostInfo; - - Child = hostInfo = new HostInfo - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - - hostInfo.Host.BindTo(host); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.cs deleted file mode 100644 index a6c036a876..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.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.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Multiplayer.RoomStatuses; -using osu.Game.Rulesets; -using osu.Game.Screens.Multi.Match.Components; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - [TestFixture] - public class TestSceneMatchInfo : MultiplayerTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Info), - typeof(HeaderButton), - typeof(ReadyButton), - typeof(MatchBeatmapPanel) - }; - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - Add(new Info()); - - AddStep(@"set name", () => Room.Name.Value = @"Room Name?"); - AddStep(@"set availability", () => Room.Availability.Value = RoomAvailability.FriendsOnly); - AddStep(@"set status", () => Room.Status.Value = new RoomStatusPlaying()); - AddStep(@"set beatmap", () => - { - Room.Playlist.Clear(); - Room.Playlist.Add(new PlaylistItem - { - Beatmap = new BeatmapInfo - { - StarDifficulty = 2.4, - Ruleset = rulesets.GetRuleset(0), - Metadata = new BeatmapMetadata - { - Title = @"My Song", - Artist = @"VisualTests", - AuthorString = @"osu!lazer", - }, - } - }); - }); - - AddStep(@"change name", () => Room.Name.Value = @"Room Name!"); - AddStep(@"change availability", () => Room.Availability.Value = RoomAvailability.InviteOnly); - AddStep(@"change status", () => Room.Status.Value = new RoomStatusOpen()); - AddStep(@"null beatmap", () => Room.Playlist.Clear()); - AddStep(@"change beatmap", () => - { - Room.Playlist.Clear(); - Room.Playlist.Add(new PlaylistItem - { - Beatmap = new BeatmapInfo - { - StarDifficulty = 4.2, - Ruleset = rulesets.GetRuleset(3), - Metadata = new BeatmapMetadata - { - Title = @"Your Song", - Artist = @"Tester", - AuthorString = @"Someone", - }, - } - }); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 7ba1782a28..c24c6c4ba3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Match.Components; using osu.Game.Users; using osuTK; @@ -18,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestSceneMatchLeaderboard() { - Room.RoomID.Value = 3; + Room = new Room { RoomID = { Value = 3 } }; Add(new MatchLeaderboard { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs deleted file mode 100644 index 1ac914e27d..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - [TestFixture] - public class TestSceneMatchParticipants : MultiplayerTestScene - { - public TestSceneMatchParticipants() - { - Add(new Participants { RelativeSizeAxes = Axes.Both }); - - AddStep(@"set max to null", () => Room.MaxParticipants.Value = null); - AddStep(@"set users", () => Room.Participants.Value = new[] - { - new User - { - Username = @"Feppla", - Id = 4271601, - Country = new Country { FlagName = @"SE" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - IsSupporter = true, - }, - new User - { - Username = @"Xilver", - Id = 3099689, - Country = new Country { FlagName = @"IL" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - IsSupporter = true, - }, - new User - { - Username = @"Wucki", - Id = 5287410, - Country = new Country { FlagName = @"FI" }, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/5287410/5cfeaa9dd41cbce038ecdc9d781396ed4b0108089170bf7f50492ef8eadeb368.jpeg", - IsSupporter = true, - }, - }); - - AddStep(@"set max", () => Room.MaxParticipants.Value = 10); - AddStep(@"clear users", () => Room.Participants.Value = System.Array.Empty()); - AddStep(@"set max to null", () => Room.MaxParticipants.Value = null); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.cs deleted file mode 100644 index 58e9240026..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.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.Linq; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Screens.Multi.Ranking; -using osu.Game.Screens.Multi.Ranking.Pages; -using osu.Game.Screens.Multi.Ranking.Types; -using osu.Game.Screens.Ranking; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMatchResults : MultiplayerTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MatchResults), - typeof(RoomLeaderboardPageInfo), - typeof(RoomLeaderboardPage) - }; - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); - if (beatmapInfo != null) - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); - - Room.RoomID.Value = 1; - Room.Name.Value = "an awesome room"; - - LoadScreen(new TestMatchResults(new ScoreInfo - { - User = new User { Id = 10 }, - })); - } - - private class TestMatchResults : MatchResults - { - public TestMatchResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable CreateResultPages() => new[] { new TestRoomLeaderboardPageInfo(Score, Beatmap.Value) }; - } - - private class TestRoomLeaderboardPageInfo : RoomLeaderboardPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public TestRoomLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public override ResultsPage CreatePage() => new TestRoomLeaderboardPage(score, beatmap); - } - - private class TestRoomLeaderboardPage : RoomLeaderboardPage - { - public TestRoomLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - } - - protected override MatchLeaderboard CreateLeaderboard() => new TestMatchLeaderboard(); - } - - private class TestMatchLeaderboard : RoomLeaderboardPage.ResultsMatchLeaderboard - { - protected override APIRequest FetchScores(Action> scoresCallback) - { - var scores = Enumerable.Range(0, 50).Select(createRoomScore).ToArray(); - - scoresCallback?.Invoke(scores); - ScoresLoaded?.Invoke(scores); - - return null; - } - - private APIUserScoreAggregate createRoomScore(int id) => new APIUserScoreAggregate - { - User = new User { Id = id, Username = $"User {id}" }, - Accuracy = 0.98, - TotalScore = 987654, - TotalAttempts = 13, - CompletedBeatmaps = 5 - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs index 8d842fc865..fdc20dc477 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,11 +17,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchSettingsOverlay : MultiplayerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MatchSettingsOverlay) - }; - [Cached(Type = typeof(IRoomManager))] private TestRoomManager roomManager = new TestRoomManager(); @@ -56,7 +50,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set name", () => Room.Name.Value = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => Room.Playlist.Add(new PlaylistItem { Beatmap = CreateBeatmap(Ruleset.Value).BeatmapInfo })); + AddStep("set beatmap", () => Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } })); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); AddStep("clear name", () => Room.Name.Value = ""); @@ -75,6 +69,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; + Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); roomManager.CreateRequested = r => { @@ -95,6 +90,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("setup", () => { + Room.Name.Value = "Test Room"; + Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); + fail = true; roomManager.CreateRequested = _ => !fail; }); @@ -135,6 +133,8 @@ namespace osu.Game.Tests.Visual.Multiplayer remove { } } + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + public IBindableList Rooms { get; } = null; public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs new file mode 100644 index 0000000000..c62479faa0 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs @@ -0,0 +1,153 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Multi.Components; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchSongSelect : MultiplayerTestScene + { + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private BeatmapManager manager; + + private RulesetStore rulesets; + + private TestMatchSongSelect songSelect; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + + var beatmaps = new List(); + + for (int i = 0; i < 6; i++) + { + int beatmapId = 10 * 10 + i; + + int length = RNG.Next(30000, 200000); + double bpm = RNG.NextSingle(80, 200); + + beatmaps.Add(new BeatmapInfo + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineBeatmapID = beatmapId, + Path = "normal.osu", + Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", + Length = length, + BPM = bpm, + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = 3.5f, + }, + }); + } + + manager.Import(new BeatmapSetInfo + { + OnlineBeatmapSetID = 10, + Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), + Metadata = new BeatmapMetadata + { + // 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 10), max bpm {beatmaps.Max(b => b.BPM):0.#})", + AuthorString = "Some Guy " + RNG.Next(0, 9), + }, + Beatmaps = beatmaps, + DateAdded = DateTimeOffset.UtcNow, + }).Wait(); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + Beatmap.SetDefault(); + }); + + AddStep("create song select", () => LoadScreen(songSelect = new TestMatchSongSelect())); + AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + }); + + [Test] + public void TestItemAddedIfEmptyOnStart() + { + AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestItemAddedWhenCreateNewItemClicked() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestItemNotAddedIfExistingOnStart() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestAddSameItemMultipleTimes() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("playlist has 2 items", () => Room.Playlist.Count == 2); + } + + [Test] + public void TestAddItemAfterRearrangement() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("rearrange", () => + { + var item = Room.Playlist[0]; + Room.Playlist.RemoveAt(0); + Room.Playlist.Add(item); + }); + + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("new item has id 2", () => Room.Playlist.Last().ID == 2); + } + + private class TestMatchSongSelect : MatchSongSelect + { + public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs new file mode 100644 index 0000000000..2e22317539 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Multi; +using osu.Game.Screens.Multi.Match; +using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchSubScreen : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached(typeof(IRoomManager))] + private readonly TestRoomManager roomManager = new TestRoomManager(); + + private BeatmapManager manager; + private RulesetStore rulesets; + + private TestMatchSubScreen match; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + + manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + }); + + [SetUpSteps] + public void SetupSteps() + { + AddStep("load match", () => LoadScreen(match = new TestMatchSubScreen(Room))); + AddUntilStep("wait for load", () => match.IsCurrentScreen()); + } + + [Test] + public void TestLoadSimpleMatch() + { + AddStep("set room properties", () => + { + Room.RoomID.Value = 1; + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.RecentParticipants.Add(Room.Host.Value); + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + } + + [Test] + public void TestPlaylistItemSelectedOnCreate() + { + AddStep("set room properties", () => + { + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + + AddStep("move mouse to create button", () => + { + var footer = match.ChildrenOfType
().Single(); + InputManager.MoveMouseTo(footer.ChildrenOfType().Single()); + }); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("first playlist item selected", () => match.SelectedItem.Value == Room.Playlist[0]); + } + + [Test] + public void TestBeatmapUpdatedOnReImport() + { + BeatmapSetInfo importedSet = null; + + AddStep("import altered beatmap", () => + { + var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; + + importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result; + }); + + AddStep("load room", () => + { + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = importedSet.Beatmaps[0] }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + + AddStep("create room", () => + { + InputManager.MoveMouseTo(match.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("match has altered beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize == 1); + + AddStep("re-import original beatmap", () => manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait()); + + AddAssert("match has original beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize != 1); + } + + private class TestMatchSubScreen : MatchSubScreen + { + public new Bindable SelectedItem => base.SelectedItem; + + public new Bindable Beatmap => base.Beatmap; + + public TestMatchSubScreen(Room room) + : base(room) + { + } + } + + private class TestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + public IBindableList Rooms { get; } = new BindableList(); + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + { + room.RoomID.Value = 1; + onSuccess?.Invoke(room); + } + + public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => onSuccess?.Invoke(room); + + public void PartRoom() + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs index 3f89f636b1..76ab402b72 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs @@ -16,7 +16,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { int index = 0; - OsuScreenStack screenStack = new OsuScreenStack(new TestMultiplayerSubScreen(index)) { RelativeSizeAxes = Axes.Both }; + OsuScreenStack screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + + screenStack.Push(new TestMultiplayerSubScreen(index)); Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs index dfe61a4dda..61859c9da3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs @@ -1,11 +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 NUnit.Framework; -using osu.Game.Screens.Multi.Lounge; -using osu.Game.Screens.Multi.Lounge.Components; namespace osu.Game.Tests.Visual.Multiplayer { @@ -14,13 +10,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Screens.Multi.Multiplayer), - typeof(LoungeSubScreen), - typeof(FilterControl) - }; - public TestSceneMultiScreen() { Screens.Multi.Multiplayer multi = new Screens.Multi.Multiplayer(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs new file mode 100644 index 0000000000..b6bfa7c93a --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneOverlinedParticipants : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room { RoomID = { Value = 7 } }; + }); + + [Test] + public void TestHorizontalLayout() + { + AddStep("create component", () => + { + Child = new ParticipantsDisplay(Direction.Horizontal) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }; + }); + } + + [Test] + public void TestVerticalLayout() + { + AddStep("create component", () => + { + Child = new ParticipantsDisplay(Direction.Vertical) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500) + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs new file mode 100644 index 0000000000..14984988cb --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.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.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Multi; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneOverlinedPlaylist : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + public TestSceneOverlinedPlaylist() + { + Room = new Room { RoomID = { Value = 7 } }; + + for (int i = 0; i < 10; i++) + { + Room.Playlist.Add(new PlaylistItem + { + ID = i, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + } + + Add(new DrawableRoomPlaylist(false, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs new file mode 100644 index 0000000000..f71c5fc5d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneParticipantsList : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room { RoomID = { Value = 7 } }; + }); + + public TestSceneParticipantsList() + { + Add(new ParticipantsList { RelativeSizeAxes = Axes.Both }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs index 74d1645f6d..1925e0ef4f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; @@ -13,13 +11,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneRoomStatus : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RoomStatusEnded), - typeof(RoomStatusOpen), - typeof(RoomStatusPlaying) - }; - public TestSceneRoomStatus() { Child = new FillFlowContainer diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs new file mode 100644 index 0000000000..9fc7c336cb --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.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; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Multi.Ranking; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneTimeshiftResultsScreen : ScreenTestScene + { + private bool roomsReceived; + + [SetUp] + public void Setup() => Schedule(() => + { + roomsReceived = false; + bindHandler(); + }); + + [Test] + public void TestShowResultsWithScore() + { + createResults(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + AddWaitStep("wait for display", 5); + } + + [Test] + public void TestShowResultsNullScore() + { + createResults(null); + AddWaitStep("wait for display", 5); + } + + [Test] + public void TestShowResultsNullScoreWithDelay() + { + AddStep("bind delayed handler", () => bindHandler(3000)); + createResults(null); + AddUntilStep("wait for rooms to be received", () => roomsReceived); + AddWaitStep("wait for display", 5); + } + + private void createResults(ScoreInfo score) + { + AddStep("load results", () => + { + LoadScreen(new TimeshiftResultsScreen(score, 1, new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + })); + }); + } + + private void bindHandler(double delay = 0) + { + var roomScores = new List(); + + for (int i = 0; i < 10; i++) + { + roomScores.Add(new RoomScore + { + ID = i, + Accuracy = 0.9 - 0.01 * i, + EndedAt = DateTimeOffset.Now.Subtract(TimeSpan.FromHours(i)), + Passed = true, + Rank = ScoreRank.B, + MaxCombo = 999, + TotalScore = 999999 - i * 1000, + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = + { + { HitResult.Miss, 1 }, + { HitResult.Meh, 50 }, + { HitResult.Good, 100 }, + { HitResult.Great, 300 }, + } + }); + } + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetRoomPlaylistScoresRequest r: + if (delay == 0) + success(); + else + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + Schedule(success); + }); + } + + void success() + { + r.TriggerSuccess(new RoomPlaylistScores { Scores = roomScores }); + roomsReceived = true; + } + + break; + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs new file mode 100644 index 0000000000..c4acf4f7da --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs @@ -0,0 +1,148 @@ +// Copyright (c) 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.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osuTK.Graphics; +using IntroSequence = osu.Game.Configuration.IntroSequence; + +namespace osu.Game.Tests.Visual.Navigation +{ + /// + /// A scene which tests full game flow. + /// + public abstract class OsuGameTestScene : OsuManualInputManagerTestScene + { + private GameHost host; + + protected TestOsuGame Game; + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + this.host = host; + + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }; + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create new game instance", () => + { + if (Game != null) + { + Remove(Game); + Game.Dispose(); + } + + RecycleLocalStorage(); + + // see MouseSettings + var frameworkConfig = host.Dependencies.Get(); + frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity).Disabled = false; + + CreateGame(); + }); + + AddUntilStep("Wait for load", () => Game.IsLoaded); + AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); + + ConfirmAtMainMenu(); + } + + protected void CreateGame() + { + Game = new TestOsuGame(LocalStorage, API); + Game.SetHost(host); + + // todo: this can be removed once we can run audio tracks without a device present + // see https://github.com/ppy/osu/issues/1302 + Game.LocalConfig.Set(OsuSetting.IntroSequence, IntroSequence.Circles); + + Add(Game); + } + + protected void PushAndConfirm(Func newScreen) + { + Screen screen = null; + AddStep("Push new screen", () => Game.ScreenStack.Push(screen = newScreen())); + AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen == screen && screen.IsLoaded); + } + + protected void ConfirmAtMainMenu() => AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); + + public class TestOsuGame : OsuGame + { + public new ScreenStack ScreenStack => base.ScreenStack; + + public new BackButton BackButton => base.BackButton; + + public new BeatmapManager BeatmapManager => base.BeatmapManager; + + public new ScoreManager ScoreManager => base.ScoreManager; + + public new SettingsPanel Settings => base.Settings; + + public new MusicController MusicController => base.MusicController; + + public new OsuConfigManager LocalConfig => base.LocalConfig; + + public new Bindable Beatmap => base.Beatmap; + + public new Bindable Ruleset => base.Ruleset; + + // if we don't do this, when running under nUnit the version that gets populated is that of nUnit. + public override string Version => "test game"; + + protected override Loader CreateLoader() => new TestLoader(); + + public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens); + + public TestOsuGame(Storage storage, IAPIProvider api) + { + Storage = storage; + API = api; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + API.Login("Rhythm Champion", "osu!"); + + Dependencies.Get().Set(Static.MutedAudioNotificationShownOnce, true); + } + } + + public class TestLoader : Loader + { + protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(); + + private class TestShaderPrecompiler : ShaderPrecompiler + { + protected override bool AllLoaded => true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs new file mode 100644 index 0000000000..75c6a2b733 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.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 NUnit.Framework; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePerformFromScreen : OsuGameTestScene + { + [Test] + public void TestPerformAtMenu() + { + AddAssert("could perform immediately", () => + { + bool actionPerformed = false; + Game.PerformFromScreen(_ => actionPerformed = true); + return actionPerformed; + }); + } + + [Test] + public void TestPerformAtSongSelect() + { + PushAndConfirm(() => new PlaySongSelect()); + + AddAssert("could perform immediately", () => + { + bool actionPerformed = false; + Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }); + return actionPerformed; + }); + } + + [Test] + public void TestPerformAtMenuFromSongSelect() + { + PushAndConfirm(() => new PlaySongSelect()); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); + AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + AddAssert("did perform", () => actionPerformed); + } + + [Test] + public void TestPerformAtSongSelectFromPlayerLoader() + { + PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new Player())); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("did perform", () => actionPerformed); + } + + [Test] + public void TestPerformAtMenuFromPlayerLoader() + { + PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new Player())); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu); + AddAssert("did perform", () => actionPerformed); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs new file mode 100644 index 0000000000..27f5b29738 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -0,0 +1,143 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePresentBeatmap : OsuGameTestScene + { + [Test] + public void TestFromMainMenu() + { + var firstImport = importBeatmap(1); + var secondimport = importBeatmap(3); + + presentAndConfirm(firstImport); + returnToMenu(); + presentAndConfirm(secondimport); + returnToMenu(); + presentSecondDifficultyAndConfirm(firstImport, 1); + returnToMenu(); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromMainMenuDifferentRuleset() + { + var firstImport = importBeatmap(1); + var secondimport = importBeatmap(3, new ManiaRuleset().RulesetInfo); + + presentAndConfirm(firstImport); + returnToMenu(); + presentAndConfirm(secondimport); + returnToMenu(); + presentSecondDifficultyAndConfirm(firstImport, 1); + returnToMenu(); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromSongSelect() + { + var firstImport = importBeatmap(1); + presentAndConfirm(firstImport); + + var secondimport = importBeatmap(3); + presentAndConfirm(secondimport); + + presentSecondDifficultyAndConfirm(firstImport, 1); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromSongSelectDifferentRuleset() + { + var firstImport = importBeatmap(1); + presentAndConfirm(firstImport); + + var secondimport = importBeatmap(3, new ManiaRuleset().RulesetInfo); + presentAndConfirm(secondimport); + + presentSecondDifficultyAndConfirm(firstImport, 1); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + private void returnToMenu() + { + AddStep("return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + } + + private Func importBeatmap(int i, RulesetInfo ruleset = null) + { + BeatmapSetInfo imported = null; + AddStep($"import beatmap {i}", () => + { + var difficulty = new BeatmapDifficulty(); + var metadata = new BeatmapMetadata + { + Artist = "SomeArtist", + AuthorString = "SomeAuthor", + Title = $"import {i}" + }; + + imported = Game.BeatmapManager.Import(new BeatmapSetInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineBeatmapSetID = i, + Metadata = metadata, + Beatmaps = new List + { + new BeatmapInfo + { + OnlineBeatmapID = i * 1024, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }, + new BeatmapInfo + { + OnlineBeatmapID = i * 2048, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }, + } + }).Result; + }); + + AddAssert($"import {i} succeeded", () => imported != null); + + return () => imported; + } + + 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.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Beatmaps.First().Ruleset.ID); + } + + private void presentSecondDifficultyAndConfirm(Func getImport, int importedID) + { + Predicate pred = b => b.OnlineBeatmapID == importedID * 2048; + 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.OnlineBeatmapID == importedID * 2048); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Beatmaps.First().Ruleset.ID); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs new file mode 100644 index 0000000000..b2e18849c9 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +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.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePresentScore : OsuGameTestScene + { + private BeatmapSetInfo beatmap; + + [SetUpSteps] + public new void SetUpSteps() + { + AddStep("import beatmap", () => + { + var difficulty = new BeatmapDifficulty(); + var metadata = new BeatmapMetadata + { + Artist = "SomeArtist", + AuthorString = "SomeAuthor", + Title = "import" + }; + + beatmap = Game.BeatmapManager.Import(new BeatmapSetInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineBeatmapSetID = 1, + Metadata = metadata, + Beatmaps = new List + { + new BeatmapInfo + { + OnlineBeatmapID = 1 * 1024, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = new OsuRuleset().RulesetInfo + }, + new BeatmapInfo + { + OnlineBeatmapID = 1 * 2048, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = new OsuRuleset().RulesetInfo + }, + } + }).Result; + }); + } + + [Test] + public void TestFromMainMenu([Values] ScorePresentType type) + { + var firstImport = importScore(1); + var secondimport = importScore(3); + + presentAndConfirm(firstImport, type); + returnToMenu(); + presentAndConfirm(secondimport, type); + returnToMenu(); + returnToMenu(); + } + + [Test] + public void TestFromMainMenuDifferentRuleset([Values] ScorePresentType type) + { + var firstImport = importScore(1); + var secondimport = importScore(3, new ManiaRuleset().RulesetInfo); + + presentAndConfirm(firstImport, type); + returnToMenu(); + presentAndConfirm(secondimport, type); + returnToMenu(); + returnToMenu(); + } + + [Test] + public void TestFromSongSelect([Values] ScorePresentType type) + { + var firstImport = importScore(1); + presentAndConfirm(firstImport, type); + + var secondimport = importScore(3); + presentAndConfirm(secondimport, type); + } + + [Test] + public void TestFromSongSelectDifferentRuleset([Values] ScorePresentType type) + { + var firstImport = importScore(1); + presentAndConfirm(firstImport, type); + + var secondimport = importScore(3, new ManiaRuleset().RulesetInfo); + presentAndConfirm(secondimport, type); + } + + private void returnToMenu() + { + AddStep("return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + } + + private Func importScore(int i, RulesetInfo ruleset = null) + { + ScoreInfo imported = null; + AddStep($"import score {i}", () => + { + imported = Game.ScoreManager.Import(new ScoreInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineScoreID = i, + Beatmap = beatmap.Beatmaps.First(), + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }).Result; + }); + + AddAssert($"import {i} succeeded", () => imported != null); + + return () => imported; + } + + private void presentAndConfirm(Func getImport, ScorePresentType type) + { + AddStep("present score", () => Game.PresentScore(getImport(), type)); + + switch (type) + { + case ScorePresentType.Results: + AddUntilStep("wait for results", () => Game.ScreenStack.CurrentScreen is ResultsScreen); + AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); + break; + + case ScorePresentType.Gameplay: + AddUntilStep("wait for player loader", () => Game.ScreenStack.CurrentScreen is ReplayPlayerLoader); + AddUntilStep("correct score displayed", () => ((ReplayPlayerLoader)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); + break; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs similarity index 51% rename from osu.Game.Tests/Visual/Menus/TestSceneScreenNavigation.cs rename to osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 471f67b7b6..8ccaca8630 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -1,90 +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.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; -using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Framework.Screens; -using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Mods; -using osu.Game.Screens; -using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osuTK; -using osuTK.Graphics; using osuTK.Input; -using IntroSequence = osu.Game.Configuration.IntroSequence; -namespace osu.Game.Tests.Visual.Menus +namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneScreenNavigation : ManualInputManagerTestScene + public class TestSceneScreenNavigation : OsuGameTestScene { private const float click_padding = 25; - private GameHost host; - private TestOsuGame game; + private Vector2 backButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, Game.LayoutRectangle.Bottom - click_padding)); - private Vector2 backButtonPosition => game.ToScreenSpace(new Vector2(click_padding, game.LayoutRectangle.Bottom - click_padding)); - - private Vector2 optionsButtonPosition => game.ToScreenSpace(new Vector2(click_padding, click_padding)); - - [BackgroundDependencyLoader] - private void load(GameHost host) - { - this.host = host; - - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }; - } - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("Create new game instance", () => - { - if (game != null) - { - Remove(game); - game.Dispose(); - } - - game = new TestOsuGame(LocalStorage, API); - game.SetHost(host); - - // todo: this can be removed once we can run audio trakcs without a device present - // see https://github.com/ppy/osu/issues/1302 - game.LocalConfig.Set(OsuSetting.IntroSequence, IntroSequence.Circles); - - Add(game); - }); - AddUntilStep("Wait for load", () => game.IsLoaded); - AddUntilStep("Wait for intro", () => game.ScreenStack.CurrentScreen is IntroScreen); - confirmAtMainMenu(); - } + private Vector2 optionsButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, click_padding)); [Test] public void TestExitSongSelectWithEscape() { TestSongSelect songSelect = null; - pushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestSongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); pushEscape(); @@ -98,21 +45,21 @@ namespace osu.Game.Tests.Visual.Menus { Player player = null; - WorkingBeatmap beatmap() => game.Beatmap.Value; + WorkingBeatmap beatmap() => Game.Beatmap.Value; Track track() => beatmap().Track; - pushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestSongSelect()); - AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Wait()); + AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); - AddUntilStep("wait for selected", () => !game.Beatmap.IsDefault); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); if (withUserPause) - AddStep("pause", () => game.Dependencies.Get().Stop()); + AddStep("pause", () => Game.Dependencies.Get().Stop()); AddStep("press enter", () => pressAndRelease(Key.Enter)); - AddUntilStep("wait for player", () => (player = game.ScreenStack.CurrentScreen as Player) != null); + AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); AddUntilStep("wait for fail", () => player.HasFailed); AddUntilStep("wait for track stop", () => !track().IsRunning); @@ -124,18 +71,35 @@ namespace osu.Game.Tests.Visual.Menus AddAssert("Ensure time wasn't reset to preview point", () => track().CurrentTime < beatmap().Metadata.PreviewTime); } + [Test] + public void TestMenuMakesMusic() + { + WorkingBeatmap beatmap() => Game.Beatmap.Value; + Track track() => beatmap().Track; + + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + + AddUntilStep("wait for no track", () => track() is TrackVirtual); + + AddStep("return to menu", () => songSelect.Exit()); + + AddUntilStep("wait for track", () => !(track() is TrackVirtual) && track().IsRunning); + } + [Test] public void TestExitSongSelectWithClick() { TestSongSelect songSelect = null; - pushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestSongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); // BackButton handles hover using its child button, so this checks whether or not any of BackButton's children are hovered. - AddUntilStep("Back button is hovered", () => InputManager.HoveredDrawables.Any(d => d.Parent == game.BackButton)); + AddUntilStep("Back button is hovered", () => InputManager.HoveredDrawables.Any(d => d.Parent == Game.BackButton)); AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); AddUntilStep("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); @@ -145,34 +109,43 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestExitMultiWithEscape() { - pushAndConfirm(() => new Screens.Multi.Multiplayer()); + PushAndConfirm(() => new Screens.Multi.Multiplayer()); exitViaEscapeAndConfirm(); } [Test] public void TestExitMultiWithBackButton() { - pushAndConfirm(() => new Screens.Multi.Multiplayer()); + PushAndConfirm(() => new Screens.Multi.Multiplayer()); exitViaBackButtonAndConfirm(); } [Test] public void TestOpenOptionsAndExitWithEscape() { - AddUntilStep("Wait for options to load", () => game.Settings.IsLoaded); + AddUntilStep("Wait for options to load", () => Game.Settings.IsLoaded); AddStep("Enter menu", () => pressAndRelease(Key.Enter)); AddStep("Move mouse to options overlay", () => InputManager.MoveMouseTo(optionsButtonPosition)); AddStep("Click options overlay", () => InputManager.Click(MouseButton.Left)); - AddAssert("Options overlay was opened", () => game.Settings.State.Value == Visibility.Visible); + AddAssert("Options overlay was opened", () => Game.Settings.State.Value == Visibility.Visible); AddStep("Hide options overlay using escape", () => pressAndRelease(Key.Escape)); - AddAssert("Options overlay was closed", () => game.Settings.State.Value == Visibility.Hidden); + AddAssert("Options overlay was closed", () => Game.Settings.State.Value == Visibility.Hidden); } - private void pushAndConfirm(Func newScreen) + [Test] + public void TestWaitForNextTrackInMenu() { - Screen screen = null; - AddStep("Push new screen", () => game.ScreenStack.Push(screen = newScreen())); - AddUntilStep("Wait for new screen", () => game.ScreenStack.CurrentScreen == screen && screen.IsLoaded); + bool trackCompleted = false; + + AddUntilStep("Wait for music controller", () => Game.MusicController.IsLoaded); + AddStep("Seek close to end", () => + { + Game.MusicController.SeekTo(Game.Beatmap.Value.Track.Length - 1000); + Game.Beatmap.Value.Track.Completed += () => trackCompleted = true; + }); + + AddUntilStep("Track was completed", () => trackCompleted); + AddUntilStep("Track was restarted", () => Game.Beatmap.Value.Track.IsRunning); } private void pushEscape() => @@ -181,64 +154,25 @@ namespace osu.Game.Tests.Visual.Menus private void exitViaEscapeAndConfirm() { pushEscape(); - confirmAtMainMenu(); + ConfirmAtMainMenu(); } private void exitViaBackButtonAndConfirm() { AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); - confirmAtMainMenu(); + ConfirmAtMainMenu(); } - private void confirmAtMainMenu() => AddUntilStep("Wait for main menu", () => game.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); - private void pressAndRelease(Key key) { InputManager.PressKey(key); InputManager.ReleaseKey(key); } - private class TestOsuGame : OsuGame - { - public new ScreenStack ScreenStack => base.ScreenStack; - - public new BackButton BackButton => base.BackButton; - - public new SettingsPanel Settings => base.Settings; - - public new OsuConfigManager LocalConfig => base.LocalConfig; - - public new Bindable Beatmap => base.Beatmap; - - protected override Loader CreateLoader() => new TestLoader(); - - public TestOsuGame(Storage storage, IAPIProvider api) - { - Storage = storage; - API = api; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - API.Login("Rhythm Champion", "osu!"); - } - } - private class TestSongSelect : PlaySongSelect { public ModSelectOverlay ModSelectOverlay => ModSelect; } - - private class TestLoader : Loader - { - protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(); - - private class TestShaderPrecompiler : ShaderPrecompiler - { - protected override bool AllLoaded => true; - } - } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs new file mode 100644 index 0000000000..c0b77b580e --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Configuration; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSettingsMigration : OsuGameTestScene + { + public override void RecycleLocalStorage() + { + base.RecycleLocalStorage(); + + using (var config = new OsuConfigManager(LocalStorage)) + { + config.Set(OsuSetting.Version, "2020.101.0"); + config.Set(OsuSetting.DisplayStarsMaximum, 10.0); + } + } + + [Test] + public void TestDisplayStarsMigration() + { + AddAssert("config has migrated value", () => Precision.AlmostEquals(Game.LocalConfig.Get(OsuSetting.DisplayStarsMaximum), 10.1)); + + AddStep("set value again", () => Game.LocalConfig.Set(OsuSetting.DisplayStarsMaximum, 10)); + + AddStep("force save config", () => Game.LocalConfig.Save()); + + AddStep("remove game", () => Remove(Game)); + + AddStep("create game again", CreateGame); + + AddUntilStep("Wait for load", () => Game.IsLoaded); + + AddAssert("config did not migrate value", () => Precision.AlmostEquals(Game.LocalConfig.Get(OsuSetting.DisplayStarsMaximum), 10)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 31eab7f74e..6c8ec917ba 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -1,30 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays; -using osu.Game.Overlays.AccountCreation; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public class TestSceneAccountCreationOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ErrorTextFlowContainer), - typeof(AccountCreationBackground), - typeof(ScreenEntry), - typeof(ScreenWarning), - typeof(ScreenWelcome), - typeof(AccountCreationScreen), - }; - private readonly Container userPanelArea; private Bindable localUser; @@ -54,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online API.Logout(); localUser = API.LocalUser.GetBoundCopy(); - localUser.BindValueChanged(user => { userPanelArea.Child = new UserPanel(user.NewValue) { Width = 200 }; }, true); + localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true); AddStep("logout", API.Logout); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs new file mode 100644 index 0000000000..6cb1687d1f --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Overlays; +using NUnit.Framework; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneBeatmapListingOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + private readonly BeatmapListingOverlay overlay; + + public TestSceneBeatmapListingOverlay() + { + Add(overlay = new BeatmapListingOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs index 1f8df438fb..eb34187cd6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs @@ -5,9 +5,9 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Rulesets; -using System; using System.Collections.Generic; using System.Linq; @@ -15,11 +15,8 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapRulesetSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(BeatmapRulesetSelector), - typeof(BeatmapRulesetTabItem), - }; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly TestRulesetSelector selector; diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 5ca2c9868f..c5d1fd6887 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -6,8 +6,6 @@ using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Overlays.BeatmapSet.Buttons; -using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; using osu.Game.Users; using System; @@ -21,30 +19,6 @@ namespace osu.Game.Tests.Visual.Online { private readonly TestBeatmapSetOverlay overlay; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Header), - typeof(ScoreTable), - typeof(ScoreTableRowBackground), - typeof(DrawableTopScore), - typeof(ScoresContainer), - typeof(AuthorInfo), - typeof(BasicStats), - typeof(BeatmapPicker), - typeof(Details), - typeof(HeaderDownloadButton), - typeof(FavouriteButton), - typeof(Header), - typeof(HeaderButton), - typeof(Info), - typeof(PreviewButton), - typeof(SuccessRate), - typeof(BeatmapAvailability), - typeof(BeatmapRulesetSelector), - typeof(BeatmapRulesetTabItem), - typeof(NotSupporterPlaceholder) - }; - protected override bool UseOnlineAPI => true; public TestSceneBeatmapSetOverlay() diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index 96c0c59695..f7099b0615 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; @@ -15,13 +16,11 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapSetOverlayDetails : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Details) - }; - private RatingsExposingDetails details; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void Setup() => Schedule(() => { @@ -55,8 +54,12 @@ namespace osu.Game.Tests.Visual.Online { Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), - } + }, } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Status = BeatmapSetOnlineStatus.Ranked } }; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index 80fad44593..4cb22bf1fe 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using 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; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; using osuTK; @@ -19,13 +19,11 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapSetOverlaySuccessRate : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Details) - }; - private GraphExposingSuccessRate successRate; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void Setup() => Schedule(() => { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index 658f678b10..02f6de2269 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using NUnit.Framework; using osu.Game.Online.API.Requests.Responses; @@ -13,39 +12,44 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneChangelogOverlay : OsuTestScene { - private ChangelogOverlay changelog; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(UpdateStreamBadgeArea), - typeof(UpdateStreamBadge), - typeof(ChangelogHeader), - typeof(ChangelogContent), - typeof(ChangelogListing), - typeof(ChangelogSingleBuild), - typeof(ChangelogBuild), - typeof(Comments), - }; + private TestChangelogOverlay changelog; protected override bool UseOnlineAPI => true; - protected override void LoadComplete() + [SetUp] + public void SetUp() => Schedule(() => { - base.LoadComplete(); + Child = changelog = new TestChangelogOverlay(); + }); - Add(changelog = new ChangelogOverlay()); - AddStep(@"Show", changelog.Show); - AddStep(@"Hide", changelog.Hide); + [Test] + public void ShowWithNoFetch() + { + AddStep(@"Show", () => changelog.Show()); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"listing displayed", () => changelog.Current.Value == null); + AddAssert(@"no stream selected", () => changelog.Header.Streams.Current.Value == null); + } - AddWaitStep("wait for hide", 3); + [Test] + public void ShowWithListing() + { + AddStep(@"Show with listing", () => changelog.ShowListing()); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"listing displayed", () => changelog.Current.Value == null); + AddAssert(@"no stream selected", () => changelog.Header.Streams.Current.Value == null); + } + [Test] + public void ShowWithBuild() + { AddStep(@"Show with Lazer 2018.712.0", () => { changelog.ShowBuild(new APIChangelogBuild { Version = "2018.712.0", DisplayVersion = "2018.712.0", - UpdateStream = new APIUpdateStream { Name = OsuGameBase.CLIENT_STREAM_NAME }, + UpdateStream = new APIUpdateStream { Id = 7, Name = OsuGameBase.CLIENT_STREAM_NAME }, ChangelogEntries = new List { new APIChangelogEntry @@ -56,19 +60,16 @@ namespace osu.Game.Tests.Visual.Online } } }); - changelog.Show(); }); - AddWaitStep("wait for show", 3); - AddStep(@"Hide", changelog.Hide); - AddWaitStep("wait for hide", 3); - - AddStep(@"Show with listing", () => - { - changelog.ShowListing(); - changelog.Show(); - }); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"correct build displayed", () => changelog.Current.Value.Version == "2018.712.0"); + AddAssert(@"correct stream selected", () => changelog.Header.Streams.Current.Value.Id == 7); + } + [Test] + public void TestHTMLUnescaping() + { AddStep(@"Ensure HTML string unescaping", () => { changelog.ShowBuild(new APIChangelogBuild @@ -97,5 +98,12 @@ namespace osu.Game.Tests.Visual.Online }); }); } + + private class TestChangelogOverlay : ChangelogOverlay + { + public new List Streams => base.Streams; + + public new ChangelogHeader Header => base.Header; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs index 16e47c5df9..73e1fc9b35 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat.Tabs; @@ -20,12 +20,7 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneChannelTabControl : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChannelTabControl), - }; - - private readonly ChannelTabControl channelTabControl; + private readonly TestTabControl channelTabControl; public TestSceneChannelTabControl() { @@ -37,7 +32,7 @@ namespace osu.Game.Tests.Visual.Online Anchor = Anchor.Centre, Children = new Drawable[] { - channelTabControl = new ChannelTabControl + channelTabControl = new TestTabControl { RelativeSizeAxes = Axes.X, Origin = Anchor.Centre, @@ -73,32 +68,40 @@ namespace osu.Game.Tests.Visual.Online channelTabControl.Current.ValueChanged += channel => currentText.Text = "Currently selected channel: " + channel.NewValue; AddStep("Add random private channel", addRandomPrivateChannel); - AddAssert("There is only one channels", () => channelTabControl.Items.Count() == 2); + AddAssert("There is only one channels", () => channelTabControl.Items.Count == 2); AddRepeatStep("Add 3 random private channels", addRandomPrivateChannel, 3); - AddAssert("There are four channels", () => channelTabControl.Items.Count() == 5); + AddAssert("There are four channels", () => channelTabControl.Items.Count == 5); AddStep("Add random public channel", () => addChannel(RNG.Next().ToString())); - AddRepeatStep("Select a random channel", () => channelTabControl.Current.Value = channelTabControl.Items.ElementAt(RNG.Next(channelTabControl.Items.Count() - 1)), 20); + AddRepeatStep("Select a random channel", () => + { + List validChannels = channelTabControl.Items.Where(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel)).ToList(); + channelTabControl.SelectChannel(validChannels[RNG.Next(0, validChannels.Count)]); + }, 20); - Channel channelBefore = channelTabControl.Items.First(); - AddStep("set first channel", () => channelTabControl.Current.Value = channelBefore); + Channel channelBefore = null; + AddStep("set first channel", () => channelTabControl.SelectChannel(channelBefore = channelTabControl.Items.First(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel)))); - AddStep("select selector tab", () => channelTabControl.Current.Value = channelTabControl.Items.Last()); + AddStep("select selector tab", () => channelTabControl.SelectChannel(channelTabControl.Items.Single(c => c is ChannelSelectorTabItem.ChannelSelectorTabChannel))); AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value); AddAssert("check channel unchanged", () => channelBefore == channelTabControl.Current.Value); - AddStep("set second channel", () => channelTabControl.Current.Value = channelTabControl.Items.Skip(1).First()); + AddStep("set second channel", () => channelTabControl.SelectChannel(channelTabControl.Items.GetNext(channelBefore))); AddAssert("selector tab is inactive", () => !channelTabControl.ChannelSelectorActive.Value); AddUntilStep("remove all channels", () => { - var first = channelTabControl.Items.First(); - if (first is ChannelSelectorTabItem.ChannelSelectorTabChannel) - return true; + foreach (var item in channelTabControl.Items.ToList()) + { + if (item is ChannelSelectorTabItem.ChannelSelectorTabChannel) + continue; - channelTabControl.RemoveChannel(first); - return false; + channelTabControl.RemoveChannel(item); + return false; + } + + return true; }); AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value); @@ -117,5 +120,10 @@ namespace osu.Game.Tests.Visual.Online Type = ChannelType.Public, Name = name }); + + private class TestTabControl : ChannelTabControl + { + public void SelectChannel(Channel channel) => base.SelectTab(TabMap[channel]); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs index 4773e84a5e..8408b7dd60 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs @@ -2,12 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Containers; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Users; @@ -19,14 +17,6 @@ namespace osu.Game.Tests.Visual.Online { private readonly TestChatLineContainer textContainer; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChatLine), - typeof(Message), - typeof(LinkFlowContainer), - typeof(MessageFormatter) - }; - public TestSceneChatLineTruncation() { Add(textContainer = new TestChatLineContainer diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index a1c77e2db0..9e69530a77 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -2,16 +2,14 @@ // 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.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Chat; @@ -27,16 +25,6 @@ namespace osu.Game.Tests.Visual.Online private readonly DialogOverlay dialogOverlay; private Color4 linkColour; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChatLine), - typeof(Message), - typeof(LinkFlowContainer), - typeof(DummyEchoMessage), - typeof(LocalEchoMessage), - typeof(MessageFormatter) - }; - public TestSceneChatLink() { Add(dialogOverlay = new DialogOverlay { Depth = float.MinValue }); @@ -77,7 +65,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert($"msg #{index} has {linkAmount} link(s)", () => newLine.Message.Links.Count == linkAmount); AddAssert($"msg #{index} has the right action", hasExpectedActions); - AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); + //AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); AddAssert($"msg #{index} shows {linkAmount} link(s)", isShowingLinks); bool hasExpectedActions() @@ -96,13 +84,13 @@ namespace osu.Game.Tests.Visual.Online return true; } - bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font.Italics); + //bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font.Italics); bool isShowingLinks() { bool hasBackground = !string.IsNullOrEmpty(newLine.Message.Sender.Colour); - Color4 textColour = isAction && hasBackground ? OsuColour.FromHex(newLine.Message.Sender.Colour) : Color4.White; + Color4 textColour = isAction && hasBackground ? Color4Extensions.FromHex(newLine.Message.Sender.Colour) : Color4.White; var linkCompilers = newLine.ContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 9196513a55..0025a26baf 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.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 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.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osu.Game.Overlays; -using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat.Selection; using osu.Game.Overlays.Chat.Tabs; using osu.Game.Users; @@ -19,24 +20,32 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneChatOverlay : ManualInputManagerTestScene + public class TestSceneChatOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChatLine), - typeof(DrawableChannel), - typeof(ChannelSelectorTabItem), - typeof(ChannelTabControl), - typeof(ChannelTabItem), - typeof(PrivateChannelTabItem), - typeof(TabCloseButton) - }; - private TestChatOverlay chatOverlay; private ChannelManager channelManager; - private readonly Channel channel1 = new Channel(new User()) { Name = "test really long username" }; - private readonly Channel channel2 = new Channel(new User()) { Name = "test2" }; + private IEnumerable visibleChannels => chatOverlay.ChannelTabControl.VisibleItems.Where(channel => channel.Name != "+"); + private IEnumerable joinedChannels => chatOverlay.ChannelTabControl.Items.Where(channel => channel.Name != "+"); + private readonly List channels; + + private Channel currentChannel => channelManager.CurrentChannel.Value; + private Channel nextChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) + 1); + private Channel previousChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) - 1); + private Channel channel1 => channels[0]; + private Channel channel2 => channels[1]; + + public TestSceneChatOverlay() + { + channels = Enumerable.Range(1, 10) + .Select(index => new Channel(new User()) + { + Name = $"Channel no. {index}", + Topic = index == 3 ? null : $"We talk about the number {index} here", + Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary + }) + .ToList(); + } [SetUp] public void Setup() @@ -45,7 +54,7 @@ namespace osu.Game.Tests.Visual.Online { ChannelManagerContainer container; - Child = container = new ChannelManagerContainer(new List { channel1, channel2 }) + Child = container = new ChannelManagerContainer(channels) { RelativeSizeAxes = Axes.Both, }; @@ -75,27 +84,143 @@ namespace osu.Game.Tests.Visual.Online AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); AddStep("Switch to channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); - AddAssert("Current channel is channel 1", () => channelManager.CurrentChannel.Value == channel1); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); AddAssert("Channel selector was closed", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); } [Test] - public void TestCloseChannelWhileSelectorClosed() + public void TestSearchInSelector() { - AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); - AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Search for 'no. 2'", () => chatOverlay.ChildrenOfType().First().Text = "no. 2"); + AddUntilStep("Only channel 2 visible", () => + { + var listItems = chatOverlay.ChildrenOfType().Where(c => c.IsPresent); + return listItems.Count() == 1 && listItems.Single().Channel == channel2; + }); + } - AddStep("Switch to channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); - AddStep("Close channel 2", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + [Test] + public void TestChannelShortcutKeys() + { + AddStep("Join channels", () => channels.ForEach(channel => channelManager.JoinChannel(channel))); + AddStep("Close channel selector", () => + { + InputManager.PressKey(Key.Escape); + InputManager.ReleaseKey(Key.Escape); + }); + AddUntilStep("Wait for close", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); + for (int zeroBasedIndex = 0; zeroBasedIndex < 10; ++zeroBasedIndex) + { + var oneBasedIndex = zeroBasedIndex + 1; + var targetNumberKey = oneBasedIndex % 10; + var targetChannel = channels[zeroBasedIndex]; + AddStep($"Press Alt+{targetNumberKey}", () => pressChannelHotkey(targetNumberKey)); + AddAssert($"Channel #{oneBasedIndex} is selected", () => currentChannel == targetChannel); + } + } + + private Channel expectedChannel; + + [Test] + public void TestCloseChannelBehaviour() + { + AddUntilStep("Join until dropdown has channels", () => + { + if (visibleChannels.Count() < joinedChannels.Count()) + return true; + + // Using temporary channels because they don't hide their names when not active + channelManager.JoinChannel(new Channel + { + Name = $"Channel no. {joinedChannels.Count() + 11}", + Type = ChannelType.Temporary + }); + + return false; + }); + + AddStep("Switch to last tab", () => clickDrawable(chatOverlay.TabMap[visibleChannels.Last()])); + AddAssert("Last visible selected", () => currentChannel == visibleChannels.Last()); + + // Closing the last channel before dropdown + AddStep("Close current channel", () => + { + expectedChannel = nextChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Next channel selected", () => currentChannel == expectedChannel); AddAssert("Selector remained closed", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); - AddAssert("Current channel is channel 1", () => channelManager.CurrentChannel.Value == channel1); - AddStep("Close channel 1", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + // Depending on the window size, one more channel might need to be closed for the selectorTab to appear + AddUntilStep("Close channels until selector visible", () => + { + if (chatOverlay.ChannelTabControl.VisibleItems.Last().Name == "+") + return true; + chatOverlay.ChannelTabControl.RemoveChannel(visibleChannels.Last()); + return false; + }); + AddAssert("Last visible selected", () => currentChannel == visibleChannels.Last()); + + // Closing the last channel with dropdown no longer present + AddStep("Close last when selector next", () => + { + expectedChannel = previousChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Previous channel selected", () => currentChannel == expectedChannel); + + // Standard channel closing + AddStep("Switch to previous channel", () => chatOverlay.ChannelTabControl.SwitchTab(-1)); + AddStep("Close current channel", () => + { + expectedChannel = nextChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Next channel selected", () => currentChannel == expectedChannel); + + // Selector reappearing after all channels closed + AddUntilStep("Close all channels", () => + { + if (!joinedChannels.Any()) + return true; + + chatOverlay.ChannelTabControl.RemoveChannel(joinedChannels.Last()); + return false; + }); AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); } + [Test] + public void TestChannelCloseButton() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // PM channel close button only appears when active + AddStep("Select PM channel", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Click PM close button", () => clickDrawable(((TestPrivateChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + AddAssert("PM channel closed", () => !channelManager.JoinedChannels.Contains(channel2)); + + // Non-PM chat channel close button only appears when hovered + AddStep("Hover normal channel tab", () => InputManager.MoveMouseTo(chatOverlay.TabMap[channel1])); + AddStep("Click normal close button", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); + } + + private void pressChannelHotkey(int number) + { + var channelKey = Key.Number0 + number; + InputManager.PressKey(Key.AltLeft); + InputManager.PressKey(channelKey); + InputManager.ReleaseKey(Key.AltLeft); + InputManager.ReleaseKey(channelKey); + } + private void clickDrawable(Drawable d) { InputManager.MoveMouseTo(d); @@ -121,7 +246,12 @@ namespace osu.Game.Tests.Visual.Online { ((BindableList)ChannelManager.AvailableChannels).AddRange(channels); - Child = ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, }; + InternalChildren = new Drawable[] + { + ChannelManager, + ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, }, + }; + ChatOverlay.Show(); } } @@ -130,6 +260,8 @@ namespace osu.Game.Tests.Visual.Online { public Visibility SelectionOverlayState => ChannelSelectionOverlay.State.Value; + public new ChannelTabControl ChannelTabControl => base.ChannelTabControl; + public new ChannelSelectionOverlay ChannelSelectionOverlay => base.ChannelSelectionOverlay; protected override ChannelTabControl CreateChannelTabControl() => new TestTabControl(); @@ -139,12 +271,22 @@ namespace osu.Game.Tests.Visual.Online private class TestTabControl : ChannelTabControl { - protected override TabItem CreateTabItem(Channel value) => new TestChannelTabItem(value); + protected override TabItem CreateTabItem(Channel value) + { + switch (value.Type) + { + case ChannelType.PM: + return new TestPrivateChannelTabItem(value); + + default: + return new TestChannelTabItem(value); + } + } public new IReadOnlyDictionary> TabMap => base.TabMap; } - private class TestChannelTabItem : PrivateChannelTabItem + private class TestChannelTabItem : ChannelTabItem { public TestChannelTabItem(Channel channel) : base(channel) @@ -153,5 +295,15 @@ namespace osu.Game.Tests.Visual.Online public new ClickableContainer CloseButton => base.CloseButton; } + + private class TestPrivateChannelTabItem : PrivateChannelTabItem + { + public TestPrivateChannelTabItem(Channel channel) + : base(channel) + { + } + + public new ClickableContainer CloseButton => base.CloseButton; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index 86bd0ddd11..08130e60db 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -3,10 +3,16 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; -using osu.Game.Online.API.Requests; -using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online @@ -14,46 +20,109 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneCommentsContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CommentsContainer), - typeof(CommentsHeader), - typeof(DrawableComment), - typeof(HeaderButton), - typeof(SortTabControl), - typeof(ShowChildrenButton), - typeof(DeletedChildrenPlaceholder), - typeof(VotePill) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - protected override bool UseOnlineAPI => true; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - public TestSceneCommentsContainer() - { - BasicScrollContainer scrollFlow; + private CommentsContainer commentsContainer; - Add(scrollFlow = new BasicScrollContainer + [SetUp] + public void SetUp() => Schedule(() => + Child = new BasicScrollContainer { RelativeSizeAxes = Axes.Both, + Child = commentsContainer = new CommentsContainer() }); - AddStep("Big Black comments", () => - { - scrollFlow.Clear(); - scrollFlow.Add(new CommentsContainer(CommentableType.Beatmapset, 41823)); - }); - - AddStep("Airman comments", () => - { - scrollFlow.Clear(); - scrollFlow.Add(new CommentsContainer(CommentableType.Beatmapset, 24313)); - }); - - AddStep("lazer build comments", () => - { - scrollFlow.Clear(); - scrollFlow.Add(new CommentsContainer(CommentableType.Build, 4772)); - }); + [Test] + public void TestIdleState() + { + AddUntilStep("loading spinner shown", + () => commentsContainer.ChildrenOfType().Single().IsLoading); } + + [Test] + public void TestSingleCommentsPage() + { + setUpCommentsResponse(exampleComments); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("show more button hidden", + () => commentsContainer.ChildrenOfType().Single().Alpha == 0); + } + + [Test] + public void TestMultipleCommentPages() + { + var comments = exampleComments; + comments.HasMore = true; + comments.TopLevelCount = 10; + + setUpCommentsResponse(comments); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("show more button visible", + () => commentsContainer.ChildrenOfType().Single().Alpha == 1); + } + + [Test] + public void TestMultipleLoads() + { + var comments = exampleComments; + int topLevelCommentCount = exampleComments.Comments.Count(comment => comment.IsTopLevel); + + AddStep("hide container", () => commentsContainer.Hide()); + setUpCommentsResponse(comments); + AddRepeatStep("show comments multiple times", + () => commentsContainer.ShowComments(CommentableType.Beatmapset, 456), 2); + AddStep("show container", () => commentsContainer.Show()); + AddUntilStep("comment count is correct", + () => commentsContainer.ChildrenOfType().Count() == topLevelCommentCount); + } + + private void setUpCommentsResponse(CommentBundle commentBundle) + => AddStep("set up response", () => + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetCommentsRequest getCommentsRequest)) + return; + + getCommentsRequest.TriggerSuccess(commentBundle); + }; + }); + + private CommentBundle exampleComments => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "This is a comment", + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 19, + RepliesCount = 1 + }, + new Comment + { + Id = 5, + ParentId = 1, + Message = "This is a child comment", + LegacyName = "SecondUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 4, + }, + new Comment + { + Id = 10, + Message = "This is another comment", + LegacyName = "ThirdUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0 + }, + }, + IncludedComments = new List(), + }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs index bc3e0eff1a..03eac5d85b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Overlays; using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online @@ -12,12 +12,8 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneCommentsHeader : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CommentsHeader), - typeof(HeaderButton), - typeof(SortTabControl), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly Bindable sort = new Bindable(); private readonly BindableBool showDeleted = new BindableBool(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs new file mode 100644 index 0000000000..7fdf0708e0 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs @@ -0,0 +1,226 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Overlays.Comments; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osuTK; +using JetBrains.Annotations; +using NUnit.Framework; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneCommentsPage : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly BindableBool showDeleted = new BindableBool(); + private readonly Container content; + + private TestCommentsPage commentsPage; + + public TestSceneCommentsPage() + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Y, + Width = 200, + Child = new OsuCheckbox + { + Current = showDeleted, + LabelText = @"Show Deleted" + } + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }); + } + + [Test] + public void TestAppendDuplicatedComment() + { + AddStep("Create page", () => createPage(getCommentBundle())); + AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); + AddStep("Append existing comment", () => commentsPage?.AppendComments(getCommentSubBundle())); + AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); + } + + [Test] + public void TestEmptyBundle() + { + AddStep("Create page", () => createPage(getEmptyCommentBundle())); + AddAssert("Dictionary length is 0", () => commentsPage?.DictionaryLength == 0); + } + + private void createPage(CommentBundle commentBundle) + { + commentsPage = null; + content.Clear(); + content.Add(commentsPage = new TestCommentsPage(commentBundle) + { + ShowDeleted = { BindTarget = showDeleted } + }); + } + + private CommentBundle getEmptyCommentBundle() => new CommentBundle + { + Comments = new List(), + }; + + private CommentBundle getCommentBundle() => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "Simple test comment", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + new Comment + { + Id = 100, + Message = "This comment has \"load replies\" button because it has unloaded replies", + LegacyName = "TestUser1100", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5, + RepliesCount = 2, + }, + new Comment + { + Id = 111, + Message = "This comment has \"Show More\" button because it has unloaded replies, but some of them are loaded", + LegacyName = "TestUser1111", + CreatedAt = DateTimeOffset.Now, + VotesCount = 100, + RepliesCount = 2, + }, + new Comment + { + Id = 112, + ParentId = 111, + Message = "I'm here to make my parent work", + LegacyName = "someone", + CreatedAt = DateTimeOffset.Now, + VotesCount = 2, + }, + new Comment + { + Id = 2, + Message = "This comment has been deleted :( but visible for admins", + LegacyName = "TestUser2", + CreatedAt = DateTimeOffset.Now, + DeletedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + new Comment + { + Id = 3, + Message = "This comment is a top level", + LegacyName = "TestUser3", + CreatedAt = DateTimeOffset.Now, + RepliesCount = 2, + }, + new Comment + { + Id = 4, + ParentId = 3, + Message = "And this is a reply", + RepliesCount = 1, + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 15, + ParentId = 4, + Message = "Reply to reply", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 6, + ParentId = 3, + LegacyName = "TestUser11515", + CreatedAt = DateTimeOffset.Now, + DeletedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 5, + Message = "This comment is voted and edited", + LegacyName = "BigBrainUser", + CreatedAt = DateTimeOffset.Now, + EditedAt = DateTimeOffset.Now, + VotesCount = 1000, + EditedById = 1, + } + }, + IncludedComments = new List(), + UserVotes = new List + { + 5 + }, + Users = new List + { + new User + { + Id = 1, + Username = "Good_Admin" + } + }, + }; + + private CommentBundle getCommentSubBundle() => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "Simple test comment", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + }, + IncludedComments = new List(), + }; + + private class TestCommentsPage : CommentsPage + { + public TestCommentsPage(CommentBundle commentBundle) + : base(commentBundle) + { + } + + public new void AppendComments([NotNull] CommentBundle bundle) => base.AppendComments(bundle); + + public int DictionaryLength => CommentDictionary.Count; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs new file mode 100644 index 0000000000..960d3fa248 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.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.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneDashboardOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + private readonly DashboardOverlay overlay; + + public TestSceneDashboardOverlay() + { + Add(overlay = new DashboardOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 5b0c2d3c67..684ce10820 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets.Osu; using osu.Game.Tests.Resources; using osuTK; @@ -18,11 +16,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneDirectDownloadButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PanelDownloadButton) - }; - private TestDownloadButton downloadButton; [Resolved] @@ -143,14 +136,14 @@ namespace osu.Game.Tests.Visual.Online return beatmap; } - private class TestDownloadButton : PanelDownloadButton + private class TestDownloadButton : BeatmapPanelDownloadButton { public new bool DownloadEnabled => base.DownloadEnabled; public DownloadState DownloadState => State.Value; - public TestDownloadButton(BeatmapSetInfo beatmapSet, bool noVideo = false) - : base(beatmapSet, noVideo) + public TestDownloadButton(BeatmapSetInfo beatmapSet) + : base(beatmapSet) { } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs deleted file mode 100644 index d9873ea243..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs +++ /dev/null @@ -1,215 +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.Game.Beatmaps; -using osu.Game.Overlays; - -namespace osu.Game.Tests.Visual.Online -{ - [TestFixture] - public class TestSceneDirectOverlay : OsuTestScene - { - private DirectOverlay direct; - - protected override bool UseOnlineAPI => true; - - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(direct = new DirectOverlay()); - newBeatmaps(); - - AddStep(@"toggle", direct.ToggleVisibility); - AddStep(@"result counts", () => direct.ResultAmounts = new DirectOverlay.ResultCounts(1, 4, 13)); - AddStep(@"trigger disabled", () => Ruleset.Disabled = !Ruleset.Disabled); - } - - private void newBeatmaps() - { - direct.BeatmapSets = new[] - { - new BeatmapSetInfo - { - OnlineBeatmapSetID = 578332, - Metadata = new BeatmapMetadata - { - Title = @"OrVid", - Artist = @"An", - AuthorString = @"RLC", - Source = @"", - Tags = @"acuticnotes an-fillnote revid tear tearvid encrpted encryption axi axivid quad her hervid recoll", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/578332/covers/card.jpg?1494591390", - Cover = @"https://assets.ppy.sh/beatmaps/578332/covers/cover.jpg?1494591390", - }, - Preview = @"https://b.ppy.sh/preview/578332.mp3", - PlayCount = 97, - FavouriteCount = 72, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.35f, - Metadata = new BeatmapMetadata(), - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 599627, - Metadata = new BeatmapMetadata - { - Title = @"tiny lamp", - Artist = @"fhana", - AuthorString = @"Sotarks", - Source = @"ぎんぎつね", - Tags = @"lantis junichi sato yuxuki waga kevin mitsunaga towana gingitsune opening op full ver version kalibe collab collaboration", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/599627/covers/card.jpg?1494539318", - Cover = @"https://assets.ppy.sh/beatmaps/599627/covers/cover.jpg?1494539318", - }, - Preview = @"https//b.ppy.sh/preview/599627.mp3", - PlayCount = 3082, - FavouriteCount = 14, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.81f, - Metadata = new BeatmapMetadata(), - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 513268, - Metadata = new BeatmapMetadata - { - Title = @"At Gwanghwamun", - Artist = @"KYUHYUN", - AuthorString = @"Cerulean Veyron", - Source = @"", - Tags = @"soul ballad kh super junior sj suju 슈퍼주니어 kt뮤직 sm엔터테인먼트 s.m.entertainment kt music 1st mini album ep", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/513268/covers/card.jpg?1494502863", - Cover = @"https://assets.ppy.sh/beatmaps/513268/covers/cover.jpg?1494502863", - }, - Preview = @"https//b.ppy.sh/preview/513268.mp3", - PlayCount = 2762, - FavouriteCount = 15, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 0.9f, - Metadata = new BeatmapMetadata(), - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 1.1f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.02f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.49f, - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 586841, - Metadata = new BeatmapMetadata - { - Title = @"RHAPSODY OF BLUE SKY", - Artist = @"fhana", - AuthorString = @"[Kamiya]", - Source = @"小林さんちのメイドラゴン", - Tags = @"kobayashi san chi no maidragon aozora no opening anime maid dragon oblivion karen dynamix imoutosan pata-mon gxytcgxytc", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/586841/covers/card.jpg?1494052741", - Cover = @"https://assets.ppy.sh/beatmaps/586841/covers/cover.jpg?1494052741", - }, - Preview = @"https//b.ppy.sh/preview/586841.mp3", - PlayCount = 62317, - FavouriteCount = 161, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 1.26f, - Metadata = new BeatmapMetadata(), - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.01f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.87f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.76f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.93f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 4.37f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.13f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.42f, - }, - }, - }, - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs index 731cb62518..74ece5da05 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.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.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets; using osu.Game.Users; using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneDirectPanel : OsuTestScene + [Cached(typeof(IPreviewTrackOwner))] + public class TestSceneDirectPanel : OsuTestScene, IPreviewTrackOwner { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DirectGridPanel), - typeof(DirectListPanel), - typeof(IconPill) - }; - private BeatmapSetInfo getUndownloadableBeatmapSet() => new BeatmapSetInfo { OnlineBeatmapSetID = 123, @@ -124,12 +118,12 @@ namespace osu.Game.Tests.Visual.Online Spacing = new Vector2(5, 20), Children = new Drawable[] { - new DirectGridPanel(normal), - new DirectGridPanel(undownloadable), - new DirectGridPanel(manyDifficulties), - new DirectListPanel(normal), - new DirectListPanel(undownloadable), - new DirectListPanel(manyDifficulties), + new GridBeatmapPanel(normal), + new GridBeatmapPanel(undownloadable), + new GridBeatmapPanel(manyDifficulties), + new ListBeatmapPanel(normal), + new ListBeatmapPanel(undownloadable), + new ListBeatmapPanel(manyDifficulties), }, }, }; diff --git a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs index 637b577021..31bb276cd4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Game.Graphics.UserInterface; using osuTK; @@ -10,8 +8,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneExternalLinkButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(ExternalLinkButton) }; - public TestSceneExternalLinkButton() { Child = new ExternalLinkButton("https://osu.ppy.sh/home") diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs new file mode 100644 index 0000000000..72033fc121 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.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 osu.Framework.Graphics.Containers; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Framework.Graphics; +using osu.Game.Users; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using NUnit.Framework; +using osu.Game.Online.API; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneFriendDisplay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private TestFriendDisplay display; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = display = new TestFriendDisplay() + }; + }); + + [Test] + public void TestOffline() + { + AddStep("Populate", () => display.Users = getUsers()); + } + + [Test] + public void TestOnline() + { + AddStep("Fetch online", () => display?.Fetch()); + } + + private List getUsers() => new List + { + new User + { + Username = "flyte", + Id = 3103765, + IsOnline = true, + CurrentModeRank = 1111, + Country = new Country { FlagName = "JP" }, + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + }, + new User + { + Username = "peppy", + Id = 2, + IsOnline = false, + CurrentModeRank = 2222, + Country = new Country { FlagName = "AU" }, + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + IsSupporter = true, + SupportLevel = 3, + }, + new User + { + Username = "Evast", + Id = 8195163, + Country = new Country { FlagName = "BY" }, + CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsOnline = false, + LastVisit = DateTimeOffset.Now + } + }; + + private class TestFriendDisplay : FriendDisplay + { + public void Fetch() + { + base.APIStateChanged(API, APIState.Online); + } + + public override void APIStateChanged(IAPIProvider api, APIState state) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs index fe8437be17..e60adcee34 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs @@ -41,6 +41,7 @@ namespace osu.Game.Tests.Visual.Online private class TestFullscreenOverlay : FullscreenOverlay { public TestFullscreenOverlay() + : base(OverlayColourScheme.Pink) { Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs index d3b037f499..3ecca85ef1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs @@ -1,15 +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.Collections.Generic; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections; -using osu.Game.Overlays.Profile.Sections.Historical; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -19,13 +18,8 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(HistoricalSection), - typeof(PaginatedMostPlayedBeatmapContainer), - typeof(DrawableMostPlayedBeatmap), - typeof(DrawableProfileRow) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); public TestSceneHistoricalSection() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs index 325d657f0e..2231139856 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs @@ -16,11 +16,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneKudosuHistory : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableKudosuHistoryItem), - }; - private readonly Box background; public TestSceneKudosuHistory() diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs index e0e5a088ce..54e655d4ec 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Overlays.BeatmapSet; -using System; -using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; using osu.Framework.Graphics; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Mania; @@ -15,16 +15,12 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Bindables; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; namespace osu.Game.Tests.Visual.Online { public class TestSceneLeaderboardModSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(LeaderboardModSelector), - }; - public TestSceneLeaderboardModSelector() { LeaderboardModSelector modSelector; @@ -44,27 +40,31 @@ namespace osu.Game.Tests.Visual.Online Ruleset = { BindTarget = ruleset } }); - modSelector.SelectedMods.ItemsAdded += mods => + modSelector.SelectedMods.CollectionChanged += (_, args) => { - mods.ForEach(mod => selectedMods.Add(new OsuSpriteText + switch (args.Action) { - Text = mod.Acronym, - })); - }; - - modSelector.SelectedMods.ItemsRemoved += mods => - { - mods.ForEach(mod => - { - foreach (var selected in selectedMods) - { - if (selected.Text == mod.Acronym) + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(mod => selectedMods.Add(new OsuSpriteText { - selectedMods.Remove(selected); - break; - } - } - }); + Text = mod.Acronym, + })); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(mod => + { + foreach (var selected in selectedMods) + { + if (selected.Text == mod.Acronym) + { + selectedMods.Remove(selected); + break; + } + } + }); + break; + } }; AddStep("osu ruleset", () => ruleset.Value = new OsuRuleset().RulesetInfo); diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs index cc3b2ac68b..afa559280c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs @@ -2,20 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Overlays.BeatmapSet; -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Bindables; using osu.Game.Screens.Select.Leaderboards; +using osu.Framework.Allocation; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { public class TestSceneLeaderboardScopeSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(LeaderboardScopeSelector), - }; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneLeaderboardScopeSelector() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs new file mode 100644 index 0000000000..0446cadac9 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.News; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osuTK; +using System; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsCard : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneNewsCard() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new[] + { + new NewsCard(new APINewsPost + { + Title = "This post has an image which starts with \"/\" and has many authors!", + Preview = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + Author = "someone, someone1, someone2, someone3, someone4", + FirstImage = "/help/wiki/shared/news/banners/monthly-beatmapping-contest.png", + PublishedAt = DateTimeOffset.Now + }), + new NewsCard(new APINewsPost + { + Title = "This post has a full-url image! (HTML entity: &)", + Preview = "boom (HTML entity: &)", + Author = "user (HTML entity: &)", + FirstImage = "https://assets.ppy.sh/artists/88/header.jpg", + PublishedAt = DateTimeOffset.Now + }) + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs new file mode 100644 index 0000000000..103308d34d --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Chat; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [HeadlessTest] + public class TestSceneNowPlayingCommand : OsuTestScene + { + [Cached(typeof(IChannelPostTarget))] + private PostTarget postTarget { get; set; } + + public TestSceneNowPlayingCommand() + { + Add(postTarget = new PostTarget()); + } + + [Test] + public void TestGenericActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby()); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is listening")); + } + + [Test] + public void TestEditActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.Editing(new BeatmapInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is editing")); + } + + [Test] + public void TestPlayActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.SoloGame(new BeatmapInfo(), new RulesetInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is playing")); + } + + [TestCase(true)] + [TestCase(false)] + public void TestLinkPresence(bool hasOnlineId) + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby()); + + AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(null, null) + { + BeatmapInfo = { OnlineBeatmapID = hasOnlineId ? 1234 : (int?)null } + }); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + if (hasOnlineId) + AddAssert("Check link presence", () => postTarget.LastMessage.Contains("https://osu.ppy.sh/b/1234")); + else + AddAssert("Check link not present", () => !postTarget.LastMessage.Contains("https://")); + } + + public class PostTarget : Component, IChannelPostTarget + { + public void PostMessage(string text, bool isAction = false, Channel target = null) + { + LastMessage = text; + } + + public string LastMessage { get; private set; } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs new file mode 100644 index 0000000000..9591d53b24 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.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 NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osu.Game.Online.API; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneOnlineViewContainer : OsuTestScene + { + private readonly TestOnlineViewContainer onlineView; + + public TestSceneOnlineViewContainer() + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Child = onlineView = new TestOnlineViewContainer() + }; + } + + [Test] + public void TestOnlineStateVisibility() + { + AddStep("set status to online", () => ((DummyAPIAccess)API).State = APIState.Online); + + AddUntilStep("children are visible", () => onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is not visible", () => !onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestOfflineStateVisibility() + { + AddStep("set status to offline", () => ((DummyAPIAccess)API).State = APIState.Offline); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is not visible", () => !onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestConnectingStateVisibility() + { + AddStep("set status to connecting", () => ((DummyAPIAccess)API).State = APIState.Connecting); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is visible", () => onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestFailingStateVisibility() + { + AddStep("set status to failing", () => ((DummyAPIAccess)API).State = APIState.Failing); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is visible", () => onlineView.LoadingSpinner.IsPresent); + } + + private class TestOnlineViewContainer : OnlineViewContainer + { + public new LoadingSpinner LoadingSpinner => base.LoadingSpinner; + + public CompositeDrawable ViewTarget => base.Content; + + public TestOnlineViewContainer() + : base(@"Please sign in to view dummy test content") + { + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Blue.Opacity(0.8f), + }, + new OsuSpriteText + { + Text = "dummy online content", + Font = OsuFont.Default.With(size: 40), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneProfileCounterPill.cs b/osu.Game.Tests/Visual/Online/TestSceneProfileCounterPill.cs index 468239cf08..eaa989f0de 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneProfileCounterPill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneProfileCounterPill.cs @@ -1,21 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections; namespace osu.Game.Tests.Visual.Online { public class TestSceneProfileCounterPill : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CounterPill) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); private readonly CounterPill pill; private readonly BindableInt value = new BindableInt(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs index 1f5ba67e03..6a847e4269 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs @@ -3,29 +3,26 @@ using osu.Framework.Graphics; using osu.Game.Overlays.Profile.Header.Components; -using System; -using System.Collections.Generic; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; using osu.Game.Users; using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.Online { public class TestSceneProfileRulesetSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileRulesetSelector), - typeof(ProfileRulesetTabItem), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneProfileRulesetSelector() { ProfileRulesetSelector selector; - Bindable user = new Bindable(); + var user = new Bindable(); Child = selector = new ProfileRulesetSelector { diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs index c70cc4ae4e..3b31192259 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.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 System.Collections.Generic; using NUnit.Framework; +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 osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -18,11 +17,8 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneRankGraph : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RankGraph), - typeof(LineGraph) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); public TestSceneRankGraph() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs new file mode 100644 index 0000000000..458ba80712 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays.Rankings; +using osu.Game.Users; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsCountryFilter : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsCountryFilter() + { + var countryBindable = new Bindable(); + + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new CountryFilter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Current = { BindTarget = countryBindable } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Some content", + Margin = new MarginPadding { Vertical = 20 } + } + } + } + }); + + var country = new Country + { + FlagName = "BY", + FullName = "Belarus" + }; + var unknownCountry = new Country + { + FlagName = "CK", + FullName = "Cook Islands" + }; + + AddStep("Set country", () => countryBindable.Value = country); + AddStep("Set null country", () => countryBindable.Value = null); + AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsDismissableFlag.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsDismissableFlag.cs deleted file mode 100644 index cd954cd6bd..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsDismissableFlag.cs +++ /dev/null @@ -1,66 +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.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays.Rankings; -using osu.Game.Users; -using osuTK; - -namespace osu.Game.Tests.Visual.Online -{ - public class TestSceneRankingsDismissableFlag : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DismissableFlag), - }; - - public TestSceneRankingsDismissableFlag() - { - DismissableFlag flag; - SpriteText text; - - var countryA = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - - var countryB = new Country - { - FlagName = "US", - FullName = "United States" - }; - - AddRange(new Drawable[] - { - flag = new DismissableFlag - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(30, 20), - Country = countryA, - }, - text = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = "Invoked", - Font = OsuFont.GetFont(size: 30), - Alpha = 0, - } - }); - - flag.Action += () => text.FadeIn().Then().FadeOut(1000, Easing.OutQuint); - - AddStep("Trigger click", () => flag.Click()); - AddStep("Change to country 2", () => flag.Country = countryB); - AddStep("Change to country 1", () => flag.Country = countryA); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs index e708934bc3..677952681c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.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.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; +using osu.Game.Overlays; using osu.Game.Overlays.Rankings; using osu.Game.Rulesets; using osu.Game.Users; @@ -13,14 +12,8 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneRankingsHeader : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DismissableFlag), - typeof(HeaderTitle), - typeof(RankingsRulesetSelector), - typeof(RankingsScopeSelector), - typeof(RankingsHeader), - }; + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneRankingsHeader() { @@ -28,31 +21,11 @@ namespace osu.Game.Tests.Visual.Online var ruleset = new Bindable(); var scope = new Bindable(); - Add(new RankingsHeader + Add(new RankingsOverlayHeader { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scope = { BindTarget = scope }, + Current = { BindTarget = scope }, Country = { BindTarget = countryBindable }, - Ruleset = { BindTarget = ruleset }, - Spotlights = new[] - { - new Spotlight - { - Id = 1, - Text = "Spotlight 1" - }, - new Spotlight - { - Id = 2, - Text = "Spotlight 2" - }, - new Spotlight - { - Id = 3, - Text = "Spotlight 3" - } - } + Ruleset = { BindTarget = ruleset } }); var country = new Country diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeaderTitle.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeaderTitle.cs deleted file mode 100644 index 0edf104da0..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeaderTitle.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Overlays.Rankings; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Online -{ - public class TestSceneRankingsHeaderTitle : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DismissableFlag), - typeof(HeaderTitle), - }; - - public TestSceneRankingsHeaderTitle() - { - var countryBindable = new Bindable(); - var scope = new Bindable(); - - Add(new HeaderTitle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Country = { BindTarget = countryBindable }, - Scope = { BindTarget = scope }, - }); - - var countryA = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - - var countryB = new Country - { - FlagName = "US", - FullName = "United States" - }; - - AddStep("Set country 1", () => countryBindable.Value = countryA); - AddStep("Set country 2", () => countryBindable.Value = countryB); - AddStep("Set null country", () => countryBindable.Value = null); - AddStep("Set scope to Performance", () => scope.Value = RankingsScope.Performance); - AddStep("Set scope to Spotlights", () => scope.Value = RankingsScope.Spotlights); - AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); - AddStep("Set scope to Country", () => scope.Value = RankingsScope.Country); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index 568e36df4c..626f545b91 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using osu.Game.Overlays.Rankings.Tables; using osu.Framework.Allocation; using osu.Game.Overlays; using NUnit.Framework; @@ -17,19 +14,8 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PerformanceTable), - typeof(ScoresTable), - typeof(CountriesTable), - typeof(TableRowBackground), - typeof(UserBasedTable), - typeof(RankingsTable<>), - typeof(RankingsOverlay) - }; - - [Cached] - private RankingsOverlay rankingsOverlay; + [Cached(typeof(RankingsOverlay))] + private readonly RankingsOverlay rankingsOverlay; private readonly Bindable countryBindable = new Bindable(); private readonly Bindable scope = new Bindable(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsRulesetSelector.cs deleted file mode 100644 index 84515bd3a4..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsRulesetSelector.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 System; -using System.Collections.Generic; -using osu.Game.Overlays.Rankings; -using osu.Framework.Graphics; -using osu.Game.Rulesets; -using osu.Framework.Bindables; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Taiko; -using osu.Game.Rulesets.Catch; - -namespace osu.Game.Tests.Visual.Online -{ - public class TestSceneRankingsRulesetSelector : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RankingsRulesetSelector), - }; - - public TestSceneRankingsRulesetSelector() - { - var current = new Bindable(); - - Add(new RankingsRulesetSelector - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Current = { BindTarget = current } - }); - - AddStep("Select osu!", () => current.Value = new OsuRuleset().RulesetInfo); - AddStep("Select mania", () => current.Value = new ManiaRuleset().RulesetInfo); - AddStep("Select taiko", () => current.Value = new TaikoRuleset().RulesetInfo); - AddStep("Select catch", () => current.Value = new CatchRuleset().RulesetInfo); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsScopeSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsScopeSelector.cs deleted file mode 100644 index 3693d6b5b4..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsScopeSelector.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 System; -using System.Collections.Generic; -using osu.Framework.Graphics; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Allocation; -using osu.Game.Graphics; -using osu.Game.Overlays.Rankings; - -namespace osu.Game.Tests.Visual.Online -{ - public class TestSceneRankingsScopeSelector : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RankingsScopeSelector), - }; - - private readonly Box background; - - public TestSceneRankingsScopeSelector() - { - var scope = new Bindable(); - - AddRange(new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new RankingsScopeSelector - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Current = scope, - } - }); - - AddStep(@"Select country", () => scope.Value = RankingsScope.Country); - AddStep(@"Select performance", () => scope.Value = RankingsScope.Performance); - AddStep(@"Select score", () => scope.Value = RankingsScope.Score); - AddStep(@"Select spotlights", () => scope.Value = RankingsScope.Spotlights); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - background.Colour = colours.GreySeafoam; - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs new file mode 100644 index 0000000000..997db827f3 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +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.Rankings; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsSpotlightSelector : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly SpotlightSelector selector; + + public TestSceneRankingsSpotlightSelector() + { + Add(selector = new SpotlightSelector()); + } + + [Test] + public void TestVisibility() + { + AddStep("Toggle Visibility", selector.ToggleVisibility); + } + + [Test] + public void TestLocalSpotlights() + { + var spotlights = new[] + { + new APISpotlight + { + Name = "Spotlight 1", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + new APISpotlight + { + Name = "Spotlight 2", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + new APISpotlight + { + Name = "Spotlight 3", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + }; + + AddStep("load spotlights", () => selector.Spotlights = spotlights); + AddStep("change to spotlight 3", () => selector.Current.Value = spotlights[2]); + } + + [Test] + public void TestOnlineSpotlights() + { + List spotlights = null; + + AddStep("retrieve spotlights", () => + { + var req = new GetSpotlightsRequest(); + req.Success += res => spotlights = res.Spotlights; + + api.Perform(req); + }); + + AddStep("set spotlights", () => + { + if (spotlights != null) + selector.Spotlights = spotlights; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs index 93da2a439e..ee109189c7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Rankings.Tables; using osu.Framework.Graphics; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; -using osu.Game.Graphics.UserInterface; using System.Threading; using osu.Game.Online.API; using osu.Game.Rulesets.Osu; @@ -16,6 +13,9 @@ using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Catch; using osu.Framework.Allocation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; namespace osu.Game.Tests.Visual.Online { @@ -23,21 +23,14 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PerformanceTable), - typeof(ScoresTable), - typeof(CountriesTable), - typeof(TableRowBackground), - typeof(UserBasedTable), - typeof(RankingsTable<>) - }; - [Resolved] private IAPIProvider api { get; set; } + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + private readonly BasicScrollContainer scrollFlow; - private readonly DimmedLoadingLayer loading; + private readonly LoadingLayer loading; private CancellationTokenSource cancellationToken; private APIRequest request; @@ -52,7 +45,7 @@ namespace osu.Game.Tests.Visual.Online RelativeSizeAxes = Axes.Both, Width = 0.8f, }, - loading = new DimmedLoadingLayer(), + loading = new LoadingLayer(), }; } @@ -64,6 +57,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("Mania scores", () => createScoreTable(new ManiaRuleset().RulesetInfo)); AddStep("Taiko country scores", () => createCountryTable(new TaikoRuleset().RulesetInfo)); AddStep("Catch US performance page 10", () => createPerformanceTable(new CatchRuleset().RulesetInfo, "US", 10)); + AddStep("Osu spotlight table (chart 271)", () => createSpotlightTable(new OsuRuleset().RulesetInfo, 271)); } private void createCountryTable(RulesetInfo ruleset, int page = 1) @@ -108,6 +102,20 @@ namespace osu.Game.Tests.Visual.Online api.Queue(request); } + private void createSpotlightTable(RulesetInfo ruleset, int spotlight) + { + onLoadStarted(); + + request = new GetSpotlightRankingsRequest(ruleset, spotlight, RankingsSortCriteria.All); + ((GetSpotlightRankingsRequest)request).Success += rankings => Schedule(() => + { + var table = new ScoresTable(1, rankings.Users); + loadTable(table); + }); + + api.Queue(request); + } + private void onLoadStarted() { loading.Show(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index b19f2dbf31..0cb8cc22ec 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using 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.MathUtils; +using osu.Framework.Utils; 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.Scoring; @@ -18,14 +19,8 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneScoresContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableTopScore), - typeof(TopScoreUserSection), - typeof(TopScoreStatisticsSection), - typeof(ScoreTable), - typeof(ScoreTableRowBackground), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneScoresContainer() { @@ -190,6 +185,29 @@ namespace osu.Game.Tests.Visual.Online Position = 1337, }; + var myBestScoreWithNullPosition = new APILegacyUserTopScoreInfo + { + Score = new APILegacyScoreInfo + { + User = new User + { + Id = 7151382, + Username = @"Mayuri Hana", + Country = new Country + { + FullName = @"Thailand", + FlagName = @"TH", + }, + }, + Rank = ScoreRank.D, + PP = 160, + MaxCombo = 1234, + TotalScore = 123456, + Accuracy = 0.6543, + }, + Position = null, + }; + var oneScore = new APILegacyScores { Scores = new List @@ -245,6 +263,12 @@ namespace osu.Game.Tests.Visual.Online allScores.UserScore = myBestScore; scoresContainer.Scores = allScores; }); + + AddStep("Load scores with null my best position", () => + { + allScores.UserScore = myBestScoreWithNullPosition; + scoresContainer.Scores = allScores; + }); } private class TestScoresContainer : ScoresContainer diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index b9fbbfef6b..273f593c32 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; @@ -12,11 +10,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneShowMoreButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ShowMoreButton), - }; - public TestSceneShowMoreButton() { TestButton button = null; diff --git a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs deleted file mode 100644 index dbd7544b38..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs +++ /dev/null @@ -1,96 +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 NUnit.Framework; -using osu.Game.Overlays; -using osu.Game.Overlays.Social; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Online -{ - [TestFixture] - public class TestSceneSocialOverlay : OsuTestScene - { - protected override bool UseOnlineAPI => true; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(UserPanel), - typeof(SocialPanel), - typeof(FilterControl), - typeof(SocialGridPanel), - typeof(SocialListPanel) - }; - - public TestSceneSocialOverlay() - { - SocialOverlay s = new SocialOverlay - { - Users = new[] - { - new User - { - Username = @"flyte", - Id = 3103765, - Country = new Country { FlagName = @"JP" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", - }, - new User - { - Username = @"Cookiezi", - Id = 124493, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - }, - new User - { - Username = @"Angelsim", - Id = 1777162, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", - }, - new User - { - Username = @"Rafis", - Id = 2558286, - Country = new Country { FlagName = @"PL" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg", - }, - new User - { - Username = @"hvick225", - Id = 50265, - Country = new Country { FlagName = @"TW" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c5.jpg", - }, - new User - { - Username = @"peppy", - Id = 2, - Country = new Country { FlagName = @"AU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" - }, - new User - { - Username = @"filsdelama", - Id = 2831793, - Country = new Country { FlagName = @"FR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c7.jpg" - }, - new User - { - Username = @"_index", - Id = 652457, - Country = new Country { FlagName = @"RU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c8.jpg" - }, - }, - }; - Add(s); - - AddStep(@"toggle", s.ToggleVisibility); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs new file mode 100644 index 0000000000..266dcb013b --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.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.Game.Overlays; +using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneSpotlightsLayout : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneSpotlightsLayout() + { + var ruleset = new Bindable(new OsuRuleset().RulesetInfo); + + Add(new BasicScrollContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Child = new SpotlightsLayout + { + Ruleset = { BindTarget = ruleset } + } + }); + + AddStep("Osu ruleset", () => ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("Mania ruleset", () => ruleset.Value = new ManiaRuleset().RulesetInfo); + AddStep("Taiko ruleset", () => ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("Catch ruleset", () => ruleset.Value = new CatchRuleset().RulesetInfo); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 28b5693ef4..492abdd88d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -128,17 +128,17 @@ namespace osu.Game.Tests.Visual.Online const int messages_per_call = 10; AddRepeatStep("add many messages", () => + { + for (int i = 0; i < messages_per_call; i++) { - for (int i = 0; i < messages_per_call; i++) + testChannel.AddNewMessages(new Message(sequence++) { - testChannel.AddNewMessages(new Message(sequence++) - { - Sender = longUsernameUser, - Content = "Many messages! " + Guid.NewGuid(), - Timestamp = DateTimeOffset.Now - }); - } - }, Channel.MAX_HISTORY / messages_per_call + 5); + Sender = longUsernameUser, + Content = "Many messages! " + Guid.NewGuid(), + Timestamp = DateTimeOffset.Now + }); + } + }, Channel.MAX_HISTORY / messages_per_call + 5); AddAssert("Ensure no adjacent day separators", () => { diff --git a/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs index 4702d24125..f168ae5035 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs @@ -1,21 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Bindables; using osu.Game.Overlays.Comments; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Framework.Allocation; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { public class TestSceneTotalCommentsCounter : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TotalCommentsCounter), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneTotalCommentsCounter() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 54f06d6ad2..f763e50067 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets; using osu.Game.Users; using osuTK; @@ -13,28 +16,40 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserPanel : OsuTestScene { - private readonly UserPanel peppy; + private readonly Bindable activity = new Bindable(); + private readonly Bindable status = new Bindable(); - public TestSceneUserPanel() + private UserGridPanel peppy; + private TestUserListPanel evast; + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [SetUp] + public void SetUp() => Schedule(() => { - UserPanel flyte; + UserGridPanel flyte; - Add(new FillFlowContainer + activity.Value = null; + status.Value = null; + + Child = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Spacing = new Vector2(10f), - Children = new[] + Children = new Drawable[] { - flyte = new UserPanel(new User + flyte = new UserGridPanel(new User { Username = @"flyte", Id = 3103765, Country = new Country { FlagName = @"JP" }, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }) { Width = 300 }, - peppy = new UserPanel(new User + peppy = new UserGridPanel(new User { Username = @"peppy", Id = 2, @@ -43,35 +58,75 @@ namespace osu.Game.Tests.Visual.Online IsSupporter = true, SupportLevel = 3, }) { Width = 300 }, + evast = new TestUserListPanel(new User + { + Username = @"Evast", + Id = 8195163, + Country = new Country { FlagName = @"BY" }, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsOnline = false, + LastVisit = DateTimeOffset.Now + }) }, - }); + }; flyte.Status.Value = new UserStatusOnline(); - peppy.Status.Value = null; - } - - [Test] - public void UserStatusesTests() - { - AddStep("online", () => { peppy.Status.Value = new UserStatusOnline(); }); - AddStep(@"do not disturb", () => { peppy.Status.Value = new UserStatusDoNotDisturb(); }); - AddStep(@"offline", () => { peppy.Status.Value = new UserStatusOffline(); }); - AddStep(@"null status", () => { peppy.Status.Value = null; }); - } - - [Test] - public void UserActivitiesTests() - { - Bindable activity = new Bindable(); + peppy.Status.BindTo(status); peppy.Activity.BindTo(activity); - AddStep("idle", () => { activity.Value = null; }); - AddStep("spectating", () => { activity.Value = new UserActivity.Spectating(); }); - AddStep("solo", () => { activity.Value = new UserActivity.SoloGame(null, null); }); - AddStep("choosing", () => { activity.Value = new UserActivity.ChoosingBeatmap(); }); - AddStep("editing", () => { activity.Value = new UserActivity.Editing(null); }); - AddStep("modding", () => { activity.Value = new UserActivity.Modding(); }); + evast.Status.BindTo(status); + evast.Activity.BindTo(activity); + }); + + [Test] + public void TestUserStatus() + { + AddStep("online", () => status.Value = new UserStatusOnline()); + AddStep("do not disturb", () => status.Value = new UserStatusDoNotDisturb()); + AddStep("offline", () => status.Value = new UserStatusOffline()); + AddStep("null status", () => status.Value = null); + } + + [Test] + public void TestUserActivity() + { + AddStep("set online status", () => status.Value = new UserStatusOnline()); + + AddStep("idle", () => activity.Value = null); + AddStep("spectating", () => activity.Value = new UserActivity.Spectating()); + 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()); + } + + [Test] + public void TestUserActivityChange() + { + AddAssert("visit message is visible", () => evast.LastVisitMessage.IsPresent); + AddStep("set online status", () => status.Value = new UserStatusOnline()); + AddAssert("visit message is not visible", () => !evast.LastVisitMessage.IsPresent); + AddStep("set choosing activity", () => activity.Value = new UserActivity.ChoosingBeatmap()); + AddStep("set offline status", () => status.Value = new UserStatusOffline()); + AddAssert("visit message is visible", () => evast.LastVisitMessage.IsPresent); + AddStep("set online status", () => status.Value = new UserStatusOnline()); + AddAssert("visit message is not visible", () => !evast.LastVisitMessage.IsPresent); + } + + private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.SoloGame(null, rulesetStore.GetRuleset(rulesetId)); + + private class TestUserListPanel : UserListPanel + { + public TestUserListPanel(User user) + : base(user) + { + } + + public new TextFlowContainer LastVisitMessage => base.LastVisitMessage; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 63b46c991f..04b741b2bb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -2,15 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays; using osu.Game.Overlays.Profile; -using osu.Game.Overlays.Profile.Header; -using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -19,17 +15,8 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileHeader), - typeof(RankGraph), - typeof(LineGraph), - typeof(TabControlOverlayHeader.OverlayHeaderTabControl), - typeof(CentreHeaderContainer), - typeof(BottomHeaderContainer), - typeof(DetailHeaderContainer), - typeof(ProfileHeaderButton) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 15f9c9a013..7ade24f4de 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -2,16 +2,12 @@ // 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.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Profile; -using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -26,15 +22,6 @@ namespace osu.Game.Tests.Visual.Online [Resolved] private IAPIProvider api { get; set; } - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileHeader), - typeof(RankGraph), - typeof(LineGraph), - typeof(SectionsContainer<>), - typeof(SupporterIcon) - }; - public static readonly User TEST_USER = new User { Username = @"Somebody", diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs index 048a1950fd..1e9d62f379 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,11 +16,6 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfilePreviousUsernames : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PreviousUsernames) - }; - [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs index f022425bf6..0973076c40 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -12,7 +12,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Overlays.Profile.Sections; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections.Recent; namespace osu.Game.Tests.Visual.Online @@ -20,13 +20,8 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfileRecentSection : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RecentSection), - typeof(DrawableRecentActivity), - typeof(PaginatedRecentActivityContainer), - typeof(MedalIcon) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneUserProfileRecentSection() { @@ -131,6 +126,22 @@ namespace osu.Game.Tests.Visual.Online Beatmap = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.Rank, + Rank = 1, + Mode = "vitaru", + Beatmap = dummyBeatmap, + }, + new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.Rank, + Rank = 1, + Mode = "fruits", + Beatmap = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.RankLost, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs new file mode 100644 index 0000000000..5dca218531 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Overlays.Profile.Sections.Ranks; +using osu.Framework.Graphics; +using osu.Game.Scoring; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneUserProfileScores : OsuTestScene + { + public TestSceneUserProfileScores() + { + var firstScore = new ScoreInfo + { + PP = 1047.21, + Rank = ScoreRank.SH, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "JUSTadICE (TV Size)", + Artist = "Oomori Seiko" + }, + Version = "Extreme" + }, + Date = DateTimeOffset.Now, + Mods = new Mod[] + { + new OsuModHidden(), + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Accuracy = 0.9813 + }; + + var secondScore = new ScoreInfo + { + PP = 134.32, + Rank = ScoreRank.A, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Triumph & Regret", + Artist = "typeMARS" + }, + Version = "[4K] Regret" + }, + Date = DateTimeOffset.Now, + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime(), + }, + Accuracy = 0.998546 + }; + + var thirdScore = new ScoreInfo + { + PP = 96.83, + Rank = ScoreRank.S, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Idolize", + Artist = "Creo" + }, + Version = "Insane" + }, + Date = DateTimeOffset.Now, + Accuracy = 0.9726 + }; + + var noPPScore = new ScoreInfo + { + Rank = ScoreRank.B, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "C18H27NO3(extend)", + Artist = "Team Grimoire" + }, + Version = "[4K] Cataclysmic Hypernova" + }, + Date = DateTimeOffset.Now, + Accuracy = 0.55879 + }; + + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new[] + { + 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 DrawableProfileWeightedScore(firstScore, 0.97)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)), + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, DrawableProfileScore score) + { + colourProvider = new OverlayColourProvider(colourScheme); + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + Add(score); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs index 2951f6b63e..c22cff4af6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.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 System; -using System.Collections.Generic; using NUnit.Framework; +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.Overlays; using osu.Game.Overlays.Profile.Sections; -using osu.Game.Overlays.Profile.Sections.Ranks; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -20,7 +19,8 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - public override IReadOnlyList RequiredTypes => new[] { typeof(DrawableProfileScore), typeof(RanksSection) }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneUserRanks() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs index 0f41247571..15cfd3ee54 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs @@ -12,8 +12,8 @@ using osu.Game.Rulesets.Mania; using osu.Game.Users; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Taiko; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Taiko; namespace osu.Game.Tests.Visual.Online { @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online private readonly Bindable user = new Bindable(); private GetUserRequest request; - private readonly DimmedLoadingLayer loading; + private readonly LoadingLayer loading; public TestSceneUserRequest() { @@ -40,10 +40,7 @@ namespace osu.Game.Tests.Visual.Online { User = { BindTarget = user } }, - loading = new DimmedLoadingLayer - { - Alpha = 0 - } + loading = new LoadingLayer() } }); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index 8197cf72de..9bb29541ec 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -1,22 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Overlays.Comments; using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { [TestFixture] public class TestSceneVotePill : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(VotePill) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private VotePill votePill; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs new file mode 100644 index 0000000000..1e87893f39 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +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.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneAccuracyCircle : OsuTestScene + { + [Test] + public void TestLowDRank() + { + var score = createScore(); + score.Accuracy = 0.2; + score.Rank = ScoreRank.D; + + addCircleStep(score); + } + + [Test] + public void TestDRank() + { + var score = createScore(); + score.Accuracy = 0.5; + score.Rank = ScoreRank.D; + + addCircleStep(score); + } + + [Test] + public void TestCRank() + { + var score = createScore(); + score.Accuracy = 0.75; + score.Rank = ScoreRank.C; + + addCircleStep(score); + } + + [Test] + public void TestBRank() + { + var score = createScore(); + score.Accuracy = 0.85; + score.Rank = ScoreRank.B; + + addCircleStep(score); + } + + [Test] + public void TestARank() + { + var score = createScore(); + score.Accuracy = 0.925; + score.Rank = ScoreRank.A; + + addCircleStep(score); + } + + [Test] + public void TestSRank() + { + var score = createScore(); + score.Accuracy = 0.975; + score.Rank = ScoreRank.S; + + addCircleStep(score); + } + + [Test] + public void TestAlmostSSRank() + { + var score = createScore(); + score.Accuracy = 0.9999; + score.Rank = ScoreRank.S; + + addCircleStep(score); + } + + [Test] + public void TestSSRank() + { + var score = createScore(); + score.Accuracy = 1; + score.Rank = ScoreRank.X; + + addCircleStep(score); + } + + private void addCircleStep(ScoreInfo score) => AddStep("add panel", () => + { + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 700), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#555"), Color4Extensions.FromHex("#333")) + } + } + }, + new AccuracyCircle(score) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(230) + } + }; + }); + + private ScoreInfo createScore() => new ScoreInfo + { + User = new User + { + Id = 2, + Username = "peppy", + }, + Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, + Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, + TotalScore = 2845370, + Accuracy = 0.95, + MaxCombo = 999, + Rank = ScoreRank.S, + Date = DateTimeOffset.Now, + Statistics = + { + { HitResult.Miss, 1 }, + { HitResult.Meh, 50 }, + { HitResult.Good, 100 }, + { HitResult.Great, 300 }, + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs new file mode 100644 index 0000000000..76cfe75b59 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Contracted; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneContractedPanelMiddleContent : OsuTestScene + { + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Test] + public void TestShowPanel() + { + AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo))); + } + + private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score) + { + Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score); + } + + private class ContractedPanelMiddleContentContainer : Container + { + [Cached] + private Bindable workingBeatmap { get; set; } + + public ContractedPanelMiddleContentContainer(WorkingBeatmap beatmap, ScoreInfo score) + { + workingBeatmap = new Bindable(beatmap); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Size = new Vector2(ScorePanel.CONTRACTED_WIDTH, 460); + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#353535"), + }, + new ContractedPanelMiddleContent(score), + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs new file mode 100644 index 0000000000..7be44a62de --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneExpandedPanelMiddleContent : OsuTestScene + { + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Test] + public void TestMapWithKnownMapper() + { + var author = new User { Username = "mapper_name" }; + + AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Beatmap = createTestBeatmap(author) + })); + + AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Text == "mapper_name")); + } + + [Test] + public void TestMapWithUnknownMapper() + { + AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Beatmap = createTestBeatmap(null) + })); + + AddAssert("mapped by text not present", () => + this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text, "mapped", "by"))); + } + + private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); + + private BeatmapInfo createTestBeatmap(User author) + { + var beatmap = new TestBeatmap(rulesetStore.GetRuleset(0)).BeatmapInfo; + + beatmap.Metadata.Author = author; + beatmap.Metadata.Title = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap title"; + beatmap.Metadata.Artist = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap artist"; + + return beatmap; + } + + private bool containsAny(string text, params string[] stringsToMatch) => stringsToMatch.Any(text.Contains); + + private class ExpandedPanelMiddleContentContainer : Container + { + public ExpandedPanelMiddleContentContainer(ScoreInfo score) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Size = new Vector2(ScorePanel.EXPANDED_WIDTH, 700); + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#444"), + }, + new ExpandedPanelMiddleContent(score) + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs new file mode 100644 index 0000000000..a32bcbe7f0 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.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.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Ranking.Expanded; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneExpandedPanelTopContent : OsuTestScene + { + public TestSceneExpandedPanelTopContent() + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 200), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#444"), + }, + new ExpandedPanelTopContent(new TestScoreInfo(new OsuRuleset().RulesetInfo).User), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..7ca1fc842f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneHitEventTimingDistributionGraph : OsuTestScene + { + [Test] + public void TestManyDistributedEvents() + { + createTest(CreateDistributedHitEvents()); + } + + [Test] + public void TestZeroTimeOffset() + { + createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList()); + } + + [Test] + public void TestNoEvents() + { + createTest(new List()); + } + + private void createTest(List events) => AddStep("create test", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + new HitEventTimingDistributionGraph(events) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(600, 130) + } + }; + }); + + public static List CreateDistributedHitEvents() + { + var hitEvents = new List(); + + for (int i = 0; i < 50; i++) + { + int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2)); + + for (int j = 0; j < count; j++) + hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null)); + } + + return hitEvents; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs new file mode 100644 index 0000000000..74808bc2f5 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -0,0 +1,322 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + [TestFixture] + public class TestSceneResultsScreen : OsuManualInputManagerTestScene + { + private BeatmapManager beatmaps; + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps) + { + this.beatmaps = beatmaps; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); + if (beatmapInfo != null) + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); + } + + private TestResultsScreen createResultsScreen() => new TestResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + + private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + + [Test] + public void TestResultsWithoutPlayer() + { + TestResultsScreen screen = null; + OsuScreenStack stack; + + AddStep("load results", () => + { + Child = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both + }; + + stack.Push(screen = createResultsScreen()); + }); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay not present", () => screen.RetryOverlay == null); + } + + [Test] + public void TestResultsWithPlayer() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay present", () => screen.RetryOverlay != null); + } + + [Test] + public void TestResultsForUnranked() + { + UnrankedSoloResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createUnrankedSoloResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay present", () => screen.RetryOverlay != null); + } + + [Test] + public void TestShowHideStatisticsViaOutsideClick() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click to right of panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel.ScreenSpaceDrawQuad.TopRight + new Vector2(100, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowHideStatistics() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowStatisticsAndClickOtherPanel() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + ScorePanel expandedPanel = null; + ScorePanel contractedPanel = null; + + AddStep("click expanded panel then contracted panel", () => + { + expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + + contractedPanel = this.ChildrenOfType().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X); + InputManager.MoveMouseTo(contractedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddAssert("contracted panel still contracted", () => contractedPanel.State == PanelState.Contracted); + AddAssert("expanded panel still expanded", () => expandedPanel.State == PanelState.Expanded); + } + + [Test] + public void TestFetchScoresAfterShowingStatistics() + { + DelayedFetchResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo), 3000))); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for fetch", () => screen.FetchCompleted); + AddAssert("expanded panel still on screen", () => this.ChildrenOfType().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0); + } + + private class TestResultsContainer : Container + { + [Cached(typeof(Player))] + private readonly Player player = new TestPlayer(); + + public TestResultsContainer(IScreen screen) + { + RelativeSizeAxes = Axes.Both; + OsuScreenStack stack; + + InternalChild = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }; + + stack.Push(screen); + } + } + + private class TestResultsScreen : ResultsScreen + { + public HotkeyRetryOverlay RetryOverlay; + + public TestResultsScreen(ScoreInfo score) + : base(score) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RetryOverlay = InternalChildren.OfType().SingleOrDefault(); + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + return null; + } + } + + private class DelayedFetchResultsScreen : TestResultsScreen + { + public bool FetchCompleted { get; private set; } + + private readonly double delay; + + public DelayedFetchResultsScreen(ScoreInfo score, double delay) + : base(score) + { + this.delay = delay; + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + Schedule(() => FetchCompleted = true); + }); + + return null; + } + } + + private class UnrankedSoloResultsScreen : SoloResultsScreen + { + public HotkeyRetryOverlay RetryOverlay; + + public UnrankedSoloResultsScreen(ScoreInfo score) + : base(score) + { + Score.Beatmap.OnlineBeatmapID = 0; + Score.Beatmap.Status = BeatmapSetOnlineStatus.Pending; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RetryOverlay = InternalChildren.OfType().SingleOrDefault(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs new file mode 100644 index 0000000000..250fdc5ebd --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneScorePanel : OsuTestScene + { + private ScorePanel panel; + + [Test] + public void TestDRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.5, Rank = ScoreRank.D }; + + addPanelStep(score); + } + + [Test] + public void TestCRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.75, Rank = ScoreRank.C }; + + addPanelStep(score); + } + + [Test] + public void TestBRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.85, Rank = ScoreRank.B }; + + addPanelStep(score); + } + + [Test] + public void TestARank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score); + } + + [Test] + public void TestSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.975, Rank = ScoreRank.S }; + + addPanelStep(score); + } + + [Test] + public void TestAlmostSSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.9999, Rank = ScoreRank.S }; + + addPanelStep(score); + } + + [Test] + public void TestSSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 1, Rank = ScoreRank.X }; + + addPanelStep(score); + } + + [Test] + public void TestAllHitResults() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Statistics = { [HitResult.Perfect] = 350, [HitResult.Ok] = 200 } }; + + addPanelStep(score); + } + + [Test] + public void TestContractedPanel() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score, PanelState.Contracted); + } + + [Test] + public void TestExpandAndContract() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score, PanelState.Contracted); + AddWaitStep("wait for transition", 10); + + AddStep("expand panel", () => panel.State = PanelState.Expanded); + AddWaitStep("wait for transition", 10); + + AddStep("contract panel", () => panel.State = PanelState.Contracted); + AddWaitStep("wait for transition", 10); + } + + private void addPanelStep(ScoreInfo score, PanelState state = PanelState.Expanded) => AddStep("add panel", () => + { + Child = panel = new ScorePanel(score) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = state + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs new file mode 100644 index 0000000000..e65dcb19b1 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -0,0 +1,208 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneScorePanelList : OsuManualInputManagerTestScene + { + private ScorePanelList list; + + [Test] + public void TestEmptyList() + { + createListStep(() => new ScorePanelList()); + } + + [Test] + public void TestEmptyListWithSelectedScore() + { + createListStep(() => new ScorePanelList + { + SelectedScore = { Value = new TestScoreInfo(new OsuRuleset().RulesetInfo) } + }); + } + + [Test] + public void TestAddPanelAfterSelectingScore() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList + { + SelectedScore = { Value = score } + }); + + AddStep("add panel", () => list.AddScore(score)); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddPanelBeforeSelectingScore() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add panel", () => list.AddScore(score)); + + assertScoreState(score, false); + assertFirstPanelCentred(); + + AddStep("select score", () => list.SelectedScore.Value = score); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyNonExpandedPanels() + { + createListStep(() => new ScorePanelList()); + + AddStep("add many scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + }); + + assertFirstPanelCentred(); + } + + [Test] + public void TestAddManyScoresAfterExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add many scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyScoresBeforeExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyPanelsOnBothSidesOfExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add scores after", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestSelectMultipleScores() + { + var firstScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var secondScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add scores and select first", () => + { + list.AddScore(firstScore); + list.AddScore(secondScore); + list.SelectedScore.Value = firstScore; + }); + + assertScoreState(firstScore, true); + assertScoreState(secondScore, false); + + AddStep("select second score", () => + { + InputManager.MoveMouseTo(list.ChildrenOfType().Single(p => p.Score == secondScore)); + InputManager.Click(MouseButton.Left); + }); + + assertScoreState(firstScore, false); + assertScoreState(secondScore, true); + assertExpandedPanelCentred(); + } + + private void createListStep(Func creationFunc) + { + AddStep("create list", () => Child = list = creationFunc().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + })); + + AddUntilStep("wait for load", () => list.IsLoaded); + } + + private void assertExpandedPanelCentred() => AddUntilStep("expanded panel centred", () => + { + var expandedPanel = list.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, list.ScreenSpaceDrawQuad.Centre.X, 1); + }); + + private void assertFirstPanelCentred() + => AddUntilStep("first panel centred", () => Precision.AlmostEquals(list.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre.X, list.ScreenSpaceDrawQuad.Centre.X, 1)); + + private void assertScoreState(ScoreInfo score, bool expanded) + => AddUntilStep($"score expanded = {expanded}", () => (list.ChildrenOfType().Single(p => p.Score == score).State == PanelState.Expanded) == expanded); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs new file mode 100644 index 0000000000..d12f32e470 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Screens.Ranking.Expanded; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneStarRatingDisplay : OsuTestScene + { + public TestSceneStarRatingDisplay() + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 1.23 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 2.34 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 3.45 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 4.56 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 5.67 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 6.78 }), + new StarRatingDisplay(new BeatmapInfo { StarDifficulty = 10.11 }), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs new file mode 100644 index 0000000000..8700fbeb42 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneStatisticsPanel : OsuTestScene + { + [Test] + public void TestScoreWithStatistics() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents() + }; + + loadPanel(score); + } + + [Test] + public void TestScoreWithoutStatistics() + { + loadPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + } + + [Test] + public void TestNullScore() + { + loadPanel(null); + } + + private void loadPanel(ScoreInfo score) => AddStep("load panel", () => + { + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score } + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs new file mode 100644 index 0000000000..0cd0f13b5f --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.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; +using osu.Framework.Platform; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneDirectorySelector : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Add(new DirectorySelector { RelativeSizeAxes = Axes.Both }); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 426ff988c4..3d335995ac 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -1,29 +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.Collections.Generic; +using System.Diagnostics; +using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Game.Overlays; using osu.Game.Overlays.KeyBinding; +using osuTK.Input; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneKeyBindingPanel : OsuTestScene + public class TestSceneKeyBindingPanel : OsuManualInputManagerTestScene { private readonly KeyBindingPanel panel; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(KeyBindingRow), - typeof(GlobalKeyBindingsSection), - typeof(KeyBindingRow), - typeof(KeyBindingsSubsection), - typeof(RulesetBindingsSection), - typeof(VariantBindingsSubsection), - }; - public TestSceneKeyBindingPanel() { Child = panel = new KeyBindingPanel(); @@ -34,5 +27,42 @@ namespace osu.Game.Tests.Visual.Settings base.LoadComplete(); panel.Show(); } + + [Test] + public void TestClickTwiceOnClearButton() + { + KeyBindingRow firstRow = null; + + AddStep("click first row", () => + { + firstRow = panel.ChildrenOfType().First(); + InputManager.MoveMouseTo(firstRow); + InputManager.Click(MouseButton.Left); + }); + + AddStep("schedule button clicks", () => + { + var clearButton = firstRow.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(clearButton); + + int buttonClicks = 0; + ScheduledDelegate clickDelegate = null; + + clickDelegate = Scheduler.AddDelayed(() => + { + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + + if (++buttonClicks == 2) + { + // ReSharper disable once AccessToModifiedClosure + Debug.Assert(clickDelegate != null); + // ReSharper disable once AccessToModifiedClosure + clickDelegate.Cancel(); + } + }, 0, true); + }); + } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs new file mode 100644 index 0000000000..2883e54385 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading; +using osu.Framework.Screens; +using osu.Game.Overlays.Settings.Sections.Maintenance; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneMigrationScreens : ScreenTestScene + { + public TestSceneMigrationScreens() + { + AddStep("Push screen", () => Stack.Push(new TestMigrationSelectScreen())); + } + + private class TestMigrationSelectScreen : MigrationSelectScreen + { + protected override void BeginMigration(DirectoryInfo target) => this.Push(new TestMigrationRunScreen()); + + private class TestMigrationRunScreen : MigrationRunScreen + { + protected override void PerformMigration() + { + Thread.Sleep(3000); + } + + public TestMigrationRunScreen() + : base(null) + { + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 668fdf2c20..115d2fec7d 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.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 System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Overlays; -using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Visual.Settings { @@ -17,12 +14,6 @@ namespace osu.Game.Tests.Visual.Settings private readonly SettingsPanel settings; private readonly DialogOverlay dialogOverlay; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SettingsFooter), - typeof(SettingsOverlay), - }; - public TestSceneSettingsPanel() { settings = new SettingsOverlay diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs new file mode 100644 index 0000000000..3d3517ada4 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select.Details; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [System.ComponentModel.Description("Advanced beatmap statistics display")] + public class TestSceneAdvancedStats : OsuTestScene + { + private TestAdvancedStats advancedStats; + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [SetUp] + public void Setup() => Schedule(() => Child = advancedStats = new TestAdvancedStats + { + Width = 500 + }); + + private BeatmapInfo exampleBeatmapInfo => new BeatmapInfo + { + RulesetID = 0, + Ruleset = rulesets.AvailableRulesets.First(), + BaseDifficulty = new BeatmapDifficulty + { + CircleSize = 7.2f, + DrainRate = 3, + OverallDifficulty = 5.7f, + ApproachRate = 3.5f + }, + StarDifficulty = 4.5f + }; + + [Test] + public void TestNoMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("no mods selected", () => SelectedMods.Value = Array.Empty()); + + AddAssert("first bar text is Circle Size", () => advancedStats.ChildrenOfType().First().Text == "Circle Size"); + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("HP drain bar is white", () => barIsWhite(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is white", () => barIsWhite(advancedStats.ApproachRate)); + } + + [Test] + public void TestManiaFirstBarText() + { + AddStep("set beatmap", () => advancedStats.Beatmap = new BeatmapInfo + { + Ruleset = rulesets.GetRuleset(3), + BaseDifficulty = new BeatmapDifficulty + { + CircleSize = 5, + DrainRate = 4.3f, + OverallDifficulty = 4.5f, + ApproachRate = 3.1f + }, + StarDifficulty = 8 + }); + + AddAssert("first bar text is Key Count", () => advancedStats.ChildrenOfType().First().Text == "Key Count"); + } + + [Test] + public void TestEasyMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select EZ mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + }); + + AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue)); + AddAssert("HP drain bar is blue", () => barIsBlue(advancedStats.HpDrain)); + AddAssert("accuracy bar is blue", () => barIsBlue(advancedStats.Accuracy)); + AddAssert("approach rate bar is blue", () => barIsBlue(advancedStats.ApproachRate)); + } + + [Test] + public void TestHardRockMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select HR mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + }); + + AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue)); + AddAssert("HP drain bar is red", () => barIsRed(advancedStats.HpDrain)); + AddAssert("accuracy bar is red", () => barIsRed(advancedStats.Accuracy)); + AddAssert("approach rate bar is red", () => barIsRed(advancedStats.ApproachRate)); + } + + [Test] + public void TestUnchangedDifficultyAdjustMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select unchanged Difficulty Adjust mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); + difficultyAdjustMod.ReadFromDifficulty(advancedStats.Beatmap.BaseDifficulty); + SelectedMods.Value = new[] { difficultyAdjustMod }; + }); + + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("HP drain bar is white", () => barIsWhite(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is white", () => barIsWhite(advancedStats.ApproachRate)); + } + + [Test] + public void TestChangedDifficultyAdjustMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select changed Difficulty Adjust mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); + var originalDifficulty = advancedStats.Beatmap.BaseDifficulty; + var adjustedDifficulty = new BeatmapDifficulty + { + CircleSize = originalDifficulty.CircleSize, + DrainRate = originalDifficulty.DrainRate - 0.5f, + OverallDifficulty = originalDifficulty.OverallDifficulty, + ApproachRate = originalDifficulty.ApproachRate + 2.2f, + }; + difficultyAdjustMod.ReadFromDifficulty(adjustedDifficulty); + SelectedMods.Value = new[] { difficultyAdjustMod }; + }); + + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("drain rate bar is blue", () => barIsBlue(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is red", () => barIsRed(advancedStats.ApproachRate)); + } + + private bool barIsWhite(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == Color4.White; + 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 + { + public new StatisticRow FirstValue => base.FirstValue; + public new StatisticRow HpDrain => base.HpDrain; + public new StatisticRow Accuracy => base.Accuracy; + public new StatisticRow ApproachRate => base.ApproachRate; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 132b104afb..a3ea4619cc 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -17,30 +17,16 @@ using osu.Game.Rulesets; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] - public class TestSceneBeatmapCarousel : OsuTestScene + public class TestSceneBeatmapCarousel : OsuManualInputManagerTestScene { private TestBeatmapCarousel carousel; private RulesetStore rulesets; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CarouselItem), - typeof(CarouselGroup), - typeof(CarouselGroupEagerSelect), - typeof(CarouselBeatmap), - typeof(CarouselBeatmapSet), - - typeof(DrawableCarouselItem), - typeof(CarouselItemState), - - typeof(DrawableCarouselBeatmap), - typeof(DrawableCarouselBeatmapSet), - }; - private readonly Stack selectedSets = new Stack(); private readonly HashSet eagerSelectedIDs = new HashSet(); @@ -54,6 +40,72 @@ namespace osu.Game.Tests.Visual.SongSelect this.rulesets = rulesets; } + [Test] + public void TestKeyRepeat() + { + loadBeatmaps(); + advanceSelection(false); + + AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); + + BeatmapInfo selection = null; + + checkSelectionIterating(true); + + AddStep("press up arrow", () => InputManager.PressKey(Key.Up)); + + checkSelectionIterating(true); + + AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down)); + + checkSelectionIterating(true); + + AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); + + checkSelectionIterating(false); + + void checkSelectionIterating(bool isIterating) + { + for (int i = 0; i < 3; i++) + { + AddStep("store selection", () => selection = carousel.SelectedBeatmap); + if (isIterating) + AddUntilStep("selection changed", () => carousel.SelectedBeatmap != selection); + else + AddUntilStep("selection not changed", () => carousel.SelectedBeatmap == selection); + } + } + } + + [Test] + public void TestRecommendedSelection() + { + loadBeatmaps(carouselAdjust: carousel => carousel.GetRecommendedBeatmap = beatmaps => beatmaps.LastOrDefault()); + + AddStep("select last", () => carousel.SelectBeatmap(carousel.BeatmapSets.Last().Beatmaps.Last())); + + // check recommended was selected + advanceSelection(direction: 1, diff: false); + waitForSelection(1, 3); + + // change away from recommended + advanceSelection(direction: -1, diff: true); + waitForSelection(1, 2); + + // next set, check recommended + advanceSelection(direction: 1, diff: false); + waitForSelection(2, 3); + + // next set, check recommended + advanceSelection(direction: 1, diff: false); + waitForSelection(3, 3); + + // go back to first set and ensure user selection was retained + advanceSelection(direction: -1, diff: false); + advanceSelection(direction: -1, diff: false); + waitForSelection(1, 2); + } + /// /// Test keyboard traversal /// @@ -62,7 +114,7 @@ namespace osu.Game.Tests.Visual.SongSelect { loadBeatmaps(); - advanceSelection(direction: 1, diff: false); + AddStep("select first", () => carousel.SelectBeatmap(carousel.BeatmapSets.First().Beatmaps.First())); waitForSelection(1, 1); advanceSelection(direction: 1, diff: true); @@ -83,6 +135,82 @@ namespace osu.Game.Tests.Visual.SongSelect waitForSelection(set_count, 3); } + [TestCase(true)] + [TestCase(false)] + public void TestTraversalBeyondVisible(bool forwards) + { + var sets = new List(); + + const int total_set_count = 200; + + for (int i = 0; i < total_set_count; i++) + sets.Add(createTestBeatmapSet(i + 1)); + + loadBeatmaps(sets); + + for (int i = 1; i < total_set_count; i += i) + selectNextAndAssert(i); + + void selectNextAndAssert(int amount) + { + setSelected(forwards ? 1 : total_set_count, 1); + + AddStep($"{(forwards ? "Next" : "Previous")} beatmap {amount} times", () => + { + for (int i = 0; i < amount; i++) + { + carousel.SelectNext(forwards ? 1 : -1); + } + }); + + waitForSelection(forwards ? amount + 1 : total_set_count - amount); + } + } + + [Test] + public void TestTraversalBeyondVisibleDifficulties() + { + var sets = new List(); + + const int total_set_count = 20; + + for (int i = 0; i < total_set_count; i++) + sets.Add(createTestBeatmapSet(i + 1)); + + loadBeatmaps(sets); + + // Selects next set once, difficulty index doesn't change + selectNextAndAssert(3, true, 2, 1); + + // Selects next set 16 times (50 \ 3 == 16), difficulty index changes twice (50 % 3 == 2) + selectNextAndAssert(50, true, 17, 3); + + // Travels around the carousel thrice (200 \ 60 == 3) + // continues to select 20 times (200 \ 60 == 20) + // selects next set 6 times (20 \ 3 == 6) + // difficulty index changes twice (20 % 3 == 2) + selectNextAndAssert(200, true, 7, 3); + + // All same but in reverse + selectNextAndAssert(3, false, 19, 3); + selectNextAndAssert(50, false, 4, 1); + selectNextAndAssert(200, false, 14, 1); + + void selectNextAndAssert(int amount, bool forwards, int expectedSet, int expectedDiff) + { + // Select very first or very last difficulty + setSelected(forwards ? 1 : 20, forwards ? 1 : 3); + + AddStep($"{(forwards ? "Next" : "Previous")} difficulty {amount} times", () => + { + for (int i = 0; i < amount; i++) + carousel.SelectNext(forwards ? 1 : -1, false); + }); + + waitForSelection(expectedSet, expectedDiff); + } + } + /// /// Test filtering /// @@ -227,6 +355,34 @@ namespace osu.Game.Tests.Visual.SongSelect waitForSelection(set_count); } + [Test] + public void TestSelectionEnteringFromEmptyRuleset() + { + var sets = new List(); + + AddStep("Create beatmaps for taiko only", () => + { + sets.Clear(); + + var rulesetBeatmapSet = createTestBeatmapSet(1); + var taikoRuleset = rulesets.AvailableRulesets.ElementAt(1); + rulesetBeatmapSet.Beatmaps.ForEach(b => + { + b.Ruleset = taikoRuleset; + b.RulesetID = 1; + }); + + sets.Add(rulesetBeatmapSet); + }); + + loadBeatmaps(sets, () => new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }); + + AddStep("Set non-empty mode filter", () => + carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) }, false)); + + AddAssert("Something is selected", () => carousel.SelectedBeatmap != null); + } + /// /// Test sorting /// @@ -399,27 +555,32 @@ 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 selected", () => carousel.SelectedBeatmap.Equals(testMixed.Beatmaps[0])); + AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmap.RulesetID == 0); AddStep("remove mixed set", () => { carousel.RemoveBeatmapSet(testMixed); testMixed = null; }); - var testSingle = createTestBeatmapSet(set_count + 2); - testSingle.Beatmaps.ForEach(b => + BeatmapSetInfo testSingle = null; + AddStep("add single ruleset beatmapset", () => { - b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); - b.RulesetID = b.Ruleset.ID ?? 1; + testSingle = createTestBeatmapSet(set_count + 2); + testSingle.Beatmaps.ForEach(b => + { + b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); + b.RulesetID = b.Ruleset.ID ?? 1; + }); + + carousel.UpdateBeatmapSet(testSingle); }); - AddStep("add single ruleset beatmapset", () => carousel.UpdateBeatmapSet(testSingle)); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testSingle.Beatmaps[0], false)); checkNoSelection(); AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle)); } [Test] - public void TestCarouselRootIsRandom() + public void TestCarouselRemembersSelection() { List manySets = new List(); @@ -429,17 +590,126 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(manySets); advanceSelection(direction: 1, diff: false); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - AddAssert("Selection was random", () => eagerSelectedIDs.Count > 1); + + for (int i = 0; i < 5; i++) + { + AddStep("Toggle non-matching filter", () => + { + carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + }); + + AddStep("Restore no filter", () => + { + carousel.Filter(new FilterCriteria(), false); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + }); + } + + // always returns to same selection as long as it's available. + AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } - private void loadBeatmaps(List beatmapSets = null) + [Test] + public void TestRandomFallbackOnNonMatchingPrevious() { - createCarousel(); + List manySets = new List(); + + AddStep("populate maps", () => + { + for (int i = 0; i < 10; i++) + { + var set = createTestBeatmapSet(i); + + foreach (var b in set.Beatmaps) + { + // all taiko except for first + int ruleset = i > 0 ? 1 : 0; + + b.Ruleset = rulesets.GetRuleset(ruleset); + b.RulesetID = ruleset; + } + + manySets.Add(set); + } + }); + + 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.SelectedBeatmap == null); + + AddStep("Restore different ruleset filter", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + }); + + AddAssert("selection changed", () => carousel.SelectedBeatmap != manySets.First().Beatmaps.First()); + } + + AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); + } + + [Test] + public void TestFilteringByUserStarDifficulty() + { + BeatmapSetInfo set = null; + + loadBeatmaps(new List()); + + AddStep("add mixed difficulty set", () => + { + set = createTestBeatmapSet(1); + set.Beatmaps.Clear(); + + for (int i = 1; i <= 15; i++) + { + set.Beatmaps.Add(new BeatmapInfo + { + Version = $"Stars: {i}", + StarDifficulty = i, + }); + } + + carousel.UpdateBeatmapSet(set); + }); + + AddStep("select added set", () => carousel.SelectBeatmap(set.Beatmaps[0], false)); + + AddStep("filter [5..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 11); + + AddStep("filter to [0..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Max = 7 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 7); + + AddStep("filter to [5..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5, Max = 7 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 3); + + AddStep("filter [2..2]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 2, Max = 2 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 1); + + AddStep("filter to [0..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 0 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 15); + } + + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null) + { + createCarousel(carouselAdjust); if (beatmapSets == null) { @@ -450,9 +720,9 @@ namespace osu.Game.Tests.Visual.SongSelect } bool changed = false; - AddStep($"Load {beatmapSets.Count} Beatmaps", () => + AddStep($"Load {(beatmapSets.Count > 0 ? beatmapSets.Count.ToString() : "some")} beatmaps", () => { - carousel.Filter(new FilterCriteria()); + carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria()); carousel.BeatmapSetsChanged = () => changed = true; carousel.BeatmapSets = beatmapSets; }); @@ -460,17 +730,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Wait for load", () => changed); } - private void createCarousel(Container target = null) + private void createCarousel(Action carouselAdjust = null, Container target = null) { AddStep("Create carousel", () => { selectedSets.Clear(); eagerSelectedIDs.Clear(); - (target ?? this).Child = carousel = new TestBeatmapCarousel + carousel = new TestBeatmapCarousel { RelativeSizeAxes = Axes.Both, }; + + carouselAdjust?.Invoke(carousel); + + (target ?? this).Child = carousel; }); } @@ -546,16 +820,6 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection is visible", selectedBeatmapVisible); } - private void checkNonmatchingFilter() - { - AddStep("Toggle non-matching filter", () => - { - carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false); - carousel.Filter(new FilterCriteria(), false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); - }); - } - private BeatmapSetInfo createTestBeatmapSet(int id) { return new BeatmapSetInfo @@ -650,6 +914,8 @@ namespace osu.Game.Tests.Visual.SongSelect public new List Items => base.Items; public bool PendingFilterTask => PendingFilter != null; + + protected override IEnumerable GetLoadableBeatmaps() => Enumerable.Empty(); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index 6aa5a76490..acf037198f 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -3,14 +3,8 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.SongSelect @@ -180,27 +174,5 @@ namespace osu.Game.Tests.Visual.SongSelect OnlineBeatmapID = 162, }); } - - [Resolved] - private RulesetStore rulesets { get; set; } - - [Resolved] - private OsuColour colours { get; set; } - - [Test] - public void TestModAdjustments() - { - TestAllMetrics(); - - Ruleset ruleset = rulesets.AvailableRulesets.First().CreateInstance(); - - AddStep("with EZ mod", () => SelectedMods.Value = new[] { ruleset.GetAllMods().First(m => m is ModEasy) }); - - AddAssert("first bar coloured blue", () => details.ChildrenOfType().Skip(1).First().AccentColour == colours.BlueDark); - - AddStep("with HR mod", () => SelectedMods.Value = new[] { ruleset.GetAllMods().First(m => m is ModHardRock) }); - - AddAssert("first bar coloured red", () => details.ChildrenOfType().Skip(1).First().AccentColour == colours.Red); - } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index f49d7a14a6..e02ebf3be1 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; @@ -194,7 +195,7 @@ namespace osu.Game.Tests.Visual.SongSelect public new BufferedWedgeInfo Info => base.Info; } - private class TestHitObject : HitObject, IHasPosition + private class TestHitObject : ConvertHitObject, IHasPosition { public float X { get; } = 0; public float Y { get; } = 0; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 57e297bcd5..48b718c04d 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -2,11 +2,12 @@ // 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.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; @@ -17,19 +18,18 @@ namespace osu.Game.Tests.Visual.SongSelect { public class TestSceneBeatmapLeaderboard : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Placeholder), - typeof(MessagePlaceholder), - typeof(RetrievalFailurePlaceholder), - typeof(UserTopScoreContainer), - typeof(Leaderboard), - }; - private readonly FailableLeaderboard leaderboard; + [Cached] + private readonly DialogOverlay dialogOverlay; + public TestSceneBeatmapLeaderboard() { + Add(dialogOverlay = new DialogOverlay + { + Depth = -1 + }); + Add(leaderboard = new FailableLeaderboard { Origin = Anchor.Centre, @@ -48,6 +48,33 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep(@"None selected", () => leaderboard.SetRetrievalState(PlaceholderState.NoneSelected)); foreach (BeatmapSetOnlineStatus status in Enum.GetValues(typeof(BeatmapSetOnlineStatus))) AddStep($"{status} beatmap", () => showBeatmapWithStatus(status)); + AddStep("null personal best position", showPersonalBestWithNullPosition); + } + + private void showPersonalBestWithNullPosition() + { + leaderboard.TopScore = new APILegacyUserTopScoreInfo + { + Position = null, + Score = new APILegacyScoreInfo + { + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Mods = new[] { new OsuModHidden().Acronym, new OsuModHardRock().Acronym, }, + User = new User + { + Id = 6602580, + Username = @"waaiiru", + Country = new Country + { + FullName = @"Spain", + FlagName = @"ES", + }, + }, + } + }; } private void showPersonalBest() diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 00fa95bedc..f7d66ca5cf 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -11,20 +11,26 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; 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; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -32,31 +38,9 @@ namespace osu.Game.Tests.Visual.SongSelect public class TestScenePlaySongSelect : ScreenTestScene { private BeatmapManager manager; - private RulesetStore rulesets; - private MusicController music; - private WorkingBeatmap defaultBeatmap; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Screens.Select.SongSelect), - typeof(BeatmapCarousel), - - typeof(CarouselItem), - typeof(CarouselGroup), - typeof(CarouselGroupEagerSelect), - typeof(CarouselBeatmap), - typeof(CarouselBeatmapSet), - - typeof(DrawableCarouselItem), - typeof(CarouselItemState), - - typeof(DrawableCarouselBeatmap), - typeof(DrawableCarouselBeatmapSet), - }; - private TestSongSelect songSelect; [BackgroundDependencyLoader] @@ -70,19 +54,23 @@ namespace osu.Game.Tests.Visual.SongSelect // required to get bindables attached Add(music); - Beatmap.SetDefault(); - Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } private OsuConfigManager config; - [SetUp] - public virtual void SetUp() => Schedule(() => + public override void SetUpSteps() { - Ruleset.Value = new OsuRuleset().RulesetInfo; - manager?.Delete(manager.GetAllUsableBeatmapSets()); - }); + base.SetUpSteps(); + + AddStep("delete all beatmaps", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + manager?.Delete(manager.GetAllUsableBeatmapSets()); + + Beatmap.SetDefault(); + }); + } [Test] public void TestSingleFilterOnEnter() @@ -95,6 +83,115 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("filter count is 1", () => songSelect.FilterCount == 1); } + [Test] + public void TestChangeBeatmapBeforeEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.PressKey(Key.Down); + InputManager.ReleaseKey(Key.Down); + InputManager.PressKey(Key.Enter); + InputManager.ReleaseKey(Key.Enter); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection changed", () => selected != Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapAfterEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.PressKey(Key.Enter); + InputManager.ReleaseKey(Key.Enter); + InputManager.PressKey(Key.Down); + InputManager.ReleaseKey(Key.Down); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapViaMouseBeforeEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); + + 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).Beatmap != songSelect.Carousel.SelectedBeatmap)); + + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + + InputManager.PressKey(Key.Enter); + InputManager.ReleaseKey(Key.Enter); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection changed", () => selected != Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapViaMouseAfterEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + 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).Beatmap != songSelect.Carousel.SelectedBeatmap)); + + InputManager.PressButton(MouseButton.Left); + + InputManager.PressKey(Key.Enter); + InputManager.ReleaseKey(Key.Enter); + + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); + } + [Test] public void TestNoFilterOnSimpleResume() { @@ -166,7 +263,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("import multi-ruleset map", () => { var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); - manager.Import(createTestBeatmapSet(0, usableRulesets)).Wait(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); }); } else @@ -207,15 +304,13 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); - var sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); - - AddStep(@"Sort by Artist", delegate { sortMode.Value = SortMode.Artist; }); - AddStep(@"Sort by Title", delegate { sortMode.Value = SortMode.Title; }); - AddStep(@"Sort by Author", delegate { sortMode.Value = SortMode.Author; }); - AddStep(@"Sort by DateAdded", delegate { sortMode.Value = SortMode.DateAdded; }); - AddStep(@"Sort by BPM", delegate { sortMode.Value = SortMode.BPM; }); - AddStep(@"Sort by Length", delegate { sortMode.Value = SortMode.Length; }); - AddStep(@"Sort by Difficulty", delegate { sortMode.Value = SortMode.Difficulty; }); + AddStep(@"Sort by Artist", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + AddStep(@"Sort by Title", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Title)); + AddStep(@"Sort by Author", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Author)); + AddStep(@"Sort by DateAdded", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); + AddStep(@"Sort by BPM", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.BPM)); + AddStep(@"Sort by Length", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Length)); + AddStep(@"Sort by Difficulty", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); } [Test] @@ -242,6 +337,68 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); } + [Test] + public void TestPresentNewRulesetNewBeatmap() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + + // this is an important check, to make sure updateComponentFromBeatmap() was actually run + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + } + + [Test] + public void TestPresentNewBeatmapNewRuleset() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + + AddUntilStep("has correct ruleset", () => Ruleset.Value.ID == 0); + + // this is an important check, to make sure updateComponentFromBeatmap() was actually run + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + } + [Test] public void TestRulesetChangeResetsMods() { @@ -310,6 +467,121 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("start not requested", () => !startRequested); } + [TestCase(false)] + [TestCase(true)] + public void TestExternalBeatmapChangeWhileFiltered(bool differentRuleset) + { + createSongSelect(); + addManyTestMaps(); + + changeRuleset(0); + + // used for filter check below + AddStep("allow convert display", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + + AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); + + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + + BeatmapInfo target = null; + + int targetRuleset = differentRuleset ? 1 : 0; + + AddStep("select beatmap externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Where(b => b.Beatmaps.Any(bi => bi.RulesetID == targetRuleset)) + .ElementAt(5).Beatmaps.First(bi => bi.RulesetID == targetRuleset); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddAssert("selected only shows expected ruleset (plus converts)", () => + { + 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.Beatmap.Ruleset.ID == targetRuleset || i.Item.Beatmap.Ruleset.ID == 0); + }); + + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + + AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); + + AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmap.OnlineBeatmapID == target.OnlineBeatmapID); + } + + [Test] + public void TestExternalBeatmapChangeWhileFilteredThenRefilter() + { + createSongSelect(); + addManyTestMaps(); + + changeRuleset(0); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + + AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); + + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + + BeatmapInfo target = null; + + AddStep("select beatmap externally", () => + { + target = manager.GetAllUsableBeatmapSets().Where(b => b.Beatmaps.Any(bi => bi.RulesetID == 1)) + .ElementAt(5).Beatmaps.First(); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); + + AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); + AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmap == null); + } + + [Test] + public void TestAutoplayViaCtrlEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddStep("press ctrl+enter", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.Enter); + + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.Enter); + }); + + AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); + + AddAssert("autoplay enabled", () => songSelect.Mods.Value.FirstOrDefault() is ModAutoplay); + + AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + + AddAssert("mod disabled", () => songSelect.Mods.Value.Count == 0); + } + [Test] public void TestHideSetSelectsCorrectBeatmap() { @@ -322,11 +594,244 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmap.ID == previousID); } + [Test] + public void TestDifficultyIconSelecting() + { + addRulesetImportStep(0); + createSongSelect(); + + DrawableCarouselBeatmapSet set = null; + AddStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().First(); + }); + + DrawableCarouselBeatmapSet.FilterableDifficultyIcon difficultyIcon = null; + AddStep("Find an icon", () => + { + difficultyIcon = set.ChildrenOfType() + .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + }); + + AddStep("Click on a difficulty", () => + { + InputManager.MoveMouseTo(difficultyIcon); + + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); + + double? maxBPM = null; + AddStep("Filter some difficulties", () => songSelect.Carousel.Filter(new FilterCriteria + { + BPM = new FilterCriteria.OptionalRange + { + Min = maxBPM = songSelect.Carousel.SelectedBeatmapSet.MaxBPM, + IsLowerInclusive = true + } + })); + + BeatmapInfo filteredBeatmap = null; + DrawableCarouselBeatmapSet.FilterableDifficultyIcon filteredIcon = null; + + AddStep("Get filtered icon", () => + { + filteredBeatmap = songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First(b => b.BPM < maxBPM); + int filteredBeatmapIndex = getBeatmapIndex(filteredBeatmap.BeatmapSet, filteredBeatmap); + filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); + }); + + AddStep("Click on a filtered difficulty", () => + { + InputManager.MoveMouseTo(filteredIcon); + + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmap == filteredBeatmap); + } + + [Test] + public void TestDifficultyIconSelectingForDifferentRuleset() + { + changeRuleset(0); + + createSongSelect(); + + AddStep("import multi-ruleset map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); + }); + + DrawableCarouselBeatmapSet set = null; + AddUntilStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + return set != null; + }); + + DrawableCarouselBeatmapSet.FilterableDifficultyIcon difficultyIcon = null; + AddStep("Find an icon for different ruleset", () => + { + difficultyIcon = set.ChildrenOfType() + .First(icon => icon.Item.Beatmap.Ruleset.ID == 3); + }); + + AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); + + int previousSetID = 0; + + AddStep("record set ID", () => previousSetID = Beatmap.Value.BeatmapSetInfo.ID); + + AddStep("Click on a difficulty", () => + { + InputManager.MoveMouseTo(difficultyIcon); + + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); + + AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmap.BeatmapSet.ID == previousSetID); + AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.ID == 3); + } + + [Test] + public void TestGroupedDifficultyIconSelecting() + { + changeRuleset(0); + + createSongSelect(); + + BeatmapSetInfo imported = null; + + AddStep("import huge difficulty count map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + imported = manager.Import(createTestBeatmapSet(usableRulesets, 50)).Result; + }); + + AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); + + DrawableCarouselBeatmapSet set = null; + AddUntilStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + return set != null; + }); + + DrawableCarouselBeatmapSet.FilterableGroupedDifficultyIcon groupIcon = null; + AddStep("Find group icon for different ruleset", () => + { + groupIcon = set.ChildrenOfType() + .First(icon => icon.Items.First().Beatmap.Ruleset.ID == 3); + }); + + AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); + + AddStep("Click on group", () => + { + InputManager.MoveMouseTo(groupIcon); + + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); + + AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo == groupIcon.Items.First().Beatmap); + } + + [Test] + public void TestChangeRulesetWhilePresentingScore() + { + BeatmapInfo getPresentBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 0); + BeatmapInfo getSwitchBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 1); + + changeRuleset(0); + + createSongSelect(); + + addRulesetImportStep(0); + addRulesetImportStep(1); + + AddStep("present score", () => + { + // this ruleset change should be overridden by the present. + Ruleset.Value = getSwitchBeatmap().Ruleset; + + songSelect.PresentScore(new ScoreInfo + { + User = new User { Username = "woo" }, + Beatmap = getPresentBeatmap(), + Ruleset = getPresentBeatmap().Ruleset + }); + }); + + AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + + AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.Equals(getPresentBeatmap())); + AddAssert("check ruleset is correct for score", () => Ruleset.Value.ID == 0); + } + + [Test] + public void TestChangeBeatmapWhilePresentingScore() + { + BeatmapInfo getPresentBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 0); + BeatmapInfo getSwitchBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 1); + + changeRuleset(0); + + addRulesetImportStep(0); + addRulesetImportStep(1); + + createSongSelect(); + + AddStep("present score", () => + { + // this beatmap change should be overridden by the present. + Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); + + songSelect.PresentScore(new ScoreInfo + { + User = new User { Username = "woo" }, + Beatmap = getPresentBeatmap(), + Ruleset = getPresentBeatmap().Ruleset + }); + }); + + AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + + AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.Equals(getPresentBeatmap())); + AddAssert("check ruleset is correct for score", () => Ruleset.Value.ID == 0); + } + + private void waitForInitialSelection() + { + AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); + AddUntilStep("wait for difficulty panels visible", () => songSelect.Carousel.ChildrenOfType().Any()); + } + + private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.FindIndex(b => b == info); + + private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmap); + + private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, DrawableCarouselBeatmapSet.FilterableDifficultyIcon icon) + { + return set.ChildrenOfType().ToList().FindIndex(i => i == icon); + } + private void addRulesetImportStep(int id) => AddStep($"import test map for ruleset {id}", () => importForRuleset(id)); - private void importForRuleset(int id) => manager.Import(createTestBeatmapSet(getImportId(), rulesets.AvailableRulesets.Where(r => r.ID == id).ToArray())).Wait(); + private void importForRuleset(int id) => manager.Import(createTestBeatmapSet(rulesets.AvailableRulesets.Where(r => r.ID == id).ToArray())).Wait(); private static int importId; + private int getImportId() => ++importId; private void checkMusicPlaying(bool playing) => @@ -340,6 +845,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); } private void addManyTestMaps() @@ -349,20 +855,22 @@ namespace osu.Game.Tests.Visual.SongSelect var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); for (int i = 0; i < 100; i += 10) - manager.Import(createTestBeatmapSet(i, usableRulesets)).Wait(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); }); } - private BeatmapSetInfo createTestBeatmapSet(int setId, RulesetInfo[] rulesets) + private BeatmapSetInfo createTestBeatmapSet(RulesetInfo[] rulesets, int countPerRuleset = 6) { int j = 0; RulesetInfo getRuleset() => rulesets[j++ % rulesets.Length]; + int setId = getImportId(); + var beatmaps = new List(); - for (int i = 0; i < 6; i++) + for (int i = 0; i < countPerRuleset; i++) { - int beatmapId = setId * 10 + i; + int beatmapId = setId * 1000 + i; int length = RNG.Next(30000, 200000); double bpm = RNG.NextSingle(80, 200); @@ -410,10 +918,14 @@ namespace osu.Game.Tests.Visual.SongSelect public new Bindable Ruleset => base.Ruleset; + public new FilterControl FilterControl => base.FilterControl; + public WorkingBeatmap CurrentBeatmap => Beatmap.Value; public WorkingBeatmap CurrentBeatmapDetailsBeatmap => BeatmapDetails.Beatmap; public new BeatmapCarousel Carousel => base.Carousel; + public new void PresentScore(ScoreInfo score) => base.PresentScore(score); + protected override bool OnStart() { StartRequested?.Invoke(); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs index e34e1844ce..0598324110 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osuTK.Graphics; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; using osu.Game.Scoring; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Select.Leaderboards; @@ -15,10 +17,18 @@ namespace osu.Game.Tests.Visual.SongSelect { public class TestSceneUserTopScoreContainer : OsuTestScene { + [Cached] + private readonly DialogOverlay dialogOverlay; + public TestSceneUserTopScoreContainer() { UserTopScoreContainer topScoreContainer; + Add(dialogOverlay = new DialogOverlay + { + Depth = -1 + }); + Add(new Container { Origin = Anchor.BottomCentre, diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index 492494ada3..22ae5257e7 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -34,11 +34,6 @@ namespace osu.Game.Tests.Visual [TestFixture] public class TestSceneOsuGame : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuLogo), - }; - private IReadOnlyList requiredGameDependencies => new[] { typeof(OsuGame), @@ -47,8 +42,8 @@ namespace osu.Game.Tests.Visual typeof(IdleTracker), typeof(OnScreenDisplay), typeof(NotificationOverlay), - typeof(DirectOverlay), - typeof(SocialOverlay), + typeof(BeatmapListingOverlay), + typeof(DashboardOverlay), typeof(ChannelManager), typeof(ChatOverlay), typeof(SettingsOverlay), diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs index b7d7053dcd..2440911c11 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,11 +12,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneBackButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TwoLayerButton) - }; - public TestSceneBackButton() { BackButton button; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index b0b673d6a4..dd5ceec739 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -26,11 +26,6 @@ namespace osu.Game.Tests.Visual.UserInterface { private readonly NowPlayingOverlay np; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(BeatSyncedContainer) - }; - [Cached] private MusicController musicController = new MusicController(); @@ -182,7 +177,7 @@ namespace osu.Game.Tests.Visual.UserInterface timeSinceLastBeat.Value = TimeSinceLastBeat; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs new file mode 100644 index 0000000000..a4698a9a32 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapListingSearchControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly BeatmapListingSearchControl control; + + public TestSceneBeatmapListingSearchControl() + { + OsuSpriteText query; + OsuSpriteText ruleset; + OsuSpriteText category; + OsuSpriteText genre; + OsuSpriteText language; + + Add(control = new BeatmapListingSearchControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + query = new OsuSpriteText(), + ruleset = new OsuSpriteText(), + category = new OsuSpriteText(), + genre = new OsuSpriteText(), + language = new OsuSpriteText(), + } + }); + + control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", 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); + } + + [Test] + public void TestCovers() + { + AddStep("Set beatmap", () => control.BeatmapSet = beatmap_set); + AddStep("Set beatmap (no cover)", () => control.BeatmapSet = no_cover_beatmap_set); + AddStep("Set null beatmap", () => control.BeatmapSet = null); + } + + private static readonly BeatmapSetInfo beatmap_set = new BeatmapSetInfo + { + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1094296/covers/cover@2x.jpg?1581416305" + } + } + }; + + private static readonly BeatmapSetInfo no_cover_beatmap_set = new BeatmapSetInfo + { + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = string.Empty + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs new file mode 100644 index 0000000000..5364f0bef5 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapListingSortTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public TestSceneBeatmapListingSortTabControl() + { + BeatmapListingSortTabControl control; + OsuSpriteText current; + OsuSpriteText direction; + + Add(control = new BeatmapListingSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + current = new OsuSpriteText(), + direction = new OsuSpriteText() + } + }); + + control.SortDirection.BindValueChanged(sortDirection => direction.Text = $"Sort direction: {sortDirection.NewValue}", true); + control.Current.BindValueChanged(criteria => current.Text = $"Criteria: {criteria.NewValue}", true); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs new file mode 100644 index 0000000000..37b7b64615 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapSearchFilter : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly ReverseChildIDFillFlowContainer resizableContainer; + + public TestSceneBeatmapSearchFilter() + { + Add(resizableContainer = new ReverseChildIDFillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new BeatmapSearchRulesetFilterRow(), + new BeatmapSearchFilterRow("Categories"), + new BeatmapSearchFilterRow("Header Name") + } + }); + } + + [Test] + public void TestResize() + { + AddStep("Resize to 0.3", () => resizableContainer.ResizeWidthTo(0.3f, 1000)); + AddStep("Resize to 1", () => resizableContainer.ResizeWidthTo(1, 1000)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs index 19eebc89b6..3967b62c95 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -12,11 +14,11 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneBreadcrumbControl : OsuTestScene { - private readonly BreadcrumbControl breadcrumbs; + private readonly TestBreadcrumbControl breadcrumbs; public TestSceneBreadcrumbControl() { - Add(breadcrumbs = new BreadcrumbControl + Add(breadcrumbs = new TestBreadcrumbControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -25,8 +27,13 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddStep(@"first", () => breadcrumbs.Current.Value = BreadcrumbTab.Click); + assertVisible(1); + AddStep(@"second", () => breadcrumbs.Current.Value = BreadcrumbTab.The); + assertVisible(2); + AddStep(@"third", () => breadcrumbs.Current.Value = BreadcrumbTab.Circles); + assertVisible(3); } [BackgroundDependencyLoader] @@ -35,11 +42,27 @@ namespace osu.Game.Tests.Visual.UserInterface breadcrumbs.StripColour = colours.Blue; } + private void assertVisible(int count) => AddAssert($"first {count} item(s) visible", () => + { + for (int i = 0; i < count; i++) + { + if (breadcrumbs.GetDrawable((BreadcrumbTab)i).State != Visibility.Visible) + return false; + } + + return true; + }); + private enum BreadcrumbTab { Click, The, Circles, } + + private class TestBreadcrumbControl : BreadcrumbControl + { + public BreadcrumbTabItem GetDrawable(BreadcrumbTab tab) => (BreadcrumbTabItem)TabContainer.First(t => t.Value == tab); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index f0e1c38525..1bb5cadc6a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -17,13 +16,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneButtonSystem : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ButtonSystem), - typeof(ButtonArea), - typeof(Button) - }; - private OsuLogo logo; private ButtonSystem buttons; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs new file mode 100644 index 0000000000..d0a2ca83e3 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneCommentEditor : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private TestCommentEditor commentEditor; + private TestCancellableCommentEditor cancellableCommentEditor; + + [SetUp] + public void SetUp() => Schedule(() => + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 800, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + commentEditor = new TestCommentEditor(), + cancellableCommentEditor = new TestCancellableCommentEditor() + } + })); + + [Test] + public void TestCommitViaKeyboard() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + AddStep("enter text", () => commentEditor.Current.Value = "text"); + + AddStep("press Enter", () => press(Key.Enter)); + + AddAssert("text committed", () => commentEditor.CommittedText == "text"); + AddAssert("button is loading", () => commentEditor.IsLoading); + } + + [Test] + public void TestCommitViaKeyboardWhenEmpty() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + + AddStep("press Enter", () => press(Key.Enter)); + + AddAssert("no text committed", () => commentEditor.CommittedText == null); + AddAssert("button is not loading", () => !commentEditor.IsLoading); + } + + [Test] + public void TestCommitViaButton() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + AddStep("enter text", () => commentEditor.Current.Value = "some other text"); + + AddStep("click submit", () => + { + InputManager.MoveMouseTo(commentEditor.ButtonsContainer); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("text committed", () => commentEditor.CommittedText == "some other text"); + AddAssert("button is loading", () => commentEditor.IsLoading); + } + + [Test] + public void TestCancelAction() + { + AddStep("click cancel button", () => + { + InputManager.MoveMouseTo(cancellableCommentEditor.ButtonsContainer); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("cancel action fired", () => cancellableCommentEditor.Cancelled); + } + + private void press(Key key) + { + InputManager.PressKey(key); + InputManager.ReleaseKey(key); + } + + private class TestCommentEditor : CommentEditor + { + public new Bindable Current => base.Current; + public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; + + public string CommittedText { get; private set; } + + public TestCommentEditor() + { + OnCommit = onCommit; + } + + private void onCommit(string value) + { + CommittedText = value; + Scheduler.AddDelayed(() => IsLoading = 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"; + } + + private class TestCancellableCommentEditor : CancellableCommentEditor + { + public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; + protected override string FooterText => @"Wow, another one. Sicc"; + + public bool Cancelled { get; private set; } + + public TestCancellableCommentEditor() + { + OnCancel = () => Cancelled = true; + } + + protected override string CommitButtonText => @"Save"; + protected override string TextBoxPlaceholder => @"Multiline textboxes soon"; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs new file mode 100644 index 0000000000..c2ac5179c9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Overlays.Comments.Buttons; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osu.Framework.Graphics.Containers; +using osuTK; +using NUnit.Framework; +using System.Linq; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneCommentRepliesButton : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly TestButton button; + + public TestSceneCommentRepliesButton() + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + button = new TestButton(), + new LoadRepliesButton + { + Action = () => { } + }, + new ShowRepliesButton(1), + new ShowRepliesButton(2) + } + }; + } + + [Test] + public void TestArrowDirection() + { + AddStep("Set upwards", () => button.SetIconDirection(true)); + AddAssert("Icon facing upwards", () => button.Icon.Scale.Y == -1); + AddStep("Set downwards", () => button.SetIconDirection(false)); + AddAssert("Icon facing downwards", () => button.Icon.Scale.Y == 1); + } + + private class TestButton : CommentRepliesButton + { + public SpriteIcon Icon => this.ChildrenOfType().First(); + + public TestButton() + { + Text = "sample text"; + } + + public new void SetIconDirection(bool upwards) => base.SetIconDirection(upwards); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs index e95f4c09c6..5b74852259 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osuTK; @@ -17,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneCursors : ManualInputManagerTestScene + public class TestSceneCursors : OsuManualInputManagerTestScene { private readonly MenuCursorContainer menuCursorContainer; private readonly CustomCursorBox[] cursorBoxes = new CustomCursorBox[6]; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs new file mode 100644 index 0000000000..eb4750a597 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene + { + private readonly ContextMenuContainer contextMenuContainer; + private readonly BeatmapLeaderboard leaderboard; + + private RulesetStore rulesetStore; + private BeatmapManager beatmapManager; + private ScoreManager scoreManager; + + private readonly List scores = new List(); + private BeatmapInfo beatmap; + + [Cached] + private readonly DialogOverlay dialogOverlay; + + public TestSceneDeleteLocalScore() + { + Children = new Drawable[] + { + contextMenuContainer = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = leaderboard = new BeatmapLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = BeatmapLeaderboardScope.Local, + Beatmap = new BeatmapInfo + { + ID = 1, + Metadata = new BeatmapMetadata + { + ID = 1, + Title = "TestSong", + Artist = "TestArtist", + Author = new User + { + Username = "TestAuthor" + }, + }, + Version = "Insane" + }, + } + }, + dialogOverlay = new DialogOverlay() + }; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.Cache(rulesetStore = new RulesetStore(ContextFactory)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, Audio, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); + + beatmap = beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Result.Beatmaps[0]; + + for (int i = 0; i < 50; i++) + { + var score = new ScoreInfo + { + OnlineScoreID = i, + Beatmap = beatmap, + BeatmapInfoID = beatmap.ID, + Accuracy = RNG.NextDouble(), + TotalScore = RNG.Next(1, 1000000), + MaxCombo = RNG.Next(1, 1000), + Rank = ScoreRank.XH, + User = new User { Username = "TestUser" }, + }; + + scores.Add(scoreManager.Import(score).Result); + } + + scores.Sort(Comparer.Create((s1, s2) => s2.TotalScore.CompareTo(s1.TotalScore))); + + return dependencies; + } + + [SetUp] + public void Setup() => Schedule(() => + { + // Due to soft deletions, we can re-use deleted scores between test runs + scoreManager.Undelete(scoreManager.QueryScores(s => s.DeletePending).ToList()); + + leaderboard.Scores = null; + leaderboard.FinishTransforms(true); // After setting scores, we may be waiting for transforms to expire drawables + + leaderboard.Beatmap = beatmap; + leaderboard.RefreshScores(); // Required in the case that the beatmap hasn't changed + }); + + [SetUpSteps] + public void SetupSteps() + { + // Ensure the leaderboard has finished async-loading drawables + AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType().Any()); + + // Ensure the leaderboard items have finished showing up + AddStep("finish transforms", () => leaderboard.FinishTransforms(true)); + } + + [Test] + public void TestDeleteViaRightClick() + { + AddStep("open menu for top score", () => + { + InputManager.MoveMouseTo(leaderboard.ChildrenOfType().First()); + InputManager.Click(MouseButton.Right); + }); + + // Ensure the context menu has finished showing + AddStep("finish transforms", () => contextMenuContainer.FinishTransforms(true)); + + AddStep("click delete option", () => + { + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToLowerInvariant() == "delete")); + InputManager.Click(MouseButton.Left); + }); + + // Ensure the dialog has finished showing + AddStep("finish transforms", () => dialogOverlay.FinishTransforms(true)); + + AddStep("click delete button", () => + { + InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + } + + [Test] + public void TestDeleteViaDatabase() + { + AddStep("delete top score", () => scoreManager.Delete(scores[0])); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 6eb621ca3b..1e3b1c2ffd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -14,11 +14,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneFooterButtonMods : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(FooterButtonMods) - }; - private readonly TestFooterButtonMods footerButtonMods; public TestSceneFooterButtonMods() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs new file mode 100644 index 0000000000..9fa5c83dba --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFriendsOnlineStatusControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private FriendOnlineStreamControl control; + + [SetUp] + public void SetUp() => Schedule(() => Child = control = new FriendOnlineStreamControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + [Test] + public void Populate() + { + AddStep("Populate", () => control.Populate(new List + { + new User + { + IsOnline = true + }, + new User + { + IsOnline = false + }, + new User + { + IsOnline = false + } + })); + + AddAssert("3 users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.All)?.Count == 3); + AddAssert("1 online user", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Online)?.Count == 1); + AddAssert("2 offline users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Offline)?.Count == 2); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs index feef1dae6b..cea91d422e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs @@ -1,11 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Screens.Menu; @@ -15,12 +12,6 @@ namespace osu.Game.Tests.Visual.UserInterface { protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ExitConfirmOverlay), - typeof(HoldToConfirmContainer), - }; - public TestSceneHoldToConfirmOverlay() { bool fired = false; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHueAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHueAnimation.cs new file mode 100644 index 0000000000..9c5888d072 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHueAnimation.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneHueAnimation : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + HueAnimation anim2; + + Add(anim2 = new HueAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-highlight"), + Colour = Colour4.White, + }); + + HueAnimation anim; + + Add(anim = new HueAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }); + + AddSliderStep("Progress", 0f, 1f, 0f, newValue => + { + anim2.AnimationProgress = newValue; + anim.AnimationProgress = newValue; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs index 6ca4d9fa4c..903f1242b4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,12 +10,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneLabelledSwitchButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(LabelledSwitchButton), - typeof(SwitchButton) - }; - [TestCase(false)] [TestCase(true)] public void TestSwitchButton(bool hasDescription) => createSwitchButton(hasDescription); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs index 8208b55952..c11ba0aa59 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,11 +12,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneLabelledTextBox : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(LabelledTextBox), - }; - [TestCase(false)] [TestCase(true)] public void TestTextBox(bool hasDescription) => createTextBox(hasDescription); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs new file mode 100644 index 0000000000..1be191fc29 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLoadingLayer : OsuTestScene + { + private Drawable dimContent; + private LoadingLayer overlay; + + private Container content; + + [SetUp] + public void SetUp() => Schedule(() => + { + Children = new[] + { + content = new Container + { + Size = new Vector2(300), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + new Box + { + Colour = Color4.SlateGray, + RelativeSizeAxes = Axes.Both, + }, + dimContent = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.9f), + 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 = () => { } }, + } + }, + overlay = new LoadingLayer(dimContent), + } + }, + }; + }); + + [Test] + public void TestShowHide() + { + AddAssert("not visible", () => !overlay.IsPresent); + + AddStep("show", () => overlay.Show()); + + AddUntilStep("wait for content dim", () => dimContent.Colour != Color4.White); + + AddStep("hide", () => overlay.Hide()); + + AddUntilStep("wait for content restore", () => dimContent.Colour == Color4.White); + } + + [Test] + public void TestContentRestoreOnDispose() + { + AddAssert("not visible", () => !overlay.IsPresent); + + AddStep("show", () => overlay.Show()); + + AddUntilStep("wait for content dim", () => dimContent.Colour != Color4.White); + + AddStep("expire", () => overlay.Expire()); + + AddUntilStep("wait for content restore", () => dimContent.Colour == Color4.White); + } + + [Test] + public void TestLargeArea() + { + AddStep("show", () => + { + content.RelativeSizeAxes = Axes.Both; + content.Size = new Vector2(1); + + overlay.Show(); + }); + + AddStep("hide", () => overlay.Hide()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs similarity index 79% rename from osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs index b0233d35f9..47f5bdfe17 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs @@ -8,12 +8,12 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLoadingAnimation : OsuGridTestScene + public class TestSceneLoadingSpinner : OsuGridTestScene { - public TestSceneLoadingAnimation() + public TestSceneLoadingSpinner() : base(2, 2) { - LoadingAnimation loading; + LoadingSpinner loading; Cell(0).AddRange(new Drawable[] { @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.Black, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); loading.Show(); @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.White, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner(true) }); loading.Show(); @@ -46,14 +46,14 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.Gray, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); loading.Show(); Cell(3).AddRange(new Drawable[] { - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); Scheduler.AddDelayed(() => loading.ToggleVisibility(), 200, true); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 54876dbbda..010e4330d7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -2,17 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -20,17 +17,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneLogoTrackingContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PlayerLoader), - typeof(Player), - typeof(LogoTrackingContainer), - typeof(ButtonSystem), - typeof(ButtonSystemState), - typeof(Menu), - typeof(MainMenu) - }; - private OsuLogo logo; private TestLogoTrackingContainer trackingContainer; private Container transferContainer; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs new file mode 100644 index 0000000000..443cf59003 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModButton : OsuTestScene + { + public TestSceneModButton() + { + Children = new Drawable[] + { + new ModButton(new MultiMod(new TestMod1(), new TestMod2(), new TestMod3(), new TestMod4())) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }; + } + + private class TestMod1 : TestMod + { + public override string Name => "Test mod 1"; + + public override string Acronym => "M1"; + } + + private class TestMod2 : TestMod + { + public override string Name => "Test mod 2"; + + public override string Acronym => "M2"; + + public override IconUsage? Icon => FontAwesome.Solid.Exclamation; + } + + private class TestMod3 : TestMod + { + public override string Name => "Test mod 3"; + + public override string Acronym => "M3"; + + public override IconUsage? Icon => FontAwesome.Solid.ArrowRight; + } + + private class TestMod4 : TestMod + { + public override string Name => "Test mod 4"; + + public override string Acronym => "M4"; + } + + private abstract class TestMod : Mod, IApplicableMod + { + public override double ScoreMultiplier => 1.0; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs new file mode 100644 index 0000000000..8168faa106 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModDisplay : OsuTestScene + { + [TestCase(ExpansionMode.ExpandOnHover)] + [TestCase(ExpansionMode.AlwaysExpanded)] + [TestCase(ExpansionMode.AlwaysContracted)] + public void TestMode(ExpansionMode mode) + { + AddStep("create mod display", () => + { + Child = new ModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ExpansionMode = mode, + Current = + { + Value = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModDifficultyAdjust(), + new OsuModEasy(), + } + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 12ee4ceb2e..6f083f4ab6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -12,14 +12,12 @@ using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; -using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets; 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.UI; using osu.Game.Screens.Play.HUD; using osuTK; using osuTK.Graphics; @@ -29,20 +27,6 @@ namespace osu.Game.Tests.Visual.UserInterface [Description("mod select and icon display")] public class TestSceneModSelectOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ModDisplay), - typeof(ModSection), - typeof(ModIcon), - typeof(ModButton), - typeof(ModButtonEmpty), - typeof(DifficultyReductionSection), - typeof(DifficultyIncreaseSection), - typeof(AutomationSection), - typeof(ConversionSection), - typeof(FunSection), - }; - private RulesetStore rulesets; private ModDisplay modDisplay; private TestModSelectOverlay modSelect; @@ -60,9 +44,9 @@ namespace osu.Game.Tests.Visual.UserInterface { modSelect = new TestModSelectOverlay { - RelativeSizeAxes = Axes.X, Origin = Anchor.BottomCentre, Anchor = Anchor.BottomCentre, + SelectedMods = { BindTarget = SelectedMods } }, modDisplay = new ModDisplay @@ -70,7 +54,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, - Position = new Vector2(0, 25), + Position = new Vector2(-5, 25), Current = { BindTarget = modSelect.SelectedMods } } }; @@ -91,13 +75,14 @@ namespace osu.Game.Tests.Visual.UserInterface var easierMods = osu.GetModsFor(ModType.DifficultyReduction); var harderMods = osu.GetModsFor(ModType.DifficultyIncrease); + var conversionMods = osu.GetModsFor(ModType.Conversion); var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); var hiddenMod = harderMods.FirstOrDefault(m => m is OsuModHidden); var doubleTimeMod = harderMods.OfType().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime)); - var spunOutMod = easierMods.FirstOrDefault(m => m is OsuModSpunOut); + var targetMod = conversionMods.FirstOrDefault(m => m is OsuModTarget); var easy = easierMods.FirstOrDefault(m => m is OsuModEasy); var hardRock = harderMods.FirstOrDefault(m => m is OsuModHardRock); @@ -109,7 +94,7 @@ namespace osu.Game.Tests.Visual.UserInterface testMultiplierTextColour(noFailMod, () => modSelect.LowMultiplierColour); testMultiplierTextColour(hiddenMod, () => modSelect.HighMultiplierColour); - testUnimplementedMod(spunOutMod); + testUnimplementedMod(targetMod); } [Test] @@ -117,7 +102,11 @@ namespace osu.Game.Tests.Visual.UserInterface { changeRuleset(3); - testRankedText(new ManiaRuleset().GetModsFor(ModType.Conversion).First(m => m is ManiaModRandom)); + var mania = new ManiaRuleset(); + + testModsWithSameBaseType( + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModFadeIn)), + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModHidden))); } [Test] @@ -216,13 +205,16 @@ namespace osu.Game.Tests.Visual.UserInterface checkLabelColor(() => Color4.White); } - private void testRankedText(Mod mod) + private void testModsWithSameBaseType(Mod modA, Mod modB) { - AddUntilStep("check for ranked", () => modSelect.UnrankedLabel.Alpha == 0); - selectNext(mod); - AddUntilStep("check for unranked", () => modSelect.UnrankedLabel.Alpha != 0); - selectPrevious(mod); - AddUntilStep("check for ranked", () => modSelect.UnrankedLabel.Alpha == 0); + selectNext(modA); + checkSelected(modA); + selectNext(modB); + checkSelected(modB); + + // Backwards + selectPrevious(modA); + checkSelected(modA); } private void selectNext(Mod mod) => AddStep($"left click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(1)); @@ -271,7 +263,6 @@ namespace osu.Game.Tests.Visual.UserInterface } public new OsuSpriteText MultiplierLabel => base.MultiplierLabel; - public new OsuSpriteText UnrankedLabel => base.UnrankedLabel; public new TriangleButton DeselectAllButton => base.DeselectAllButton; public new Color4 LowMultiplierColour => base.LowMultiplierColour; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index 8dcb7dcbf8..7ff463361a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -25,12 +25,21 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly Mod testCustomisableMod = new TestModCustomisable1(); + private readonly Mod testCustomisableAutoOpenMod = new TestModCustomisable2(); + + [SetUp] + public void SetUp() => Schedule(() => + { + SelectedMods.Value = Array.Empty(); + Ruleset.Value = new TestRulesetInfo(); + }); + [Test] public void TestButtonShowsOnCustomisableMod() { createModSelect(); + openModSelect(); - AddStep("open", () => modSelect.Show()); AddAssert("button disabled", () => !modSelect.CustomiseButton.Enabled.Value); AddUntilStep("wait for button load", () => modSelect.ButtonsLoaded); AddStep("select mod", () => modSelect.SelectMod(testCustomisableMod)); @@ -49,25 +58,42 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mods still active", () => SelectedMods.Value.Count == 1); - AddStep("open", () => modSelect.Show()); + openModSelect(); AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); } + [Test] + public void TestCustomisationMenuVisibility() + { + createModSelect(); + openModSelect(); + + AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.Alpha == 0); + AddStep("select mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.Alpha == 1); + AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.Alpha == 0); + } + private void createModSelect() { AddStep("create mod select", () => { - Ruleset.Value = new TestRulesetInfo(); - Child = modSelect = new TestModSelectOverlay { - RelativeSizeAxes = Axes.X, Origin = Anchor.BottomCentre, Anchor = Anchor.BottomCentre, + SelectedMods = { BindTarget = SelectedMods } }; }); } + private void openModSelect() + { + AddStep("open", () => modSelect.Show()); + AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); + } + private class TestModSelectOverlay : ModSelectOverlay { public new Container ModSettingsContainer => base.ModSettingsContainer; @@ -128,6 +154,8 @@ namespace osu.Game.Tests.Visual.UserInterface public override string Name => "Customisable Mod 2"; public override string Acronym => "CM2"; + + public override bool RequiresConfiguration => true; } private abstract class TestModCustomisable : Mod, IApplicableMod diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index 35e5f9719c..43ba23e6c6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.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 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.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -18,16 +17,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneNotificationOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(NotificationSection), - typeof(SimpleNotification), - typeof(ProgressNotification), - typeof(ProgressCompletionNotification), - typeof(IHasCompletionTarget), - typeof(Notification) - }; - private NotificationOverlay notificationOverlay; private readonly List progressingNotifications = new List(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs index e3daa9c279..532744a0fc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.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.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Graphics; -using osu.Framework.Timing; +using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.UserInterface { @@ -15,22 +20,75 @@ namespace osu.Game.Tests.Visual.UserInterface [Cached] private MusicController musicController = new MusicController(); - public TestSceneNowPlayingOverlay() - { - Clock = new FramedClock(); + private WorkingBeatmap currentBeatmap; - var np = new NowPlayingOverlay + private NowPlayingOverlay nowPlayingOverlay; + + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + + nowPlayingOverlay = new NowPlayingOverlay { Origin = Anchor.Centre, Anchor = Anchor.Centre }; Add(musicController); - Add(np); + Add(nowPlayingOverlay); + } - AddStep(@"show", () => np.Show()); + [Test] + public void TestShowHideDisable() + { + AddStep(@"show", () => nowPlayingOverlay.Show()); AddToggleStep(@"toggle beatmap lock", state => Beatmap.Disabled = state); - AddStep(@"show", () => np.Hide()); + AddStep(@"hide", () => nowPlayingOverlay.Hide()); + } + + private BeatmapManager manager { get; set; } + + private int importId; + + [Test] + public void TestPrevTrackBehavior() + { + // ensure we have at least two beatmaps available. + AddRepeatStep("import beatmap", () => manager.Import(new BeatmapSetInfo + { + Beatmaps = new List + { + new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + } + }, + Metadata = new BeatmapMetadata + { + Artist = $"a test map {importId++}", + Title = "title", + } + }).Wait(), 5); + + AddStep(@"Next track", () => musicController.NextTrack()); + AddStep("Store track", () => currentBeatmap = Beatmap.Value); + + AddStep(@"Seek track to 6 second", () => musicController.SeekTo(6000)); + AddUntilStep(@"Wait for current time to update", () => currentBeatmap.Track.CurrentTime > 5000); + + AddStep(@"Set previous", () => musicController.PreviousTrack()); + + AddAssert(@"Check beatmap didn't change", () => currentBeatmap == Beatmap.Value); + AddUntilStep("Wait for current time to update", () => currentBeatmap.Track.CurrentTime < 5000); + + AddStep(@"Set previous", () => musicController.PreviousTrack()); + AddAssert(@"Check beatmap did change", () => currentBeatmap != Beatmap.Value); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs deleted file mode 100644 index f73450db60..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public class TestSceneNumberBox : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuNumberBox), - }; - - private OsuNumberBox numberBox; - - [BackgroundDependencyLoader] - private void load() - { - Child = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 250 }, - Child = numberBox = new OsuNumberBox - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - PlaceholderText = "Insert numbers here" - } - }; - - clearInput(); - AddStep("enter numbers", () => numberBox.Text = "987654321"); - expectedValue("987654321"); - clearInput(); - AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); - expectedValue("123"); - clearInput(); - } - - private void clearInput() => AddStep("clear input", () => numberBox.Text = null); - - private void expectedValue(string value) => AddAssert("expect number", () => numberBox.Text == value); - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs index dbef7d1686..396bec51b6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneOsuHoverContainer : ManualInputManagerTestScene + public class TestSceneOsuHoverContainer : OsuManualInputManagerTestScene { private OsuHoverTestContainer hoverContainer; private Box colourContainer; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs index 061039b297..c5374d50ab 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.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.Reflection; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; @@ -45,7 +46,12 @@ namespace osu.Game.Tests.Visual.UserInterface }); foreach (var p in typeof(OsuIcon).GetProperties(BindingFlags.Public | BindingFlags.Static)) - flow.Add(new Icon($"{nameof(OsuIcon)}.{p.Name}", (IconUsage)p.GetValue(null))); + { + var propValue = p.GetValue(null); + Debug.Assert(propValue != null); + + flow.Add(new Icon($"{nameof(OsuIcon)}.{p.Name}", (IconUsage)propValue)); + } AddStep("toggle shadows", () => flow.Children.ForEach(i => i.SpriteIcon.Shadow = !i.SpriteIcon.Shadow)); AddStep("change icons", () => flow.Children.ForEach(i => i.SpriteIcon.Icon = new IconUsage((char)(i.SpriteIcon.Icon.Icon + 1)))); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs new file mode 100644 index 0000000000..387deea76c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuMenu : OsuManualInputManagerTestScene + { + private OsuMenu menu; + private bool actionPerformed; + + [SetUp] + public void Setup() => Schedule(() => + { + actionPerformed = false; + + Child = menu = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new OsuMenuItem("standard", MenuItemType.Standard, performAction), + new OsuMenuItem("highlighted", MenuItemType.Highlighted, performAction), + new OsuMenuItem("destructive", MenuItemType.Destructive, performAction), + } + }; + }); + + [Test] + public void TestClickEnabledMenuItem() + { + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action performed", () => actionPerformed); + } + + [Test] + public void TestDisableMenuItemsAndClick() + { + AddStep("disable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = true; + }); + + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action not performed", () => !actionPerformed); + } + + [Test] + public void TestEnableMenuItemsAndClick() + { + AddStep("disable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = true; + }); + + AddStep("enable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = false; + }); + + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action performed", () => actionPerformed); + } + + private void performAction() => actionPerformed = true; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs new file mode 100644 index 0000000000..756928d3ec --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuTextBox : OsuTestScene + { + private readonly OsuNumberBox numberBox; + + public TestSceneOsuTextBox() + { + Child = new Container + { + Masking = true, + CornerRadius = 10f, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(15f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.DarkSlateGray, + Alpha = 0.75f, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(50f), + Spacing = new Vector2(0f, 50f), + Children = new[] + { + new OsuTextBox + { + Width = 500f, + PlaceholderText = "Normal textbox", + }, + new OsuPasswordTextBox + { + Width = 500f, + PlaceholderText = "Password textbox", + }, + numberBox = new OsuNumberBox + { + Width = 500f, + PlaceholderText = "Number textbox" + } + } + } + } + }; + } + + [Test] + public void TestNumberBox() + { + clearTextbox(numberBox); + AddStep("enter numbers", () => numberBox.Text = "987654321"); + expectedValue(numberBox, "987654321"); + + clearTextbox(numberBox); + AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); + expectedValue(numberBox, "123"); + + clearTextbox(numberBox); + } + + private void clearTextbox(OsuTextBox textBox) => AddStep("clear textbox", () => textBox.Text = null); + private void expectedValue(OsuTextBox textBox, string value) => AddAssert("expected textbox value", () => textBox.Text == value); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs new file mode 100644 index 0000000000..60af5b37ef --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs @@ -0,0 +1,148 @@ +// Copyright (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.Game.Overlays; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayHeader : OsuTestScene + { + private readonly FillFlowContainer flow; + + public TestSceneOverlayHeader() + { + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical + } + } + }); + + addHeader("Orange OverlayHeader (no background)", new TestNoBackgroundHeader(), OverlayColourScheme.Orange); + addHeader("Blue OverlayHeader", new TestNoControlHeader(), OverlayColourScheme.Blue); + addHeader("Green TabControlOverlayHeader (string) with ruleset selector", new TestStringTabControlHeader(), OverlayColourScheme.Green); + addHeader("Pink TabControlOverlayHeader (enum)", new TestEnumTabControlHeader(), OverlayColourScheme.Pink); + addHeader("Red BreadcrumbControlOverlayHeader (no background)", new TestBreadcrumbControlHeader(), OverlayColourScheme.Red); + } + + private void addHeader(string name, OverlayHeader header, OverlayColourScheme colourScheme) + { + flow.Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(20), + Text = name, + }, + new ColourProvidedContainer(colourScheme, header) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, OverlayHeader header) + { + colourProvider = new OverlayColourProvider(colourScheme); + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + Add(header); + } + } + + private class TestNoBackgroundHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private class TestNoControlHeader : OverlayHeader + { + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/changelog"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private class TestStringTabControlHeader : TabControlOverlayHeader + { + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/news"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + + protected override Drawable CreateTitleContent() => new OverlayRulesetSelector(); + + public TestStringTabControlHeader() + { + TabControl.AddItem("tab1"); + TabControl.AddItem("tab2"); + } + } + + private class TestEnumTabControlHeader : TabControlOverlayHeader + { + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/rankings"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private enum TestEnum + { + Some, + Cool, + Tabs + } + + private class TestBreadcrumbControlHeader : BreadcrumbControlOverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + + public TestBreadcrumbControlHeader() + { + TabControl.AddItem("tab1"); + TabControl.AddItem("tab2"); + TabControl.Current.Value = "tab2"; + } + } + + private class TestTitle : OverlayTitle + { + public TestTitle() + { + Title = "title"; + IconTexture = "Icons/changelog"; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs new file mode 100644 index 0000000000..db414d23a0 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.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.Containers; +using osu.Game.Overlays; +using osu.Framework.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayHeaderBackground : OsuTestScene + { + public TestSceneOverlayHeaderBackground() + { + Add(new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new[] + { + new OverlayHeaderBackground(@"Headers/changelog"), + new OverlayHeaderBackground(@"Headers/news"), + new OverlayHeaderBackground(@"Headers/rankings"), + new OverlayHeaderBackground(@"Headers/search"), + } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs new file mode 100644 index 0000000000..f4fa41a3b7 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayRulesetSelector : OsuTestScene + { + private readonly OverlayRulesetSelector selector; + private readonly Bindable ruleset = new Bindable(); + + public TestSceneOverlayRulesetSelector() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new[] + { + new ColourProvidedContainer(OverlayColourScheme.Green, selector = new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Blue, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Orange, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Pink, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Purple, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Red, new OverlayRulesetSelector { Current = ruleset }), + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, OverlayRulesetSelector rulesetSelector) + { + colourProvider = new OverlayColourProvider(colourScheme); + AutoSizeAxes = Axes.Both; + Add(rulesetSelector); + } + } + + [Test] + public void TestSelection() + { + AddStep("Select osu!", () => ruleset.Value = new OsuRuleset().RulesetInfo); + AddAssert("Check osu! selected", () => selector.Current.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("Select mania", () => ruleset.Value = new ManiaRuleset().RulesetInfo); + AddAssert("Check mania selected", () => selector.Current.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("Select taiko", () => ruleset.Value = new TaikoRuleset().RulesetInfo); + AddAssert("Check taiko selected", () => selector.Current.Value.Equals(new TaikoRuleset().RulesetInfo)); + + AddStep("Select catch", () => ruleset.Value = new CatchRuleset().RulesetInfo); + AddAssert("Check catch selected", () => selector.Current.Value.Equals(new CatchRuleset().RulesetInfo)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs new file mode 100644 index 0000000000..7fa730e02b --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.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 osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using NUnit.Framework; +using osu.Framework.Utils; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayScrollContainer : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private TestScrollContainer scroll; + + private int invocationCount; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = scroll = new TestScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Height = 3000, + RelativeSizeAxes = Axes.X, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray + } + } + }; + + invocationCount = 0; + + scroll.Button.Action += () => invocationCount++; + }); + + [Test] + public void TestButtonVisibility() + { + AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden); + + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); + + AddStep("scroll to start", () => scroll.ScrollToStart(false)); + AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden); + + 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); + } + + [Test] + public void TestButtonAction() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddStep("invoke action", () => scroll.Button.Action.Invoke()); + + AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + } + + [Test] + public void TestClick() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(scroll.Button); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + } + + [Test] + public void TestMultipleClicks() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddAssert("invocation count is 0", () => invocationCount == 0); + + AddStep("hover button", () => InputManager.MoveMouseTo(scroll.Button)); + AddRepeatStep("click button", () => InputManager.Click(MouseButton.Left), 3); + + AddAssert("invocation count is 1", () => invocationCount == 1); + } + + private class TestScrollContainer : OverlayScrollContainer + { + public new ScrollToTopButton Button => base.Button; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs new file mode 100644 index 0000000000..a470244f53 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Music; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestScenePlaylistOverlay : OsuTestScene + { + private readonly BindableList beatmapSets = new BindableList(); + + [SetUp] + public void Setup() => Schedule(() => + { + PlaylistOverlay overlay; + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 500), + Child = overlay = new PlaylistOverlay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + State = { Value = Visibility.Visible } + } + }; + + beatmapSets.Clear(); + + for (int i = 0; i < 100; i++) + { + beatmapSets.Add(new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + // Create random metadata, then we can check if sorting works based on these + Artist = "Some Artist " + RNG.Next(0, 9), + Title = $"Some Song {i + 1}", + AuthorString = "Some Guy " + RNG.Next(0, 9), + }, + DateAdded = DateTimeOffset.UtcNow, + }); + } + + overlay.BeatmapSets.BindTo(beatmapSets); + }); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 7207506ccd..8e53c7c402 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Dialog; namespace osu.Game.Tests.Visual.UserInterface @@ -14,14 +11,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestScenePopupDialog : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PopupDialogOkButton), - typeof(PopupDialogCancelButton), - typeof(PopupDialogButton), - typeof(DialogButton), - }; - public TestScenePopupDialog() { AddStep("new popup", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs new file mode 100644 index 0000000000..24bc0dbc97 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRankingsSortTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsSortTabControl() + { + Child = new RankingsSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs index 0cb8683d72..77a7d819a9 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -26,7 +25,9 @@ namespace osu.Game.Tests.Visual.UserInterface OsuSpriteText titleText; IScreen startScreen = new TestScreenOne(); - screenStack = new OsuScreenStack(startScreen) { RelativeSizeAxes = Axes.Both }; + + screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + screenStack.Push(startScreen); Children = new Drawable[] { @@ -62,7 +63,7 @@ namespace osu.Game.Tests.Visual.UserInterface waitForCurrent(); pushNext(); waitForCurrent(); - AddAssert(@"only 2 items", () => breadcrumbs.Items.Count() == 2); + AddAssert(@"only 2 items", () => breadcrumbs.Items.Count == 2); AddStep(@"exit current", () => screenStack.CurrentScreen.Exit()); AddAssert(@"current screen is first", () => startScreen == screenStack.CurrentScreen); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs index 2ada5b927b..29aeb6a4b2 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,16 +11,8 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneStatefulMenuItem : ManualInputManagerTestScene + public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuMenu), - typeof(StatefulMenuItem), - typeof(TernaryStateMenuItem), - typeof(DrawableStatefulMenuItem), - }; - [Test] public void TestTernaryMenuItem() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs index 4a104b4a41..37fab75aee 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs @@ -9,7 +9,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneSwitchButton : ManualInputManagerTestScene + public class TestSceneSwitchButton : OsuManualInputManagerTestScene { private SwitchButton switchButton; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs index 2abda56a28..9fb8e747f3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; @@ -10,13 +8,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneToggleMenuItem : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuMenu), - typeof(ToggleMenuItem), - typeof(DrawableStatefulMenuItem) - }; - public TestSceneToggleMenuItem() { Add(new OsuMenu(Direction.Vertical, true) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs index 0da256855a..cdfbb14cba 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs @@ -3,25 +3,17 @@ using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Toolbar; -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets; namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneToolbarRulesetSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ToolbarRulesetSelector), - typeof(ToolbarRulesetTabButton), - }; - [Resolved] private RulesetStore rulesets { get; set; } @@ -44,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Select random", () => { - selector.Current.Value = selector.Items.ElementAt(RNG.Next(selector.Items.Count())); + selector.Current.Value = selector.Items.ElementAt(RNG.Next(selector.Items.Count)); }); AddStep("Toggle disabled state", () => selector.Current.Disabled = !selector.Current.Disabled); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs new file mode 100644 index 0000000000..8f7140ed7c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneUserListToolbar : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneUserListToolbar() + { + UserListToolbar toolbar; + OsuSpriteText sort; + OsuSpriteText displayStyle; + + Add(toolbar = new UserListToolbar + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + sort = new OsuSpriteText(), + displayStyle = new OsuSpriteText() + } + }); + + toolbar.SortCriteria.BindValueChanged(criteria => sort.Text = $"Criteria: {criteria.NewValue}", true); + toolbar.DisplayStyle.BindValueChanged(style => displayStyle.Text = $"Style: {style.NewValue}", true); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs index 2fe6240b22..c8478c8eca 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Overlays.Volume; using osuTK; @@ -12,8 +10,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneVolumePieces : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(VolumeMeter), typeof(MuteButton) }; - protected override void LoadComplete() { VolumeMeter meter; diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index b7d7bb1ee1..90c91eb007 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -6,11 +6,12 @@ using System.Linq; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Rulesets.Catch; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; namespace osu.Game.Tests @@ -20,30 +21,35 @@ namespace osu.Game.Tests ///
public class WaveformTestBeatmap : WorkingBeatmap { + private readonly Beatmap beatmap; private readonly ITrackStore trackStore; public WaveformTestBeatmap(AudioManager audioManager) - : base(new BeatmapInfo(), audioManager) + : this(audioManager, new WaveformBeatmap()) { + } + + public WaveformTestBeatmap(AudioManager audioManager, Beatmap beatmap) + : base(beatmap.BeatmapInfo, audioManager) + { + this.beatmap = beatmap; trackStore = audioManager.GetTrackStore(getZipReader()); } - protected override void Dispose(bool isDisposing) + ~WaveformTestBeatmap() { - base.Dispose(isDisposing); + // Remove the track store from the audio manager trackStore?.Dispose(); } - private Stream getStream() => TestResources.GetTestBeatmapStream(); + private static Stream getStream() => TestResources.GetTestBeatmapStream(); - private ZipArchiveReader getZipReader() => new ZipArchiveReader(getStream()); + private static ZipArchiveReader getZipReader() => new ZipArchiveReader(getStream()); - protected override IBeatmap GetBeatmap() => createTestBeatmap(); + protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; - protected override VideoSprite GetVideo() => null; - protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); protected override Track GetTrack() => trackStore.Get(firstAudioFile); @@ -57,10 +63,16 @@ namespace osu.Game.Tests } } - private Beatmap createTestBeatmap() + private class WaveformBeatmap : TestBeatmap { - using (var reader = getZipReader()) + public WaveformBeatmap() + : base(new CatchRuleset().RulesetInfo) { + } + + protected override Beatmap CreateBeatmap() + { + using (var reader = getZipReader()) using (var beatmapStream = reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu")))) using (var beatmapReader = new LineBufferedReader(beatmapStream)) return Decoder.GetDecoder(beatmapReader).Decode(beatmapReader); diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 6c799e5e90..4b0506d818 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,9 +3,9 @@ - + - + diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs index e65b708fea..f98f55dfbc 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs @@ -1,11 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Ladder.Components; @@ -13,12 +10,6 @@ namespace osu.Game.Tournament.Tests.Components { public class TestSceneDrawableTournamentMatch : TournamentTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TournamentMatch), - typeof(DrawableTournamentTeam), - }; - public TestSceneDrawableTournamentMatch() { Container level1; diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs new file mode 100644 index 0000000000..376c59ec2d --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Tests.Visual; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Drawings.Components; +using osu.Game.Tournament.Screens.Gameplay.Components; +using osu.Game.Tournament.Screens.Ladder.Components; +using osu.Game.Users; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneDrawableTournamentTeam : OsuGridTestScene + { + public TestSceneDrawableTournamentTeam() + : base(4, 3) + { + var team = new TournamentTeam + { + FlagName = { Value = "AU" }, + FullName = { Value = "Australia" }, + Players = + { + new User { Username = "ASecretBox" }, + new User { Username = "Dereban" }, + new User { Username = "mReKk" }, + new User { Username = "uyghti" }, + new User { Username = "Parkes" }, + new User { Username = "Shiroha" }, + new User { Username = "Jordan The Bear" }, + } + }; + + var match = new TournamentMatch { Team1 = { Value = team } }; + + int i = 0; + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamFlag" }, + new DrawableTeamFlag(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamTitle" }, + new DrawableTeamTitle(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamTitleWithHeader" }, + new DrawableTeamTitleWithHeader(team, TeamColour.Red) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableMatchTeam" }, + new DrawableMatchTeam(team, match, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "TeamWithPlayers" }, + new DrawableTeamWithPlayers(team, TeamColour.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "GroupTeam" }, + new GroupTeam(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "TeamDisplay" }, + new TeamDisplay(team, TeamColour.Red, new Bindable(2), 6) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs new file mode 100644 index 0000000000..b29e4964b6 --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Screens.Gameplay.Components; +using osuTK; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneMatchHeader : TournamentTestScene + { + public TestSceneMatchHeader() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(50), + Children = new Drawable[] + { + new TournamentSpriteText { Text = "with logo", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader(), + new TournamentSpriteText { Text = "without logo", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader { ShowLogo = false }, + new TournamentSpriteText { Text = "without scores", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader { ShowScores = false }, + } + }; + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs index 72d9eb0e07..acd5d53310 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs @@ -3,13 +3,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Screens.Gameplay.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneMatchScoreDisplay : LadderTestScene + public 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 new file mode 100644 index 0000000000..13bca7bea1 --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneRoundDisplay : TournamentTestScene + { + public TestSceneRoundDisplay() + { + Children = new Drawable[] + { + new RoundDisplay(new TournamentMatch + { + Round = + { + Value = new TournamentRound + { + Name = { Value = "Test Round" } + } + } + }) + { + Margin = new MarginPadding(20) + } + }; + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index 77fa411058..bc32a12ab7 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -8,12 +8,11 @@ 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.Tests.Visual; using osu.Game.Tournament.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneTournamentBeatmapPanel : OsuTestScene + public class TestSceneTournamentBeatmapPanel : TournamentTestScene { [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game.Tournament.Tests/LadderTestScene.cs b/osu.Game.Tournament.Tests/LadderTestScene.cs deleted file mode 100644 index dae0721023..0000000000 --- a/osu.Game.Tournament.Tests/LadderTestScene.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. - -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Game.Tournament.Models; - -namespace osu.Game.Tournament.Tests -{ - [TestFixture] - public abstract class LadderTestScene : TournamentTestScene - { - [Resolved] - protected LadderInfo Ladder { get; private set; } - } -} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 9de00818a5..c1159dc000 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tournament.Tests.Screens public class TestSceneGameplayScreen : TournamentTestScene { [Cached] - private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay(); + private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay { Width = 0.5f }; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs index a45c5de2bd..bceb3e6b74 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs @@ -8,7 +8,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderEditorScreen : LadderTestScene + public 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 2be0564c82..c4c100d506 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs @@ -8,7 +8,7 @@ using osu.Game.Tournament.Screens.Ladder; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderScreen : LadderTestScene + public 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 a7011c6d3c..f4032fdd54 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs @@ -1,24 +1,140 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.MapPool; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneMapPoolScreen : LadderTestScene + public class TestSceneMapPoolScreen : TournamentTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MapPoolScreen) - }; + private MapPoolScreen screen; [BackgroundDependencyLoader] private void load() { - Add(new MapPoolScreen { Width = 0.7f }); + Add(screen = new MapPoolScreen { Width = 0.7f }); + } + + [Test] + public void TestFewMaps() + { + AddStep("load few maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 8; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + [Test] + public void TestJustEnoughMaps() + { + AddStep("load just enough maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 18; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + [Test] + public void TestManyMaps() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 19; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertThreeWide(); + } + + [Test] + public void TestJustEnoughMods() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 11; i++) + addBeatmap(i > 4 ? $"M{i}" : "NM"); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + private void assertTwoWide() => + AddAssert("ensure layout width is 2", () => screen.ChildrenOfType>>().First().Padding.Left > 0); + + private void assertThreeWide() => + AddAssert("ensure layout width is 3", () => screen.ChildrenOfType>>().First().Padding.Left == 0); + + [Test] + public void TestManyMods() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 12; i++) + addBeatmap(i > 4 ? $"M{i}" : "NM"); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertThreeWide(); + } + + private void addBeatmap(string mods = "nm") + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Add(new RoundBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Mods = mods + }); } } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs index e15ac416b0..5c2b59df3a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs @@ -5,7 +5,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneRoundEditorScreen : LadderTestScene + public class TestSceneRoundEditorScreen : TournamentTestScene { public TestSceneRoundEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index 2277302e98..b240ef3ae5 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Tournament.Components; using osu.Game.Tournament.Screens.Schedule; namespace osu.Game.Tournament.Tests.Screens @@ -11,6 +13,7 @@ namespace osu.Game.Tournament.Tests.Screens [BackgroundDependencyLoader] private void load() { + Add(new TourneyVideo("main") { RelativeSizeAxes = Axes.Both }); Add(new ScheduleScreen()); } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs new file mode 100644 index 0000000000..2722021216 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Editors; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneSeedingEditorScreen : TournamentTestScene + { + [Cached] + private readonly LadderInfo ladder = new LadderInfo(); + + public TestSceneSeedingEditorScreen() + { + var match = CreateSampleMatch(); + + Add(new SeedingEditorScreen(match.Team1.Value, new TeamEditorScreen()) + { + Width = 0.85f // create room for control panel + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs new file mode 100644 index 0000000000..d414d8e36e --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.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; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.TeamIntro; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneSeedingScreen : TournamentTestScene + { + [Cached] + private readonly LadderInfo ladder = new LadderInfo(); + + [BackgroundDependencyLoader] + private void load() + { + Add(new SeedingScreen + { + FillMode = FillMode.Fit, + FillAspectRatio = 16 / 9f + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs new file mode 100644 index 0000000000..6e63b2d799 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.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.Tournament.Screens; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneStablePathSelectScreen : TournamentTestScene + { + public TestSceneStablePathSelectScreen() + { + AddStep("Add screen", () => Add(new StablePathSelectTestScreen())); + } + + private class StablePathSelectTestScreen : StablePathSelectScreen + { + protected override void ChangePath() + { + Expire(); + } + + protected override void AutoDetect() + { + Expire(); + } + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs index 097bad4a02..fc6574ec8a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs @@ -5,7 +5,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamEditorScreen : LadderTestScene + public class TestSceneTeamEditorScreen : TournamentTestScene { public TestSceneTeamEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs index e36b594ff2..b3f78c92d9 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs @@ -9,7 +9,7 @@ using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamIntroScreen : LadderTestScene + public 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 5cb35a506f..3ca58dcaf4 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs @@ -4,24 +4,19 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.TeamWin; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamWinScreen : LadderTestScene + public class TestSceneTeamWinScreen : TournamentTestScene { - [Cached] - private readonly LadderInfo ladder = new LadderInfo(); - [BackgroundDependencyLoader] private void load() { - var match = new TournamentMatch(); - match.Team1.Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "USA"); - match.Team2.Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "JPN"); + var match = Ladder.CurrentMatch.Value; + match.Round.Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals"); - ladder.CurrentMatch.Value = match; + match.Completed.Value = true; Add(new TeamWinScreen { diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 18ac3230da..d22da25f9d 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -1,13 +1,151 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; using osu.Game.Tests.Visual; +using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Models; +using osu.Game.Users; namespace osu.Game.Tournament.Tests { public abstract class TournamentTestScene : OsuTestScene { + [Cached] + protected LadderInfo Ladder { get; private set; } = new LadderInfo(); + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Cached] + protected MatchIPCInfo IPCInfo { get; private set; } = new MatchIPCInfo(); + + [BackgroundDependencyLoader] + private void load(Storage storage) + { + Ladder.Ruleset.Value ??= rulesetStore.AvailableRulesets.First(); + + TournamentMatch match = CreateSampleMatch(); + + Ladder.Rounds.Add(match.Round.Value); + Ladder.Matches.Add(match); + Ladder.Teams.Add(match.Team1.Value); + Ladder.Teams.Add(match.Team2.Value); + + Ladder.CurrentMatch.Value = match; + + Ruleset.BindTo(Ladder.Ruleset); + Dependencies.CacheAs(new StableInfo(storage)); + } + + public static TournamentMatch CreateSampleMatch() => new TournamentMatch + { + Team1 = + { + Value = new TournamentTeam + { + Acronym = { Value = "JPN" }, + FlagName = { Value = "JP" }, + FullName = { Value = "Japan" }, + LastYearPlacing = { Value = 10 }, + Seed = { Value = "Low" }, + SeedingResults = + { + new SeedingResult + { + Mod = { Value = "NM" }, + Seed = { Value = 10 }, + Beatmaps = + { + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 12345672, + Seed = { Value = 24 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 1234567, + Seed = { Value = 12 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 1234567, + Seed = { Value = 16 }, + } + } + }, + new SeedingResult + { + Mod = { Value = "DT" }, + Seed = { Value = 5 }, + Beatmaps = + { + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 3 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 6 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 12 }, + } + } + } + }, + Players = + { + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 12 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 16 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 20 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 24 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 30 } } }, + } + } + }, + Team2 = + { + Value = new TournamentTeam + { + Acronym = { Value = "USA" }, + FlagName = { Value = "US" }, + FullName = { Value = "United States" }, + Players = + { + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + } + } + }, + Round = + { + Value = new TournamentRound { Name = { Value = "Quarterfinals" } } + } + }; + + public static BeatmapInfo CreateSampleBeatmapInfo() => + new BeatmapInfo { Metadata = new BeatmapMetadata { Title = "Test Title", Artist = "Test Artist", ID = RNG.Next(0, 1000000) } }; + protected override ITestSceneTestRunner CreateRunner() => new TournamentTestSceneTestRunner(); public class TournamentTestSceneTestRunner : TournamentGameBase, ITestSceneTestRunner diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 7ecfd6ef70..f256b8e4e9 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,9 +5,9 @@ - + - + WinExe diff --git a/osu.Game.Tournament/Components/ControlPanel.cs b/osu.Game.Tournament/Components/ControlPanel.cs index a9bb1bf42f..ef8c8767e0 100644 --- a/osu.Game.Tournament/Components/ControlPanel.cs +++ b/osu.Game.Tournament/Components/ControlPanel.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; @@ -23,9 +22,9 @@ namespace osu.Game.Tournament.Components public ControlPanel() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; AlwaysPresent = true; - Width = 0.15f; + Width = TournamentSceneManager.CONTROL_AREA_WIDTH; Anchor = Anchor.TopRight; InternalChildren = new Drawable[] @@ -35,7 +34,7 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, Colour = new Color4(54, 54, 54, 255) }, - new OsuSpriteText + new TournamentSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -48,8 +47,8 @@ namespace osu.Game.Tournament.Components Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Width = 0.75f, Position = new Vector2(0, 35f), + Padding = new MarginPadding(5), Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5f), }, diff --git a/osu.Game.Tournament/Components/DrawableTeamFlag.cs b/osu.Game.Tournament/Components/DrawableTeamFlag.cs new file mode 100644 index 0000000000..8c85c9a46f --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamFlag.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamFlag : Sprite + { + private readonly TournamentTeam team; + + [UsedImplicitly] + private Bindable flag; + + public DrawableTeamFlag(TournamentTeam team) + { + this.team = team; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + if (team == null) return; + + (flag = team.FlagName.GetBoundCopy()).BindValueChanged(acronym => Texture = textures.Get($@"Flags/{team.FlagName}"), true); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamHeader.cs b/osu.Game.Tournament/Components/DrawableTeamHeader.cs new file mode 100644 index 0000000000..3d9e8a6e00 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamHeader.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamHeader : TournamentSpriteTextWithBackground + { + public DrawableTeamHeader(TeamColour colour) + { + Background.Colour = TournamentGame.GetTeamColour(colour); + + Text.Colour = TournamentGame.TEXT_COLOUR; + Text.Text = $"Team {colour}".ToUpperInvariant(); + Text.Scale = new Vector2(0.6f); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamTitle.cs b/osu.Game.Tournament/Components/DrawableTeamTitle.cs new file mode 100644 index 0000000000..5aac37259f --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamTitle.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamTitle : TournamentSpriteTextWithBackground + { + private readonly TournamentTeam team; + + [UsedImplicitly] + private Bindable acronym; + + public DrawableTeamTitle(TournamentTeam team) + { + this.team = team; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + if (team == null) return; + + (acronym = team.Acronym.GetBoundCopy()).BindValueChanged(acronym => Text.Text = team?.FullName.Value ?? string.Empty, true); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs new file mode 100644 index 0000000000..ceffe3d315 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamTitleWithHeader : CompositeDrawable + { + public DrawableTeamTitleWithHeader(TournamentTeam team, TeamColour colour) + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new DrawableTeamHeader(colour), + new DrawableTeamTitle(team), + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs new file mode 100644 index 0000000000..e949bf9881 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.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.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Models; +using osu.Game.Users; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamWithPlayers : CompositeDrawable + { + public DrawableTeamWithPlayers(TournamentTeam team, TeamColour colour) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new DrawableTeamTitleWithHeader(team, colour), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = 10 }, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + ChildrenEnumerable = team?.Players.Select(createPlayerText).Take(5) ?? Enumerable.Empty() + }, + new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + ChildrenEnumerable = team?.Players.Select(createPlayerText).Skip(5) ?? Enumerable.Empty() + }, + } + }, + } + }, + }; + + TournamentSpriteText createPlayerText(User p) => + new TournamentSpriteText + { + Text = p.Username, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold), + Colour = Color4.White, + }; + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs new file mode 100644 index 0000000000..3f5ab42fd7 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTournamentHeaderLogo : CompositeDrawable + { + public DrawableTournamentHeaderLogo() + { + InternalChild = new LogoSprite(); + + Height = 82; + RelativeSizeAxes = Axes.X; + } + + private class LogoSprite : Sprite + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Texture = textures.Get("header-logo"); + } + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs new file mode 100644 index 0000000000..99d914fed4 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTournamentHeaderText : CompositeDrawable + { + public DrawableTournamentHeaderText(bool center = true) + { + InternalChild = new TextSprite + { + Anchor = center ? Anchor.Centre : Anchor.TopLeft, + Origin = center ? Anchor.Centre : Anchor.TopLeft, + }; + + Height = 22; + RelativeSizeAxes = Axes.X; + } + + private class TextSprite : Sprite + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + + Texture = textures.Get("header-text"); + } + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs index 361bd92770..f8aed26ce1 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components @@ -19,27 +18,24 @@ namespace osu.Game.Tournament.Components public readonly TournamentTeam Team; protected readonly Sprite Flag; - protected readonly OsuSpriteText AcronymText; + protected readonly TournamentSpriteText AcronymText; [UsedImplicitly] private Bindable acronym; - [UsedImplicitly] - private Bindable flag; - protected DrawableTournamentTeam(TournamentTeam team) { Team = team; - Flag = new Sprite + Flag = new DrawableTeamFlag(team) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit }; - AcronymText = new OsuSpriteText + AcronymText = new TournamentSpriteText { - Font = OsuFont.GetFont(weight: FontWeight.Regular), + Font = OsuFont.Torus.With(weight: FontWeight.Regular), }; } @@ -49,7 +45,6 @@ namespace osu.Game.Tournament.Components if (Team == null) return; (acronym = Team.Acronym.GetBoundCopy()).BindValueChanged(acronym => AcronymText.Text = Team?.Acronym.Value?.ToUpperInvariant() ?? string.Empty, true); - (flag = Team.FlagName.GetBoundCopy()).BindValueChanged(acronym => Flag.Texture = textures.Get($@"Flags/{Team.FlagName}"), true); } } } diff --git a/osu.Game.Tournament/Components/IPCErrorDialog.cs b/osu.Game.Tournament/Components/IPCErrorDialog.cs new file mode 100644 index 0000000000..dc039cd3bc --- /dev/null +++ b/osu.Game.Tournament/Components/IPCErrorDialog.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.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Tournament.Components +{ + public class IPCErrorDialog : PopupDialog + { + public IPCErrorDialog(string headerText, string bodyText) + { + Icon = FontAwesome.Regular.SadTear; + HeaderText = headerText; + BodyText = bodyText; + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Alright.", + Action = () => Expire() + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/RoundDisplay.cs b/osu.Game.Tournament/Components/RoundDisplay.cs new file mode 100644 index 0000000000..c0002e6804 --- /dev/null +++ b/osu.Game.Tournament/Components/RoundDisplay.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Components +{ + public class RoundDisplay : CompositeDrawable + { + public RoundDisplay(TournamentMatch match) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DrawableTournamentHeaderText(false) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + new TournamentSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Text = match.Round.Value?.Name.Value ?? "Unknown Round", + Font = OsuFont.Torus.With(size: 26, weight: FontWeight.SemiBold) + }, + } + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index 8a46da9565..cafec0a88b 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -4,16 +4,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.Effects; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osu.Game.Screens.Menu; using osuTK; @@ -25,6 +23,8 @@ namespace osu.Game.Tournament.Components { private BeatmapInfo beatmap; + public const float HEIGHT = 145 / 2f; + [Resolved] private IBindable ruleset { get; set; } @@ -53,15 +53,7 @@ namespace osu.Game.Tournament.Components } } - private Container panelContents; - private Container innerPanel; - private Container outerPanel; - private TournamentBeatmapPanel panel; - - private float panelWidth => expanded ? 0.6f : 1; - - private const float main_width = 0.97f; - private const float inner_panel_width = 0.7f; + private FillFlowContainer flow; private bool expanded; @@ -71,86 +63,30 @@ namespace osu.Game.Tournament.Components set { expanded = value; - panel?.ResizeWidthTo(panelWidth, 800, Easing.OutQuint); - - if (expanded) - { - innerPanel.ResizeWidthTo(inner_panel_width, 800, Easing.OutQuint); - outerPanel.ResizeWidthTo(main_width, 800, Easing.OutQuint); - } - else - { - innerPanel.ResizeWidthTo(1, 800, Easing.OutQuint); - outerPanel.ResizeWidthTo(0.25f, 800, Easing.OutQuint); - } + flow.Direction = expanded ? FillDirection.Full : FillDirection.Vertical; } } + // Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - outerPanel = new Container + flow = new FillFlowContainer { - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.2f), - Type = EdgeEffectType.Shadow, - Radius = 5, - }, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + LayoutDuration = 500, + LayoutEasing = Easing.OutQuint, + Direction = FillDirection.Full, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - RelativePositionAxes = Axes.X, - X = -(1 - main_width) / 2, - Y = -10, - Width = main_width, - Height = TournamentBeatmapPanel.HEIGHT, - CornerRadius = TournamentBeatmapPanel.HEIGHT / 2, - CornerExponent = 2, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.93f), - }, - new OsuLogo - { - Triangles = false, - Colour = OsuColour.Gray(0.33f), - Scale = new Vector2(0.08f), - Margin = new MarginPadding(50), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, - innerPanel = new Container - { - Masking = true, - CornerRadius = TournamentBeatmapPanel.HEIGHT / 2, - CornerExponent = 2, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Width = inner_panel_width, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.86f), - }, - panelContents = new Container - { - RelativeSizeAxes = Axes.Both, - } - } - } - } } }; @@ -161,7 +97,7 @@ namespace osu.Game.Tournament.Components { if (beatmap == null) { - panelContents.Clear(); + flow.Clear(); return; } @@ -220,34 +156,86 @@ namespace osu.Game.Tournament.Components break; } - panelContents.Children = new Drawable[] + flow.Children = new Drawable[] { - new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))) + new Container { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = HEIGHT, + Width = 0.5f, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DiffPiece(stats), + new DiffPiece(("Star Rating", $"{beatmap.StarDifficulty:0.#}{srExtra}")) + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))), + new DiffPiece(("BPM", $"{bpm:0.#}")) + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }, + new OsuLogo + { + Triangles = false, + Scale = new Vector2(0.08f), + Margin = new MarginPadding(50), + X = -10, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + } + }, + }, + } + } + } }, - new DiffPiece(("BPM", $"{bpm:0.#}")) + new TournamentBeatmapPanel(beatmap) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft - }, - new DiffPiece(stats) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.BottomRight - }, - new DiffPiece(("Star Rating", $"{beatmap.StarDifficulty:0.#}{srExtra}")) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.TopRight - }, - panel = new TournamentBeatmapPanel(beatmap) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(panelWidth, 1) + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = HEIGHT, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, } }; } @@ -259,10 +247,9 @@ namespace osu.Game.Tournament.Components Margin = new MarginPadding { Horizontal = 15, Vertical = 1 }; AutoSizeAxes = Axes.Both; - static void cp(SpriteText s, Color4 colour) + static void cp(SpriteText s, bool bold) { - s.Colour = colour; - s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15); + s.Font = OsuFont.Torus.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, size: 15); } for (var i = 0; i < tuples.Length; i++) @@ -273,14 +260,14 @@ namespace osu.Game.Tournament.Components { AddText(" / ", s => { - cp(s, OsuColour.Gray(0.33f)); + cp(s, false); s.Spacing = new Vector2(-2, 0); }); } - AddText(new OsuSpriteText { Text = heading }, s => cp(s, OsuColour.Gray(0.33f))); - AddText(" ", s => cp(s, OsuColour.Gray(0.33f))); - AddText(new OsuSpriteText { Text = content }, s => cp(s, OsuColour.Gray(0.5f))); + AddText(new TournamentSpriteText { Text = heading }, s => cp(s, false)); + AddText(" ", s => cp(s, false)); + AddText(new TournamentSpriteText { Text = content }, s => cp(s, true)); } } } diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index 51483a0964..477bf4bd63 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -15,9 +15,7 @@ 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.Tournament.Models; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Components @@ -28,7 +26,7 @@ namespace osu.Game.Tournament.Components private readonly string mods; private const float horizontal_padding = 10; - private const float vertical_padding = 5; + private const float vertical_padding = 10; public const float HEIGHT = 50; @@ -51,8 +49,6 @@ namespace osu.Game.Tournament.Components currentMatch.BindValueChanged(matchChanged); currentMatch.BindTo(ladder.CurrentMatch); - CornerRadius = HEIGHT / 2; - CornerExponent = 2; Masking = true; AddRangeInternal(new Drawable[] @@ -71,52 +67,47 @@ namespace osu.Game.Tournament.Components new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding(vertical_padding), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding(15), Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, Text = new LocalisedString(( $"{Beatmap.Metadata.ArtistUnicode ?? Beatmap.Metadata.Artist} - {Beatmap.Metadata.TitleUnicode ?? Beatmap.Metadata.Title}", $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}")), - Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true), + Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding(vertical_padding), Direction = FillDirection.Horizontal, Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { Text = "mapper", Padding = new MarginPadding { Right = 5 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Regular, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = Beatmap.Metadata.AuthorString, Padding = new MarginPadding { Right = 20 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Bold, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = "difficulty", Padding = new MarginPadding { Right = 5 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Regular, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = Beatmap.Version, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Bold, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, } } @@ -133,13 +124,21 @@ namespace osu.Game.Tournament.Components if (!string.IsNullOrEmpty(mods)) { - AddInternal(new Sprite + AddInternal(new Container { - Texture = textures.Get($"mods/{mods}"), + RelativeSizeAxes = Axes.Y, + Width = 60, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Margin = new MarginPadding(20), - Scale = new Vector2(0.5f) + Margin = new MarginPadding(10), + Child = new Sprite + { + FillMode = FillMode.Fit, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Texture = textures.Get($"mods/{mods}"), + } }); } } @@ -171,16 +170,7 @@ namespace osu.Game.Tournament.Components BorderThickness = 6; - switch (found.Team) - { - case TeamColour.Red: - BorderColour = Color4.Red; - break; - - case TeamColour.Blue: - BorderColour = Color4.Blue; - break; - } + BorderColour = TournamentGame.GetTeamColour(found.Team); switch (found.Type) { diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index 48c5b9bd35..fe22d1e76d 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -9,8 +9,6 @@ using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Components { @@ -23,11 +21,11 @@ namespace osu.Game.Tournament.Components public TournamentMatchChatDisplay() { RelativeSizeAxes = Axes.X; - Y = 100; - Size = new Vector2(0.45f, 112); - Margin = new MarginPadding(10); - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; + Height = 144; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + CornerRadius = 0; } [BackgroundDependencyLoader(true)] @@ -66,8 +64,23 @@ namespace osu.Game.Tournament.Components } } + public void Expand() => this.FadeIn(300); + + public void Contract() => this.FadeOut(200); + protected override ChatLine CreateMessage(Message message) => new MatchMessage(message); + protected override StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new MatchChannel(channel); + + public class MatchChannel : StandAloneDrawableChannel + { + public MatchChannel(Channel channel) + : base(channel) + { + ScrollbarVisible = false; + } + } + protected class MatchMessage : StandAloneMessage { public MatchMessage(Message message) @@ -75,19 +88,15 @@ namespace osu.Game.Tournament.Components { } - [BackgroundDependencyLoader] private void load(LadderInfo info) { - //if (info.CurrentMatch.Value.Team1.Value.Players.Any(u => u.Id == Message.Sender.Id)) - // ColourBox.Colour = red; - //else if (info.CurrentMatch.Value.Team2.Value.Players.Any(u => u.Id == Message.Sender.Id)) - // ColourBox.Colour = blue; - //else if (Message.Sender.Colour != null) - // SenderText.Colour = ColourBox.Colour = OsuColour.FromHex(Message.Sender.Colour); + // if (info.CurrentMatch.Value.Team1.Value.Players.Any(u => u.Id == Message.Sender.Id)) + // SenderText.Colour = TournamentGame.COLOUR_RED; + // else if (info.CurrentMatch.Value.Team2.Value.Players.Any(u => u.Id == Message.Sender.Id)) + // SenderText.Colour = TournamentGame.COLOUR_BLUE; + // else if (Message.Sender.Colour != null) + // SenderText.Colour = ColourBox.Colour = Color4Extensions.FromHex(Message.Sender.Colour); } - - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); } } } diff --git a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs new file mode 100644 index 0000000000..d92b9eb605 --- /dev/null +++ b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.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; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; + +namespace osu.Game.Tournament.Components +{ + public class TournamentSpriteTextWithBackground : CompositeDrawable + { + protected readonly TournamentSpriteText Text; + protected readonly Box Background; + + public TournamentSpriteTextWithBackground(string text = "") + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + Background = new Box + { + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, + RelativeSizeAxes = Axes.Both, + }, + Text = new TournamentSpriteText + { + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR, + Font = OsuFont.Torus.With(weight: FontWeight.SemiBold, size: 50), + Padding = new MarginPadding { Left = 10, Right = 20 }, + Text = text + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index 206689ca1a..317c5f6a56 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.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.IO; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -14,13 +14,34 @@ namespace osu.Game.Tournament.Components { public class TourneyVideo : CompositeDrawable { - private readonly VideoSprite video; + private readonly string filename; + private readonly bool drawFallbackGradient; + private Video video; - private readonly ManualClock manualClock; + private ManualClock manualClock; - public TourneyVideo(Stream stream) + public TourneyVideo(string filename, bool drawFallbackGradient = false) { - if (stream == null) + this.filename = filename; + this.drawFallbackGradient = drawFallbackGradient; + } + + [BackgroundDependencyLoader] + private void load(TournamentStorage storage) + { + var stream = storage.GetStream($@"videos/{filename}"); + + if (stream != null) + { + InternalChild = video = new Video(stream, false) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Clock = new FramedClock(manualClock = new ManualClock()), + Loop = loop, + }; + } + else if (drawFallbackGradient) { InternalChild = new Box { @@ -28,26 +49,26 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, }; } - else - { - InternalChild = video = new VideoSprite(stream) - { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Clock = new FramedClock(manualClock = new ManualClock()) - }; - } } + private bool loop; + public bool Loop { set { + loop = value; if (video != null) video.Loop = value; } } + public void Reset() + { + if (manualClock != null) + manualClock.CurrentTime = 0; + } + protected override void Update() { base.Update(); diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index b19f2bedf0..999ce61ac8 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -4,11 +4,11 @@ using System; using System.IO; using System.Linq; +using JetBrains.Annotations; using Microsoft.Win32; using osu.Framework.Allocation; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Platform.Windows; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; @@ -21,6 +21,8 @@ namespace osu.Game.Tournament.IPC { public class FileBasedIPC : MatchIPCInfo { + public Storage IPCStorage { get; private set; } + [Resolved] protected IAPIProvider API { get; private set; } @@ -33,39 +35,46 @@ namespace osu.Game.Tournament.IPC [Resolved] private LadderInfo ladder { get; set; } + [Resolved] + private StableInfo stableInfo { get; set; } + private int lastBeatmapId; private ScheduledDelegate scheduled; - - public Storage Storage { get; private set; } + private GetBeatmapRequest beatmapLookupRequest; [BackgroundDependencyLoader] private void load() { - LocateStableStorage(); + var stablePath = stableInfo.StablePath ?? findStablePath(); + initialiseIPCStorage(stablePath); } - public Storage LocateStableStorage() + [CanBeNull] + private Storage initialiseIPCStorage(string path) { scheduled?.Cancel(); - Storage = null; + IPCStorage = null; try { - Storage = new StableStorage(host as DesktopGameHost); + if (string.IsNullOrEmpty(path)) + return null; + + IPCStorage = new DesktopStorage(path, host as DesktopGameHost); const string file_ipc_filename = "ipc.txt"; const string file_ipc_state_filename = "ipc-state.txt"; const string file_ipc_scores_filename = "ipc-scores.txt"; const string file_ipc_channel_filename = "ipc-channel.txt"; - if (Storage.Exists(file_ipc_filename)) + if (IPCStorage.Exists(file_ipc_filename)) { scheduled = Scheduler.AddDelayed(delegate { try { - using (var stream = Storage.GetStream(file_ipc_filename)) + using (var stream = IPCStorage.GetStream(file_ipc_filename)) using (var sr = new StreamReader(stream)) { var beatmapId = int.Parse(sr.ReadLine()); @@ -73,6 +82,8 @@ namespace osu.Game.Tournament.IPC if (lastBeatmapId != beatmapId) { + beatmapLookupRequest?.Cancel(); + lastBeatmapId = beatmapId; var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null); @@ -81,9 +92,9 @@ namespace osu.Game.Tournament.IPC Beatmap.Value = existing.BeatmapInfo; else { - var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId }); - req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets); - API.Queue(req); + beatmapLookupRequest = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId }); + beatmapLookupRequest.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets); + API.Queue(beatmapLookupRequest); } } @@ -97,7 +108,7 @@ namespace osu.Game.Tournament.IPC try { - using (var stream = Storage.GetStream(file_ipc_channel_filename)) + using (var stream = IPCStorage.GetStream(file_ipc_channel_filename)) using (var sr = new StreamReader(stream)) { ChatChannel.Value = sr.ReadLine(); @@ -110,7 +121,7 @@ namespace osu.Game.Tournament.IPC try { - using (var stream = Storage.GetStream(file_ipc_state_filename)) + using (var stream = IPCStorage.GetStream(file_ipc_state_filename)) using (var sr = new StreamReader(stream)) { State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine()); @@ -123,7 +134,7 @@ namespace osu.Game.Tournament.IPC try { - using (var stream = Storage.GetStream(file_ipc_scores_filename)) + using (var stream = IPCStorage.GetStream(file_ipc_scores_filename)) using (var sr = new StreamReader(stream)) { Score1.Value = int.Parse(sr.ReadLine()); @@ -142,73 +153,106 @@ namespace osu.Game.Tournament.IPC Logger.Error(e, "Stable installation could not be found; disabling file based IPC"); } - return Storage; + return IPCStorage; } /// - /// A method of accessing an osu-stable install in a controlled fashion. + /// Manually sets the path to the directory used for inter-process communication with a cutting-edge install. /// - private class StableStorage : WindowsStorage + /// Path to the IPC directory + /// Whether the supplied path was a valid IPC directory. + public bool SetIPCLocation(string path) { - protected override string LocateBasePath() + if (path == null || !ipcFileExistsInDirectory(path)) + return false; + + var newStorage = initialiseIPCStorage(stableInfo.StablePath = path); + if (newStorage == null) + return false; + + stableInfo.SaveChanges(); + return true; + } + + /// + /// Tries to automatically detect the path to the directory used for inter-process communication + /// with a cutting-edge install. + /// + /// Whether an IPC directory was successfully auto-detected. + public bool AutoDetectIPCLocation() => SetIPCLocation(findStablePath()); + + private static bool ipcFileExistsInDirectory(string p) => p != null && File.Exists(Path.Combine(p, "ipc.txt")); + + [CanBeNull] + private string findStablePath() + { + var stableInstallPath = findFromEnvVar() ?? + findFromRegistry() ?? + findFromLocalAppData() ?? + findFromDotFolder(); + + Logger.Log($"Stable path for tourney usage: {stableInstallPath}"); + return stableInstallPath; + } + + private string findFromEnvVar() + { + try { - static bool checkExists(string p) - { - return File.Exists(Path.Combine(p, "ipc.txt")); - } + Logger.Log("Trying to find stable with environment variables"); + string stableInstallPath = Environment.GetEnvironmentVariable("OSU_STABLE_PATH"); - string stableInstallPath = string.Empty; - - try - { - try - { - stableInstallPath = "G:\\My Drive\\Main\\osu!tourney"; - - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = "G:\\My Drive\\Main\\osu!mappool"; - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - try - { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - return null; - } - finally - { - Logger.Log($"Stable path for tourney usage: {stableInstallPath}"); - } + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; } - - public StableStorage(DesktopGameHost host) - : base(string.Empty, host) + catch { } + + return null; + } + + private string findFromLocalAppData() + { + Logger.Log("Trying to find stable in %LOCALAPPDATA%"); + string stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + + return null; + } + + private string findFromDotFolder() + { + Logger.Log("Trying to find stable in dotfolders"); + string stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + + return null; + } + + private string findFromRegistry() + { + Logger.Log("Trying to find stable in registry"); + + try + { + string stableInstallPath; + + using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + } + catch + { + } + + return null; } } } diff --git a/osu.Game.Tournament/Models/LadderInfo.cs b/osu.Game.Tournament/Models/LadderInfo.cs index 5db0b01547..7794019437 100644 --- a/osu.Game.Tournament/Models/LadderInfo.cs +++ b/osu.Game.Tournament/Models/LadderInfo.cs @@ -24,7 +24,19 @@ namespace osu.Game.Tournament.Models // only used for serialisation public List Progressions = new List(); - [JsonIgnore] + [JsonIgnore] // updated manually in TournamentGameBase public Bindable CurrentMatch = new Bindable(); + + public Bindable ChromaKeyWidth = new BindableInt(1024) + { + MinValue = 640, + MaxValue = 1366, + }; + + public Bindable PlayersPerTeam = new BindableInt(4) + { + MinValue = 3, + MaxValue = 4, + }; } } diff --git a/osu.Game.Tournament/Models/SeedingBeatmap.cs b/osu.Game.Tournament/Models/SeedingBeatmap.cs new file mode 100644 index 0000000000..2cd6fa7188 --- /dev/null +++ b/osu.Game.Tournament/Models/SeedingBeatmap.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.Bindables; +using osu.Game.Beatmaps; + +namespace osu.Game.Tournament.Models +{ + public class SeedingBeatmap + { + public int ID; + + public BeatmapInfo BeatmapInfo; + + public long Score; + + public Bindable Seed = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + } +} diff --git a/osu.Game.Tournament/Models/SeedingResult.cs b/osu.Game.Tournament/Models/SeedingResult.cs new file mode 100644 index 0000000000..87aaf8bf36 --- /dev/null +++ b/osu.Game.Tournament/Models/SeedingResult.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; + +namespace osu.Game.Tournament.Models +{ + public class SeedingResult + { + public List Beatmaps = new List(); + + public Bindable Mod = new Bindable(); + + public Bindable Seed = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + } +} diff --git a/osu.Game.Tournament/Models/StableInfo.cs b/osu.Game.Tournament/Models/StableInfo.cs new file mode 100644 index 0000000000..0b0050a245 --- /dev/null +++ b/osu.Game.Tournament/Models/StableInfo.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 System.IO; +using Newtonsoft.Json; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Models +{ + /// + /// Holds the path to locate the osu! stable cutting-edge installation. + /// + [Serializable] + public class StableInfo + { + /// + /// Path to the IPC directory used by the stable (cutting-edge) install. + /// + public string StablePath { get; set; } + + /// + /// Fired whenever stable info is successfully saved to file. + /// + public event Action OnStableInfoSaved; + + private const string config_path = "tournament/stable.json"; + + private readonly Storage storage; + + public StableInfo(Storage storage) + { + this.storage = storage; + + if (!storage.Exists(config_path)) + return; + + using (Stream stream = storage.GetStream(config_path, FileAccess.Read, FileMode.Open)) + using (var sr = new StreamReader(stream)) + { + JsonConvert.PopulateObject(sr.ReadToEnd(), this); + } + } + + public void SaveChanges() + { + using (var stream = storage.GetStream(config_path, FileAccess.Write, FileMode.Create)) + using (var sw = new StreamWriter(stream)) + { + sw.Write(JsonConvert.SerializeObject(this, + new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore, + })); + } + + OnStableInfoSaved?.Invoke(); + } + } +} diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 06cce3d59e..8ebcbf4e15 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -90,6 +90,8 @@ namespace osu.Game.Tournament.Models [JsonIgnore] public TournamentTeam Loser => !Completed.Value ? null : Team1Score.Value > Team2Score.Value ? Team2.Value : Team1.Value; + public TeamColour WinnerColour => Winner == Team1.Value ? TeamColour.Red : TeamColour.Blue; + public int PointsToWin => Round.Value?.BestOf.Value / 2 + 1 ?? 0; /// diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 54b8a35180..7fca75cea4 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Users; @@ -29,6 +30,32 @@ namespace osu.Game.Tournament.Models /// public Bindable Acronym = new Bindable(string.Empty); + public BindableList SeedingResults = new BindableList(); + + public double AverageRank + { + get + { + var ranks = Players.Select(p => p.Statistics?.Ranks.Global) + .Where(i => i.HasValue) + .Select(i => i.Value) + .ToArray(); + + if (ranks.Length == 0) + return 0; + + return ranks.Average(); + } + } + + public Bindable Seed = new Bindable(string.Empty); + + public Bindable LastYearPlacing = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + [JsonProperty] public BindableList Players { get; set; } = new BindableList(); diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin b/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin deleted file mode 100644 index 42cfdf08de..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png b/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png deleted file mode 100644 index 332d9ca056..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin b/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin deleted file mode 100644 index 3047c2eb3e..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png b/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png deleted file mode 100644 index 1252d233d3..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png and /dev/null differ diff --git a/osu.Game.Tournament/Resources/countries.json b/osu.Game.Tournament/Resources/countries.json index ec2ca2bf37..7306a8bec5 100644 --- a/osu.Game.Tournament/Resources/countries.json +++ b/osu.Game.Tournament/Resources/countries.json @@ -541,7 +541,7 @@ }, { "FlagName": "MK", - "FullName": "Macedonia", + "FullName": "North Macedonia", "Acronym": "MKD" }, { @@ -811,7 +811,7 @@ }, { "FlagName": "CV", - "FullName": "Cape Verde", + "FullName": "Cabo Verde", "Acronym": "CPV" }, { @@ -821,7 +821,7 @@ }, { "FlagName": "SZ", - "FullName": "Swaziland", + "FullName": "Eswatini", "Acronym": "SWZ" }, { diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index fccd35ca9e..0a3163ef43 100644 --- a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs +++ b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs @@ -21,6 +21,7 @@ namespace osu.Game.Tournament.Screens { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, + Depth = float.MinValue, }); } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs index 549ff26018..ece1c431e2 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs @@ -8,8 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; @@ -43,7 +41,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Colour = new Color4(54, 54, 54, 255) }, // Group name - new OsuSpriteText + new TournamentSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -51,7 +49,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Position = new Vector2(0, 7f), Text = $"GROUP {name.ToUpperInvariant()}", - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 8), + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 8), Colour = new Color4(255, 204, 34, 255), }, teams = new FillFlowContainer @@ -117,53 +115,5 @@ namespace osu.Game.Tournament.Screens.Drawings.Components sb.AppendLine(gt.Team.FullName.Value); return sb.ToString(); } - - private class GroupTeam : DrawableTournamentTeam - { - private readonly FillFlowContainer innerContainer; - - public GroupTeam(TournamentTeam team) - : base(team) - { - Width = 36; - AutoSizeAxes = Axes.Y; - - Flag.Anchor = Anchor.TopCentre; - Flag.Origin = Anchor.TopCentre; - - AcronymText.Anchor = Anchor.TopCentre; - AcronymText.Origin = Anchor.TopCentre; - AcronymText.Text = team.Acronym.Value.ToUpperInvariant(); - AcronymText.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 10); - - InternalChildren = new Drawable[] - { - innerContainer = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5f), - - Children = new Drawable[] - { - Flag, - AcronymText - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - innerContainer.ScaleTo(1.5f); - innerContainer.ScaleTo(1f, 200); - } - } } } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs new file mode 100644 index 0000000000..4f0ce0bbe7 --- /dev/null +++ b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Drawings.Components +{ + public class GroupTeam : DrawableTournamentTeam + { + private readonly FillFlowContainer innerContainer; + + public GroupTeam(TournamentTeam team) + : base(team) + { + Width = 36; + AutoSizeAxes = Axes.Y; + + Flag.Anchor = Anchor.TopCentre; + Flag.Origin = Anchor.TopCentre; + + AcronymText.Anchor = Anchor.TopCentre; + AcronymText.Origin = Anchor.TopCentre; + AcronymText.Text = team.Acronym.Value.ToUpperInvariant(); + AcronymText.Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 10); + + InternalChildren = new Drawable[] + { + innerContainer = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5f), + + Children = new Drawable[] + { + Flag, + AcronymText + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + innerContainer.ScaleTo(1.5f); + innerContainer.ScaleTo(1f, 200); + } + } +} diff --git a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs index 1cd942b987..f21f5c9460 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Tournament.Screens.Drawings.Components { diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 5efa0a1e69..e10154b722 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Drawings.Components; @@ -29,7 +28,7 @@ namespace osu.Game.Tournament.Screens.Drawings private ScrollingTeamContainer teamsContainer; private GroupContainer groupsContainer; - private OsuSpriteText fullTeamNameText; + private TournamentSpriteText fullTeamNameText; private readonly List allTeams = new List(); @@ -48,8 +47,7 @@ namespace osu.Game.Tournament.Screens.Drawings this.storage = storage; - if (TeamList == null) - TeamList = new StorageBackedTeamList(storage); + TeamList ??= new StorageBackedTeamList(storage); if (!TeamList.Teams.Any()) { @@ -109,18 +107,18 @@ namespace osu.Game.Tournament.Screens.Drawings RelativeSizeAxes = Axes.X, }, // Scrolling team name - fullTeamNameText = new OsuSpriteText + fullTeamNameText = new TournamentSpriteText { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Position = new Vector2(0, 45f), - Colour = OsuColour.Gray(0.33f), + Colour = OsuColour.Gray(0.95f), Alpha = 0, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 42), + Font = OsuFont.Torus.With(weight: FontWeight.Light, size: 42), } } }, diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 7119533743..8b8078e119 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -129,8 +129,6 @@ namespace osu.Game.Tournament.Screens.Editors RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, ChildrenEnumerable = round.Beatmaps.Select(p => new RoundBeatmapRow(round, p)) }; } diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs new file mode 100644 index 0000000000..0973a7dc75 --- /dev/null +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -0,0 +1,290 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Editors +{ + public class SeedingEditorScreen : TournamentEditorScreen + { + private readonly TournamentTeam team; + + protected override BindableList Storage => team.SeedingResults; + + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + public SeedingEditorScreen(TournamentTeam team, TournamentScreen parentScreen) + : base(parentScreen) + { + this.team = team; + } + + public class SeedingResultRow : CompositeDrawable, IModelBacked + { + public SeedingResult Model { get; } + + [Resolved] + private LadderInfo ladderInfo { get; set; } + + public SeedingResultRow(TournamentTeam team, SeedingResult round) + { + Model = round; + + Masking = true; + CornerRadius = 10; + + SeedingBeatmapEditor beatmapEditor = new SeedingBeatmapEditor(round) + { + Width = 0.95f + }; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(5), + Padding = new MarginPadding { Right = 160 }, + Spacing = new Vector2(5), + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new SettingsTextBox + { + LabelText = "Mod", + Width = 0.33f, + Bindable = Model.Mod + }, + new SettingsSlider + { + LabelText = "Seed", + Width = 0.33f, + Bindable = Model.Seed + }, + new SettingsButton + { + Width = 0.2f, + Margin = new MarginPadding(10), + Text = "Add beatmap", + Action = () => beatmapEditor.CreateNew() + }, + beatmapEditor + } + }, + new DangerousSettingsButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.None, + Width = 150, + Text = "Delete result", + Action = () => + { + Expire(); + team.SeedingResults.Remove(Model); + }, + } + }; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + public class SeedingBeatmapEditor : CompositeDrawable + { + private readonly SeedingResult round; + private readonly FillFlowContainer flow; + + public SeedingBeatmapEditor(SeedingResult round) + { + this.round = round; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + ChildrenEnumerable = round.Beatmaps.Select(p => new SeedingBeatmapRow(round, p)) + }; + } + + public void CreateNew() + { + var user = new SeedingBeatmap(); + round.Beatmaps.Add(user); + flow.Add(new SeedingBeatmapRow(round, user)); + } + + public class SeedingBeatmapRow : CompositeDrawable + { + private readonly SeedingResult result; + public SeedingBeatmap Model { get; } + + [Resolved] + protected IAPIProvider API { get; private set; } + + private readonly Bindable beatmapId = new Bindable(); + + private readonly Bindable score = new Bindable(); + + private readonly Container drawableContainer; + + public SeedingBeatmapRow(SeedingResult result, SeedingBeatmap beatmap) + { + this.result = result; + Model = beatmap; + + Margin = new MarginPadding(10); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(5), + Padding = new MarginPadding { Right = 160 }, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SettingsNumberBox + { + LabelText = "Beatmap ID", + RelativeSizeAxes = Axes.None, + Width = 200, + Bindable = beatmapId, + }, + new SettingsSlider + { + LabelText = "Seed", + RelativeSizeAxes = Axes.None, + Width = 200, + Bindable = beatmap.Seed + }, + new SettingsTextBox + { + LabelText = "Score", + RelativeSizeAxes = Axes.None, + Width = 200, + Bindable = score, + }, + drawableContainer = new Container + { + Size = new Vector2(100, 70), + }, + } + }, + new DangerousSettingsButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.None, + Width = 150, + Text = "Delete Beatmap", + Action = () => + { + Expire(); + result.Beatmaps.Remove(beatmap); + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + beatmapId.Value = Model.ID.ToString(); + beatmapId.BindValueChanged(idString => + { + int parsed; + + int.TryParse(idString.NewValue, out parsed); + + Model.ID = parsed; + + if (idString.NewValue != idString.OldValue) + Model.BeatmapInfo = null; + + if (Model.BeatmapInfo != null) + { + updatePanel(); + return; + } + + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = Model.ID }); + + req.Success += res => + { + Model.BeatmapInfo = res.ToBeatmap(rulesets); + updatePanel(); + }; + + req.Failure += _ => + { + Model.BeatmapInfo = null; + updatePanel(); + }; + + API.Queue(req); + }, true); + + score.Value = Model.Score.ToString(); + score.BindValueChanged(str => long.TryParse(str.NewValue, out Model.Score)); + } + + private void updatePanel() + { + drawableContainer.Clear(); + + if (Model.BeatmapInfo != null) + { + drawableContainer.Child = new TournamentBeatmapPanel(Model.BeatmapInfo, result.Mod.Value) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 300 + }; + } + } + } + } + } + + protected override SeedingResultRow CreateDrawable(SeedingResult model) => new SeedingResultRow(team, model); + } +} diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 494dd73edd..dbfcfe4225 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tournament.Screens.Editors }); } - protected override TeamRow CreateDrawable(TournamentTeam model) => new TeamRow(model); + protected override TeamRow CreateDrawable(TournamentTeam model) => new TeamRow(model, this); private void addAllCountries() { @@ -57,10 +57,13 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Container drawableContainer; + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + [Resolved] private LadderInfo ladderInfo { get; set; } - public TeamRow(TournamentTeam team) + public TeamRow(TournamentTeam team, TournamentScreen parent) { Model = team; @@ -113,6 +116,18 @@ namespace osu.Game.Tournament.Screens.Editors Width = 0.2f, Bindable = Model.FlagName }, + new SettingsTextBox + { + LabelText = "Seed", + Width = 0.2f, + Bindable = Model.Seed + }, + new SettingsSlider + { + LabelText = "Last Year Placement", + Width = 0.33f, + Bindable = Model.LastYearPlacing + }, new SettingsButton { Width = 0.11f, @@ -131,7 +146,17 @@ namespace osu.Game.Tournament.Screens.Editors ladderInfo.Teams.Remove(Model); }, }, - playerEditor + playerEditor, + new SettingsButton + { + Width = 0.2f, + Margin = new MarginPadding(10), + Text = "Edit seeding results", + Action = () => + { + sceneManager?.SetScreen(new SeedingEditorScreen(team, parent)); + } + }, } }, }; @@ -147,19 +172,6 @@ namespace osu.Game.Tournament.Screens.Editors drawableContainer.Child = new DrawableTeamFlag(Model); } - private class DrawableTeamFlag : DrawableTournamentTeam - { - public DrawableTeamFlag(TournamentTeam team) - : base(team) - { - InternalChild = Flag; - RelativeSizeAxes = Axes.Both; - - Flag.Anchor = Anchor.Centre; - Flag.Origin = Anchor.Centre; - } - } - public class PlayerEditor : CompositeDrawable { private readonly TournamentTeam team; @@ -177,8 +189,6 @@ namespace osu.Game.Tournament.Screens.Editors RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, ChildrenEnumerable = team.Players.Select(p => new PlayerRow(team, p)) }; } @@ -286,7 +296,7 @@ namespace osu.Game.Tournament.Screens.Editors private void updatePanel() { - drawableContainer.Child = new UserPanel(user) { Width = 300 }; + drawableContainer.Child = new UserGridPanel(user) { Width = 300 }; } } } diff --git a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs index 32cf6bbcc8..a5a2c5c15f 100644 --- a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.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.Collections.Specialized; +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.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Overlays.Settings; @@ -23,8 +26,19 @@ namespace osu.Game.Tournament.Screens.Editors private FillFlowContainer flow; + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + protected ControlPanel ControlPanel; + private readonly TournamentScreen parentScreen; + private BackButton backButton; + + protected TournamentEditorScreen(TournamentScreen parentScreen = null) + { + this.parentScreen = parentScreen; + } + [BackgroundDependencyLoader] private void load() { @@ -38,7 +52,6 @@ namespace osu.Game.Tournament.Screens.Editors new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Width = 0.9f, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Child = flow = new FillFlowContainer @@ -46,9 +59,7 @@ namespace osu.Game.Tournament.Screens.Editors Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - Spacing = new Vector2(20) + Spacing = new Vector2(20), }, }, ControlPanel = new ControlPanel @@ -71,8 +82,32 @@ namespace osu.Game.Tournament.Screens.Editors } }); - Storage.ItemsAdded += items => items.ForEach(i => flow.Add(CreateDrawable(i))); - Storage.ItemsRemoved += items => items.ForEach(i => flow.RemoveAll(d => d.Model == i)); + if (parentScreen != null) + { + AddInternal(backButton = new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => sceneManager?.SetScreen(parentScreen.GetType()) + }); + + flow.Padding = new MarginPadding { Bottom = backButton.Height * 2 }; + } + + Storage.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(i => flow.Add(CreateDrawable(i))); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i)); + break; + } + }; foreach (var model in Storage) flow.Add(CreateDrawable(model)); diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs index 9e1888b44b..d790f4b754 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs @@ -2,24 +2,54 @@ // 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.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Tournament.Screens.Gameplay.Components { public class MatchHeader : Container { + private TeamScoreDisplay teamDisplay1; + private TeamScoreDisplay teamDisplay2; + private DrawableTournamentHeaderLogo logo; + + private bool showScores = true; + + public bool ShowScores + { + get => showScores; + set + { + if (value == showScores) + return; + + showScores = value; + + if (IsLoaded) + updateDisplay(); + } + } + + private bool showLogo = true; + + public bool ShowLogo + { + get => showLogo; + set + { + if (value == showLogo) + return; + + showLogo = value; + + if (IsLoaded) + updateDisplay(); + } + } + [BackgroundDependencyLoader] private void load() { @@ -27,194 +57,54 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components Height = 95; Children = new Drawable[] { - new TournamentLogo(), - new RoundDisplay + new FillFlowContainer { - Y = 10, - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(20), + Spacing = new Vector2(5), + Children = new Drawable[] + { + logo = new DrawableTournamentHeaderLogo + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Alpha = showLogo ? 1 : 0 + }, + new DrawableTournamentHeaderText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new MatchRoundDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(0.4f) + }, + } }, - new TeamScoreDisplay(TeamColour.Red) + teamDisplay1 = new TeamScoreDisplay(TeamColour.Red) { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, }, - new TeamScoreDisplay(TeamColour.Blue) + teamDisplay2 = new TeamScoreDisplay(TeamColour.Blue) { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }, }; + + updateDisplay(); } - private class TeamScoreDisplay : CompositeDrawable + private void updateDisplay() { - private readonly TeamColour teamColour; + teamDisplay1.ShowScore = showScores; + teamDisplay2.ShowScore = showScores; - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - private readonly Bindable currentMatch = new Bindable(); - private readonly Bindable currentTeam = new Bindable(); - private readonly Bindable currentTeamScore = new Bindable(); - - public TeamScoreDisplay(TeamColour teamColour) - { - this.teamColour = teamColour; - - RelativeSizeAxes = Axes.Y; - Width = 300; - } - - [BackgroundDependencyLoader] - private void load(LadderInfo ladder) - { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - } - - private void matchChanged(ValueChangedEvent match) - { - currentTeamScore.UnbindBindings(); - currentTeamScore.BindTo(teamColour == TeamColour.Red ? match.NewValue.Team1Score : match.NewValue.Team2Score); - - currentTeam.UnbindBindings(); - currentTeam.BindTo(teamColour == TeamColour.Red ? match.NewValue.Team1 : match.NewValue.Team2); - - // team may change to same team, which means score is not in a good state. - // thus we handle this manually. - teamChanged(currentTeam.Value); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - switch (e.Button) - { - case MouseButton.Left: - if (currentTeamScore.Value < currentMatch.Value.PointsToWin) - currentTeamScore.Value++; - return true; - - case MouseButton.Right: - if (currentTeamScore.Value > 0) - currentTeamScore.Value--; - return true; - } - - return base.OnMouseDown(e); - } - - private void teamChanged(TournamentTeam team) - { - var colour = teamColour == TeamColour.Red ? red : blue; - var flip = teamColour != TeamColour.Red; - - InternalChildren = new Drawable[] - { - new TeamDisplay(team, colour, flip), - new TeamScore(currentTeamScore, flip, currentMatch.Value.PointsToWin) - { - Colour = colour - } - }; - } - } - - private class TeamScore : CompositeDrawable - { - private readonly Bindable currentTeamScore = new Bindable(); - private readonly StarCounter counter; - - public TeamScore(Bindable score, bool flip, int count) - { - var anchor = flip ? Anchor.CentreRight : Anchor.CentreLeft; - - Anchor = anchor; - Origin = anchor; - - InternalChild = counter = new StarCounter(count) - { - Anchor = anchor, - X = (flip ? -1 : 1) * 90, - Y = 5, - Scale = flip ? new Vector2(-1, 1) : Vector2.One, - }; - - currentTeamScore.BindValueChanged(scoreChanged); - currentTeamScore.BindTo(score); - } - - private void scoreChanged(ValueChangedEvent score) => counter.CountStars = score.NewValue ?? 0; - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, Color4 colour, bool flip) - : base(team) - { - RelativeSizeAxes = Axes.Both; - - var anchor = flip ? Anchor.CentreRight : Anchor.CentreLeft; - - Anchor = Origin = anchor; - - Flag.Anchor = Flag.Origin = anchor; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(60, 40); - Flag.Margin = new MarginPadding(20); - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - X = (flip ? -1 : 1) * 90, - Y = -10, - Colour = colour, - Font = TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 20), - Origin = anchor, - Anchor = anchor, - }, - } - }; - } - } - - private class RoundDisplay : CompositeDrawable - { - private readonly Bindable currentMatch = new Bindable(); - - public RoundDisplay() - { - Width = 200; - Height = 20; - } - - [BackgroundDependencyLoader] - private void load(LadderInfo ladder) - { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - } - - private void matchChanged(ValueChangedEvent match) - { - InternalChildren = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = Color4.White, - Text = match.NewValue.Round.Value?.Name.Value ?? "Unknown Round", - Font = TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 18), - }, - }; - } + logo.Alpha = showLogo ? 1 : 0; } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs new file mode 100644 index 0000000000..87793f7e1b --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class MatchRoundDisplay : TournamentSpriteTextWithBackground + { + private readonly Bindable currentMatch = new Bindable(); + + [BackgroundDependencyLoader] + private void load(LadderInfo ladder) + { + currentMatch.BindValueChanged(matchChanged); + currentMatch.BindTo(ladder.CurrentMatch); + } + + private void matchChanged(ValueChangedEvent match) => + Text.Text = match.NewValue.Round.Value?.Name.Value ?? "Unknown Round"; + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs index cc7903f2fa..2e7484542a 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs @@ -11,16 +11,13 @@ using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; -using osuTK.Graphics; +using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { public class MatchScoreDisplay : CompositeDrawable { - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); - - private const float bar_height = 20; + private const float bar_height = 18; private readonly BindableInt score1 = new BindableInt(); private readonly BindableInt score2 = new BindableInt(); @@ -28,45 +25,63 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components private readonly MatchScoreCounter score1Text; private readonly MatchScoreCounter score2Text; - private readonly Circle score1Bar; - private readonly Circle score2Bar; + private readonly Drawable score1Bar; + private readonly Drawable score2Bar; public MatchScoreDisplay() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + InternalChildren = new[] { - score1Bar = new Circle + new Box + { + Name = "top bar red (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = TournamentGame.COLOUR_RED, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight + }, + new Box + { + Name = "top bar blue (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = TournamentGame.COLOUR_BLUE, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft + }, + score1Bar = new Box { Name = "top bar red", RelativeSizeAxes = Axes.X, Height = bar_height, Width = 0, - Colour = red, + Colour = TournamentGame.COLOUR_RED, Anchor = Anchor.TopCentre, Origin = Anchor.TopRight }, score1Text = new MatchScoreCounter { - Colour = red, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre }, - score2Bar = new Circle + score2Bar = new Box { Name = "top bar blue", RelativeSizeAxes = Axes.X, Height = bar_height, Width = 0, - Colour = blue, + Colour = TournamentGame.COLOUR_BLUE, Anchor = Anchor.TopCentre, Origin = Anchor.TopLeft }, score2Text = new MatchScoreCounter { - Colour = blue, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre }, @@ -103,10 +118,9 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint); } - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); - + base.UpdateAfterChildren(); score1Text.X = -Math.Max(5 + score1Text.DrawWidth / 2, score1Bar.DrawWidth); score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } @@ -115,16 +129,18 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components { public MatchScoreCounter() { - Margin = new MarginPadding { Top = bar_height + 5, Horizontal = 10 }; + Margin = new MarginPadding { Top = bar_height, Horizontal = 10 }; Winning = false; + + DisplayedCountSpriteText.Spacing = new Vector2(-6); } public bool Winning { set => DisplayedCountSpriteText.Font = value - ? TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 60) - : TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Light, size: 40); + ? OsuFont.Torus.With(weight: FontWeight.Bold, size: 50, fixedWidth: true) + : OsuFont.Torus.With(weight: FontWeight.Regular, size: 40, fixedWidth: true); } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs new file mode 100644 index 0000000000..b01c93ae03 --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamDisplay : DrawableTournamentTeam + { + private readonly TeamScore score; + + public bool ShowScore + { + set => score.FadeTo(value ? 1 : 0, 200); + } + + public TeamDisplay(TournamentTeam team, TeamColour colour, Bindable currentTeamScore, int pointsToWin) + : base(team) + { + AutoSizeAxes = Axes.Both; + + bool flip = colour == TeamColour.Red; + + var anchor = flip ? Anchor.TopLeft : Anchor.TopRight; + + Flag.RelativeSizeAxes = Axes.None; + Flag.Size = new Vector2(60, 40); + Flag.Origin = anchor; + Flag.Anchor = anchor; + + Margin = new MarginPadding(20); + + InternalChild = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + Flag, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Origin = anchor, + Anchor = anchor, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new DrawableTeamHeader(colour) + { + Scale = new Vector2(0.75f), + Origin = anchor, + Anchor = anchor, + }, + score = new TeamScore(currentTeamScore, colour, pointsToWin) + { + Origin = anchor, + Anchor = anchor, + } + } + }, + new TournamentSpriteTextWithBackground(team?.FullName.Value ?? "???") + { + Scale = new Vector2(0.5f), + Origin = anchor, + Anchor = anchor, + }, + } + }, + } + }, + } + }; + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs new file mode 100644 index 0000000000..36c78c5ac1 --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.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.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.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Tournament.Models; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamScore : CompositeDrawable + { + private readonly Bindable currentTeamScore = new Bindable(); + private readonly StarCounter counter; + + public TeamScore(Bindable score, TeamColour colour, int count) + { + bool flip = colour == TeamColour.Blue; + var anchor = flip ? Anchor.TopRight : Anchor.TopLeft; + + AutoSizeAxes = Axes.Both; + + InternalChild = counter = new TeamScoreStarCounter(count) + { + Anchor = anchor, + Scale = flip ? new Vector2(-1, 1) : Vector2.One, + }; + + currentTeamScore.BindValueChanged(scoreChanged); + currentTeamScore.BindTo(score); + } + + private void scoreChanged(ValueChangedEvent score) => counter.Current = score.NewValue ?? 0; + + public class TeamScoreStarCounter : StarCounter + { + public TeamScoreStarCounter(int count) + : base(count) + { + } + + public override Star CreateStar() => new LightSquare(); + + public class LightSquare : Star + { + private readonly Box box; + + public LightSquare() + { + Size = new Vector2(22.5f); + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderColour = OsuColour.Gray(0.5f), + BorderThickness = 3, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Transparent, + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + }, + } + }, + box = new Box + { + Colour = Color4Extensions.FromHex("#FFE8AD"), + RelativeSizeAxes = Axes.Both, + }, + }; + + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Color4Extensions.FromHex("#FFE8AD").Opacity(0.1f), + Hollow = true, + Radius = 20, + Roundness = 10, + }; + } + + public override void DisplayAt(float scale) + { + box.FadeTo(scale, 500, Easing.OutQuint); + FadeEdgeEffectTo(0.2f * scale, 500, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs new file mode 100644 index 0000000000..da55ba53ea --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Tournament.Models; +using osuTK.Input; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamScoreDisplay : CompositeDrawable + { + private readonly TeamColour teamColour; + + private readonly Bindable currentMatch = new Bindable(); + private readonly Bindable currentTeam = new Bindable(); + private readonly Bindable currentTeamScore = new Bindable(); + + private TeamDisplay teamDisplay; + + public bool ShowScore + { + set => teamDisplay.ShowScore = value; + } + + public TeamScoreDisplay(TeamColour teamColour) + { + this.teamColour = teamColour; + + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load(LadderInfo ladder) + { + currentMatch.BindTo(ladder.CurrentMatch); + currentMatch.BindValueChanged(matchChanged); + + updateMatch(); + } + + private void matchChanged(ValueChangedEvent match) + { + currentTeamScore.UnbindBindings(); + currentTeam.UnbindBindings(); + + Scheduler.AddOnce(updateMatch); + } + + private void updateMatch() + { + var match = currentMatch.Value; + + if (match != null) + { + match.StartMatch(); + + currentTeamScore.BindTo(teamColour == TeamColour.Red ? match.Team1Score : match.Team2Score); + currentTeam.BindTo(teamColour == TeamColour.Red ? match.Team1 : match.Team2); + } + + // team may change to same team, which means score is not in a good state. + // thus we handle this manually. + teamChanged(currentTeam.Value); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (e.Button) + { + case MouseButton.Left: + if (currentTeamScore.Value < currentMatch.Value.PointsToWin) + currentTeamScore.Value++; + return true; + + case MouseButton.Right: + if (currentTeamScore.Value > 0) + currentTeamScore.Value--; + return true; + } + + return base.OnMouseDown(e); + } + + private void teamChanged(TournamentTeam team) + { + InternalChildren = new Drawable[] + { + teamDisplay = new TeamDisplay(team, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), + }; + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index 6a3095d42d..e4e3842369 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -6,20 +6,21 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Gameplay.Components; using osu.Game.Tournament.Screens.MapPool; using osu.Game.Tournament.Screens.TeamWin; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Gameplay { - public class GameplayScreen : BeatmapInfoScreen + public class GameplayScreen : BeatmapInfoScreen, IProvideVideo { private readonly BindableBool warmup = new BindableBool(); @@ -29,75 +30,69 @@ namespace osu.Game.Tournament.Screens.Gameplay private OsuButton warmupButton; private MatchIPCInfo ipc; - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } [Resolved] private TournamentMatchChatDisplay chat { get; set; } + private Drawable chroma; + [BackgroundDependencyLoader] - private void load(LadderInfo ladder, MatchIPCInfo ipc) + private void load(LadderInfo ladder, MatchIPCInfo ipc, Storage storage) { this.ipc = ipc; AddRangeInternal(new Drawable[] { - new MatchHeader(), + new TourneyVideo("gameplay") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, + header = new MatchHeader + { + ShowLogo = false + }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Y = 5, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] + Y = 110, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new[] { - new Box + chroma = new Container { - // chroma key area for stable gameplay - Name = "chroma", - RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, Height = 512, - Colour = new Color4(0, 255, 0, 255), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Y = -4, Children = new Drawable[] { - new Circle + new ChromaArea { - Name = "top bar red", - RelativeSizeAxes = Axes.X, - Height = 8, + Name = "Left chroma", + RelativeSizeAxes = Axes.Both, Width = 0.5f, - Colour = red, }, - new Circle + new ChromaArea { - Name = "top bar blue", - RelativeSizeAxes = Axes.X, - Height = 8, - Width = 0.5f, - Colour = blue, + Name = "Right chroma", + RelativeSizeAxes = Axes.Both, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, + Width = 0.5f, + } } }, } }, scoreDisplay = new MatchScoreDisplay { - Y = -60, - Scale = new Vector2(0.8f), + Y = -147, Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, + Origin = Anchor.TopCentre, }, new ControlPanel { @@ -114,6 +109,18 @@ namespace osu.Game.Tournament.Screens.Gameplay RelativeSizeAxes = Axes.X, Text = "Toggle chat", Action = () => { State.Value = State.Value == TourneyState.Idle ? TourneyState.Playing : TourneyState.Idle; } + }, + new SettingsSlider + { + LabelText = "Chroma width", + Bindable = LadderInfo.ChromaKeyWidth, + KeyboardStep = 1, + }, + new SettingsSlider + { + LabelText = "Players per team", + Bindable = LadderInfo.PlayersPerTeam, + KeyboardStep = 1, } } } @@ -122,6 +129,8 @@ namespace osu.Game.Tournament.Screens.Gameplay State.BindTo(ipc.State); State.BindValueChanged(stateChanged, true); + ladder.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); + currentMatch.BindValueChanged(m => { warmup.Value = m.NewValue.Team1Score.Value + m.NewValue.Team2Score.Value == 0; @@ -130,13 +139,18 @@ namespace osu.Game.Tournament.Screens.Gameplay currentMatch.BindTo(ladder.CurrentMatch); - warmup.BindValueChanged(w => warmupButton.Alpha = !w.NewValue ? 0.5f : 1, true); + warmup.BindValueChanged(w => + { + warmupButton.Alpha = !w.NewValue ? 0.5f : 1; + header.ShowScores = !w.NewValue; + }, true); } private ScheduledDelegate scheduledOperation; private MatchScoreDisplay scoreDisplay; private TourneyState lastState; + private MatchHeader header; private void stateChanged(ValueChangedEvent state) { @@ -156,7 +170,7 @@ namespace osu.Game.Tournament.Screens.Gameplay void expand() { - chat?.Expand(); + chat?.Contract(); using (BeginDelayedSequence(300, true)) { @@ -170,7 +184,7 @@ namespace osu.Game.Tournament.Screens.Gameplay SongBar.Expanded = false; scoreDisplay.FadeOut(100); using (chat?.BeginDelayedSequence(500)) - chat?.Contract(); + chat?.Expand(); } switch (state.NewValue) @@ -197,7 +211,7 @@ namespace osu.Game.Tournament.Screens.Gameplay break; default: - chat.Expand(); + chat.Contract(); expand(); break; } @@ -207,5 +221,54 @@ namespace osu.Game.Tournament.Screens.Gameplay lastState = state.NewValue; } } + + private class ChromaArea : CompositeDrawable + { + [Resolved] + private LadderInfo ladder { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + // chroma key area for stable gameplay + Colour = new Color4(0, 255, 0, 255); + + ladder.PlayersPerTeam.BindValueChanged(performLayout, true); + } + + private void performLayout(ValueChangedEvent playerCount) + { + switch (playerCount.NewValue) + { + case 3: + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 0.5f, + }, + }; + break; + + default: + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + }; + break; + } + } + } } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs index 031d6bf3d2..15cb7e44cb 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs @@ -4,6 +4,7 @@ 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.Cursor; @@ -11,7 +12,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -26,23 +26,25 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { private readonly TournamentMatch match; private readonly bool losers; - private OsuSpriteText scoreText; + private TournamentSpriteText scoreText; private Box background; + private Box backgroundRight; private readonly Bindable score = new Bindable(); private readonly BindableBool completed = new BindableBool(); private Color4 colourWinner; - private Color4 colourNormal; private readonly Func isWinner; private LadderEditorScreen ladderEditor; - [Resolved] + [Resolved(canBeNull: true)] private LadderInfo ladderInfo { get; set; } private void setCurrent() { + if (ladderInfo == null) return; + //todo: tournamentgamebase? if (ladderInfo.CurrentMatch.Value != null) ladderInfo.CurrentMatch.Value.Current.Value = false; @@ -61,15 +63,12 @@ namespace osu.Game.Tournament.Screens.Ladder.Components this.losers = losers; Size = new Vector2(150, 40); - Masking = true; - CornerRadius = 5; - Flag.Scale = new Vector2(0.9f); Flag.Anchor = Flag.Origin = Anchor.CentreLeft; AcronymText.Anchor = AcronymText.Origin = Anchor.CentreLeft; AcronymText.Padding = new MarginPadding { Left = 50 }; - AcronymText.Font = OsuFont.GetFont(size: 24); + AcronymText.Font = OsuFont.Torus.With(size: 22, weight: FontWeight.Bold); if (match != null) { @@ -86,8 +85,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { this.ladderEditor = ladderEditor; - colourWinner = losers ? colours.YellowDarker : colours.BlueDarker; - colourNormal = OsuColour.Gray(0.2f); + colourWinner = losers + ? Color4Extensions.FromHex("#8E7F48") + : Color4Extensions.FromHex("#1462AA"); InternalChildren = new Drawable[] { @@ -103,29 +103,28 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { AcronymText, Flag, - new Container + } + }, + new Container + { + Masking = true, + Width = 0.3f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + backgroundRight = new Box { - Masking = true, - CornerRadius = 5, - Width = 0.3f, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, + Colour = OsuColour.Gray(0.1f), + Alpha = 0.8f, RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.1f), - Alpha = 0.8f, - RelativeSizeAxes = Axes.Both, - }, - scoreText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20), - } - } + }, + scoreText = new TournamentSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 22), } } } @@ -182,9 +181,12 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { bool winner = completed.Value && isWinner?.Invoke() == true; - background.FadeColour(winner ? colourWinner : colourNormal, winner ? 500 : 0, Easing.OutQuint); + background.FadeColour(winner ? Color4.White : Color4Extensions.FromHex("#444"), winner ? 500 : 0, Easing.OutQuint); + backgroundRight.FadeColour(winner ? colourWinner : Color4Extensions.FromHex("#333"), winner ? 500 : 0, Easing.OutQuint); - scoreText.Font = AcronymText.Font = OsuFont.GetFont(weight: winner ? FontWeight.Bold : FontWeight.Regular); + AcronymText.Colour = winner ? Color4.Black : Color4.White; + + scoreText.Font = scoreText.Font.With(weight: winner ? FontWeight.Bold : FontWeight.Regular); } public MenuItem[] ContextMenuItems diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index dde280ccd8..655beb4bdd 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components private readonly bool editor; protected readonly FillFlowContainer Flow; private readonly Drawable selectionBox; - private readonly Drawable currentMatchSelectionBox; + protected readonly Drawable CurrentMatchSelectionBox; private Bindable globalSelection; [Resolved(CanBeNull = true)] @@ -45,9 +45,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { selectionBox = new Container { - CornerRadius = 5, - Masking = true, - Scale = new Vector2(1.05f), + Scale = new Vector2(1.1f), RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -55,16 +53,14 @@ namespace osu.Game.Tournament.Screens.Ladder.Components Colour = Color4.YellowGreen, Child = new Box { RelativeSizeAxes = Axes.Both } }, - currentMatchSelectionBox = new Container + CurrentMatchSelectionBox = new Container { - CornerRadius = 5, - Masking = true, - Scale = new Vector2(1.05f), + Scale = new Vector2(1.05f, 1.1f), RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Alpha = 0, - Colour = Color4.OrangeRed, + Colour = Color4.White, Child = new Box { RelativeSizeAxes = Axes.Both } }, Flow = new FillFlowContainer @@ -128,9 +124,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components private void updateCurrentMatch() { if (Match.Current.Value) - currentMatchSelectionBox.Show(); + CurrentMatchSelectionBox.Show(); else - currentMatchSelectionBox.Hide(); + CurrentMatchSelectionBox.Hide(); } private bool selected; @@ -289,16 +285,15 @@ namespace osu.Game.Tournament.Screens.Ladder.Components return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { - if (base.OnDrag(e)) return true; + base.OnDrag(e); Selected = true; this.MoveToOffset(e.Delta); var pos = Position; Match.Position.Value = new Point((int)pos.X, (int)pos.Y); - return true; } public void Remove() diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs index dacd98d3b8..cad0b827c0 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs @@ -6,9 +6,7 @@ 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.Tournament.Models; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Ladder.Components { @@ -22,8 +20,8 @@ namespace osu.Game.Tournament.Screens.Ladder.Components public DrawableTournamentRound(TournamentRound round, bool losers = false) { - OsuSpriteText textName; - OsuSpriteText textDescription; + TournamentSpriteText textName; + TournamentSpriteText textDescription; AutoSizeAxes = Axes.Both; InternalChild = new FillFlowContainer @@ -32,16 +30,16 @@ namespace osu.Game.Tournament.Screens.Ladder.Components AutoSizeAxes = Axes.Both, Children = new Drawable[] { - textDescription = new OsuSpriteText + textDescription = new TournamentSpriteText { - Colour = Color4.Black, + Colour = TournamentGame.TEXT_COLOUR, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre }, - textName = new OsuSpriteText + textName = new TournamentSpriteText { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Colour = Color4.Black, + Font = OsuFont.Torus.With(weight: FontWeight.Bold), + Colour = TournamentGame.TEXT_COLOUR, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre }, diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index 0864d25a2f..4aea7ff4c0 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -2,13 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tournament.Components; @@ -90,8 +90,19 @@ namespace osu.Game.Tournament.Screens.Ladder.Components foreach (var r in rounds.Prepend(new TournamentRound())) add(r); - rounds.ItemsRemoved += items => items.ForEach(i => Control.RemoveDropdownItem(i)); - rounds.ItemsAdded += items => items.ForEach(add); + rounds.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(add); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); + break; + } + }; } private readonly List refBindables = new List(); @@ -114,55 +125,5 @@ namespace osu.Game.Tournament.Screens.Ladder.Components }); } } - - private class SettingsTeamDropdown : LadderSettingsDropdown - { - public SettingsTeamDropdown(BindableList teams) - { - foreach (var t in teams.Prepend(new TournamentTeam())) - add(t); - - teams.ItemsRemoved += items => items.ForEach(i => Control.RemoveDropdownItem(i)); - teams.ItemsAdded += items => items.ForEach(add); - } - - private readonly List refBindables = new List(); - - private T boundReference(T obj) - where T : IBindable - { - obj = (T)obj.GetBoundCopy(); - refBindables.Add(obj); - return obj; - } - - private void add(TournamentTeam team) - { - Control.AddDropdownItem(team); - boundReference(team.FullName).BindValueChanged(_ => - { - Control.RemoveDropdownItem(team); - Control.AddDropdownItem(team); - }); - } - } - - private class LadderSettingsDropdown : SettingsDropdown - { - protected override OsuDropdown CreateDropdown() => new DropdownControl(); - - private new class DropdownControl : SettingsDropdown.DropdownControl - { - protected override DropdownMenu CreateMenu() => new Menu(); - - private new class Menu : OsuDropdownMenu - { - public Menu() - { - MaxHeight = 200; - } - } - } - } } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs new file mode 100644 index 0000000000..347e4d91e0 --- /dev/null +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Tournament.Screens.Ladder.Components +{ + public class LadderSettingsDropdown : SettingsDropdown + { + protected override OsuDropdown CreateDropdown() => new DropdownControl(); + + private new class DropdownControl : SettingsDropdown.DropdownControl + { + protected override DropdownMenu CreateMenu() => new Menu(); + + private new class Menu : OsuDropdownMenu + { + public Menu() + { + MaxHeight = 200; + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs index 84a329085a..cb73985b11 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs @@ -10,8 +10,8 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { public class ProgressionPath : Path { - public DrawableTournamentMatch Source { get; private set; } - public DrawableTournamentMatch Destination { get; private set; } + public DrawableTournamentMatch Source { get; } + public DrawableTournamentMatch Destination { get; } public ProgressionPath(DrawableTournamentMatch source, DrawableTournamentMatch destination) { diff --git a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs new file mode 100644 index 0000000000..a630e51e44 --- /dev/null +++ b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens.Ladder.Components +{ + public class SettingsTeamDropdown : LadderSettingsDropdown + { + public SettingsTeamDropdown(BindableList teams) + { + foreach (var t in teams.Prepend(new TournamentTeam())) + add(t); + + teams.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(add); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); + break; + } + }; + } + + private readonly List refBindables = new List(); + + private T boundReference(T obj) + where T : IBindable + { + obj = (T)obj.GetBoundCopy(); + refBindables.Add(obj); + return obj; + } + + private void add(TournamentTeam team) + { + Control.AddDropdownItem(team); + boundReference(team.FullName).BindValueChanged(_ => + { + Control.RemoveDropdownItem(team); + Control.AddDropdownItem(team); + }); + } + } +} diff --git a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs index 0c450a66b4..fa03518c47 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs @@ -22,10 +22,11 @@ namespace osu.Game.Tournament.Screens.Ladder protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; - protected override bool OnDrag(DragEvent e) + public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + + protected override void OnDrag(DragEvent e) { this.MoveTo(target += e.Delta, 1000, Easing.OutQuint); - return true; } private const float min_scale = 0.6f; diff --git a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs index 66e68a0f37..534c402f6c 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Caching; @@ -31,8 +32,8 @@ namespace osu.Game.Tournament.Screens.Ladder [BackgroundDependencyLoader] private void load(OsuColour colours, Storage storage) { - normalPathColour = colours.BlueDarker.Darken(2); - losersPathColour = colours.YellowDarker.Darken(2); + normalPathColour = Color4Extensions.FromHex("#66D1FF"); + losersPathColour = Color4Extensions.FromHex("#FFC700"); RelativeSizeAxes = Axes.Both; @@ -41,11 +42,17 @@ namespace osu.Game.Tournament.Screens.Ladder RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Side Logo - OWC.m4v")) + new TourneyVideo("ladder") { RelativeSizeAxes = Axes.Both, Loop = true, }, + new DrawableTournamentHeaderText + { + Y = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, ScrollContent = new LadderDragContainer { RelativeSizeAxes = Axes.Both, @@ -68,22 +75,24 @@ namespace osu.Game.Tournament.Screens.Ladder foreach (var match in LadderInfo.Matches) addMatch(match); - LadderInfo.Rounds.ItemsAdded += _ => layout.Invalidate(); - LadderInfo.Rounds.ItemsRemoved += _ => layout.Invalidate(); - - LadderInfo.Matches.ItemsAdded += matches => + LadderInfo.Rounds.CollectionChanged += (_, __) => layout.Invalidate(); + LadderInfo.Matches.CollectionChanged += (_, args) => { - foreach (var p in matches) - addMatch(p); - layout.Invalidate(); - }; - - LadderInfo.Matches.ItemsRemoved += matches => - { - foreach (var p in matches) + switch (args.Action) { - foreach (var d in MatchesContainer.Where(d => d.Match == p)) - d.Expire(); + case NotifyCollectionChangedAction.Add: + foreach (var p in args.NewItems.Cast()) + addMatch(p); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var p in args.OldItems.Cast()) + { + foreach (var d in MatchesContainer.Where(d => d.Match == p)) + d.Expire(); + } + + break; } layout.Invalidate(); diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index c3875716b8..2c4fed8d86 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Beatmaps; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; @@ -43,20 +42,25 @@ namespace osu.Game.Tournament.Screens.MapPool { InternalChildren = new Drawable[] { + new TourneyVideo("mappool") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, new MatchHeader(), mapFlows = new FillFlowContainer> { - Y = 100, + Y = 160, Spacing = new Vector2(10, 10), - Padding = new MarginPadding(25), Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }, new ControlPanel { Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { Text = "Current Mode" }, @@ -91,6 +95,7 @@ namespace osu.Game.Tournament.Screens.MapPool Text = "Reset", Action = reset }, + new ControlPanel.Spacer(), } } }; @@ -207,11 +212,15 @@ namespace osu.Game.Tournament.Screens.MapPool { mapFlows.Clear(); + int totalRows = 0; + if (match.NewValue.Round.Value != null) { FillFlowContainer currentFlow = null; string currentMod = null; + int flowCount = 0; + foreach (var b in match.NewValue.Round.Value.Beatmaps) { if (currentFlow == null || currentMod != b.Mods) @@ -225,15 +234,31 @@ namespace osu.Game.Tournament.Screens.MapPool }); currentMod = b.Mods; + + totalRows++; + flowCount = 0; + } + + if (++flowCount > 2) + { + totalRows++; + flowCount = 1; } currentFlow.Add(new TournamentBeatmapPanel(b.BeatmapInfo, b.Mods) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + Height = 42, }); } } + + mapFlows.Padding = new MarginPadding(5) + { + // remove horizontal padding to increase flow width to 3 panels + Horizontal = totalRows > 9 ? 0 : 100 + }; } } } diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index 4b46264055..88289ad6bd 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Ladder.Components; @@ -19,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Schedule { - public class ScheduleScreen : TournamentScreen, IProvideVideo + public class ScheduleScreen : TournamentScreen // IProvidesVideo { private readonly Bindable currentMatch = new Bindable(); private Container mainContainer; @@ -34,15 +33,68 @@ namespace osu.Game.Tournament.Screens.Schedule InternalChildren = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Side Logo - OWC.m4v")) + new TourneyVideo("schedule") { RelativeSizeAxes = Axes.Both, Loop = true, }, - mainContainer = new Container + new Container { RelativeSizeAxes = Axes.Both, - } + Padding = new MarginPadding(100) { Bottom = 50 }, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DrawableTournamentHeaderText(), + new Container + { + Margin = new MarginPadding { Top = 40 }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.White, + Size = new Vector2(50, 10), + }, + new TournamentSpriteTextWithBackground("Schedule") + { + X = 60, + Scale = new Vector2(0.8f) + } + } + }, + } + }, + }, + new Drawable[] + { + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } + } + } + } + } + }, }; currentMatch.BindValueChanged(matchChanged); @@ -63,7 +115,7 @@ namespace osu.Game.Tournament.Screens.Schedule .SelectMany(m => m.ConditionalMatches.Where(cp => m.Acronyms.TrueForAll(a => cp.Acronyms.Contains(a)))); upcoming = upcoming.Concat(conditionals); - upcoming = upcoming.OrderBy(p => p.Date.Value).Take(12); + upcoming = upcoming.OrderBy(p => p.Date.Value).Take(8); mainContainer.Child = new FillFlowContainer { @@ -74,7 +126,7 @@ namespace osu.Game.Tournament.Screens.Schedule new Container { RelativeSizeAxes = Axes.Both, - Height = 0.65f, + Height = 0.74f, Child = new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -92,7 +144,7 @@ namespace osu.Game.Tournament.Screens.Schedule .Take(8) .Select(p => new ScheduleMatch(p)) }, - new ScheduleContainer("match overview") + new ScheduleContainer("upcoming matches") { RelativeSizeAxes = Axes.Both, Width = 0.6f, @@ -101,26 +153,57 @@ namespace osu.Game.Tournament.Screens.Schedule } } }, - new ScheduleContainer("current match") + new ScheduleContainer("coming up next") { RelativeSizeAxes = Axes.Both, Height = 0.25f, Children = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - Margin = new MarginPadding { Left = -10, Bottom = 10, Top = -5 }, - Spacing = new Vector2(10, 0), - Text = match.NewValue.Round.Value?.Name.Value, - Colour = Color4.Black, - Font = OsuFont.GetFont(size: 20) - }, - new ScheduleMatch(match.NewValue, false), - new OsuSpriteText - { - Text = "Start Time " + match.NewValue.Date.Value.ToUniversalTime().ToString("HH:mm UTC"), - Colour = Color4.Black, - Font = OsuFont.GetFont(size: 20) + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new ScheduleMatch(match.NewValue, false) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TournamentSpriteTextWithBackground(match.NewValue.Round.Value?.Name.Value) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.5f) + }, + new TournamentSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = match.NewValue.Team1.Value?.FullName + " vs " + match.NewValue.Team2.Value?.FullName, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold) + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + new TournamentSpriteText + { + Text = "Starting ", + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + }, + new DrawableDate(match.NewValue.Date.Value) + { + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + } + } + }, + } }, } } @@ -135,6 +218,10 @@ namespace osu.Game.Tournament.Screens.Schedule { Flow.Direction = FillDirection.Horizontal; + Scale = new Vector2(0.8f); + + CurrentMatchSelectionBox.Scale = new Vector2(1.02f, 1.15f); + bool conditional = match is ConditionalTournamentMatch; if (conditional) @@ -146,15 +233,16 @@ namespace osu.Game.Tournament.Screens.Schedule { Anchor = Anchor.TopRight, Origin = Anchor.TopLeft, - Colour = Color4.Black, + Colour = OsuColour.Gray(0.7f), Alpha = conditional ? 0.6f : 1, + Font = OsuFont.Torus, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, }); - AddInternal(new OsuSpriteText + AddInternal(new TournamentSpriteText { Anchor = Anchor.BottomRight, Origin = Anchor.BottomLeft, - Colour = Color4.Black, + Colour = OsuColour.Gray(0.7f), Alpha = conditional ? 0.6f : 1, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, Text = match.Date.Value.ToUniversalTime().ToString("HH:mm UTC") + (conditional ? " (conditional)" : "") @@ -171,29 +259,27 @@ namespace osu.Game.Tournament.Screens.Schedule public ScheduleContainer(string title) { - Padding = new MarginPadding { Left = 30, Top = 30 }; + Padding = new MarginPadding { Left = 60, Top = 10 }; InternalChildren = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - X = 30, - Text = title, - Colour = Color4.Black, - Spacing = new Vector2(10, 0), - Font = OsuFont.GetFont(size: 30) - }, - content = new FillFlowContainer - { - Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.Both, - Margin = new MarginPadding(40) + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TournamentSpriteTextWithBackground(title.ToUpperInvariant()) + { + Scale = new Vector2(0.5f) + }, + content = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding(10) + }, + } }, - new Circle - { - Colour = new Color4(233, 187, 79, 255), - Width = 5, - RelativeSizeAxes = Axes.Y, - } }; } } diff --git a/osu.Game.Tournament/Screens/SetupScreen.cs b/osu.Game.Tournament/Screens/SetupScreen.cs index 8e1481d87c..98bc292901 100644 --- a/osu.Game.Tournament/Screens/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/SetupScreen.cs @@ -3,16 +3,19 @@ using System; using System.Collections.Generic; +using System.Drawing; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; @@ -23,19 +26,30 @@ namespace osu.Game.Tournament.Screens private FillFlowContainer fillFlow; private LoginOverlay loginOverlay; + private ResolutionSelector resolution; [Resolved] private MatchIPCInfo ipc { get; set; } + [Resolved] + private StableInfo stableInfo { get; set; } + [Resolved] private IAPIProvider api { get; set; } [Resolved] private RulesetStore rulesets { get; set; } + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + private Bindable windowSize; + [BackgroundDependencyLoader] - private void load() + private void load(FrameworkConfigManager frameworkConfig) { + windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); + InternalChild = fillFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, @@ -46,27 +60,26 @@ namespace osu.Game.Tournament.Screens }; api.LocalUser.BindValueChanged(_ => Schedule(reload)); + stableInfo.OnStableInfoSaved += () => Schedule(reload); reload(); } + [Resolved] + private Framework.Game game { get; set; } + private void reload() { var fileBasedIpc = ipc as FileBasedIPC; - fillFlow.Children = new Drawable[] { new ActionableInfo { Label = "Current IPC source", - ButtonText = "Refresh", - Action = () => - { - fileBasedIpc?.LocateStableStorage(); - reload(); - }, - Value = fileBasedIpc?.Storage?.GetFullPath(string.Empty) ?? "Not found", - Failing = fileBasedIpc?.Storage == 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, and that it is registered as the default osu! install." + ButtonText = "Change source", + 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." }, new ActionableInfo { @@ -98,9 +111,27 @@ namespace osu.Game.Tournament.Screens Items = rulesets.AvailableRulesets, Current = LadderInfo.Ruleset, }, + resolution = new ResolutionSelector + { + Label = "Stream area resolution", + ButtonText = "Set height", + Action = height => + { + windowSize.Value = new Size((int)(height * aspect_ratio / TournamentSceneManager.STREAM_AREA_WIDTH * TournamentSceneManager.REQUIRED_WIDTH), height); + } + }, }; } + private const float aspect_ratio = 16f / 9f; + + protected override void Update() + { + base.Update(); + + resolution.Value = $"{ScreenSpaceDrawQuad.Width:N0}x{ScreenSpaceDrawQuad.Height:N0}"; + } + public class LabelledDropdown : LabelledComponent, T> { public LabelledDropdown() @@ -147,7 +178,8 @@ namespace osu.Game.Tournament.Screens public Action Action; - private OsuSpriteText valueText; + private TournamentSpriteText valueText; + protected FillFlowContainer FlowContainer; protected override Drawable CreateComponent() => new Container { @@ -155,20 +187,72 @@ namespace osu.Game.Tournament.Screens RelativeSizeAxes = Axes.X, Children = new Drawable[] { - valueText = new OsuSpriteText + valueText = new TournamentSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - button = new TriangleButton + FlowContainer = new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Size = new Vector2(100, 30), - Action = () => Action?.Invoke() - }, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + button = new TriangleButton + { + Size = new Vector2(100, 40), + Action = () => Action?.Invoke() + } + } + } } }; } + + private class ResolutionSelector : ActionableInfo + { + private const int minimum_window_height = 480; + private const int maximum_window_height = 2160; + + public new Action Action; + + private OsuNumberBox numberBox; + + protected override Drawable CreateComponent() + { + var drawable = base.CreateComponent(); + FlowContainer.Insert(-1, numberBox = new OsuNumberBox + { + Text = "1080", + Width = 100 + }); + + base.Action = () => + { + if (string.IsNullOrEmpty(numberBox.Text)) + return; + + // box contains text + if (!int.TryParse(numberBox.Text, out var number)) + { + // at this point, the only reason we can arrive here is if the input number was too big to parse into an int + // so clamp to max allowed value + number = maximum_window_height; + } + else + { + number = Math.Clamp(number, minimum_window_height, maximum_window_height); + } + + // in case number got clamped, reset number in numberBox + numberBox.Text = number.ToString(); + + Action?.Invoke(number); + }; + return drawable; + } + } } } diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 20928499bf..9785b7e647 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -2,15 +2,42 @@ // 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.Tournament.Components; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase { - public class ShowcaseScreen : BeatmapInfoScreen + public class ShowcaseScreen : BeatmapInfoScreen // IProvideVideo { [BackgroundDependencyLoader] private void load() { - AddInternal(new TournamentLogo(false)); + AddRangeInternal(new Drawable[] + { + new TournamentLogo(), + new TourneyVideo("showcase") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Padding = new MarginPadding { Bottom = SongBar.HEIGHT }, + RelativeSizeAxes = Axes.Both, + Child = new Box + { + // chroma key area for stable gameplay + Name = "chroma", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 255, 0, 255), + } + } + }); } } } diff --git a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs index 1fee2b29e8..bd5aa2f5d9 100644 --- a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs +++ b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs @@ -11,20 +11,12 @@ namespace osu.Game.Tournament.Screens.Showcase { public class TournamentLogo : CompositeDrawable { - public TournamentLogo(bool includeRoundBackground = true) + public TournamentLogo() { RelativeSizeAxes = Axes.X; Margin = new MarginPadding { Vertical = 5 }; - if (includeRoundBackground) - { - AutoSizeAxes = Axes.Y; - } - else - { - Masking = true; - Height = 100; - } + Height = 100; } [BackgroundDependencyLoader] @@ -32,9 +24,11 @@ namespace osu.Game.Tournament.Screens.Showcase { InternalChild = new Sprite { - Texture = textures.Get("game-screen-logo"), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + FillMode = FillMode.Fit, + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("header-logo"), }; } } diff --git a/osu.Game.Tournament/Screens/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs new file mode 100644 index 0000000000..b4d56f60c7 --- /dev/null +++ b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs @@ -0,0 +1,164 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Components; +using osuTK; + +namespace osu.Game.Tournament.Screens +{ + public class StablePathSelectScreen : TournamentScreen + { + [Resolved] + private GameHost host { get; set; } + + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + [Resolved] + private MatchIPCInfo ipc { get; set; } + + private DirectorySelector directorySelector; + private DialogOverlay overlay; + + [BackgroundDependencyLoader(true)] + private void load(Storage storage, OsuColour colours) + { + var initialStorage = (ipc as FileBasedIPC)?.IPCStorage ?? storage; + var initialPath = new DirectoryInfo(initialStorage.GetFullPath(string.Empty)).Parent?.FullName; + + AddRangeInternal(new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.5f, 0.8f), + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.8f), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please select a new location", + Font = OsuFont.Default.With(size: 40) + }, + }, + new Drawable[] + { + directorySelector = new DirectorySelector(initialPath) + { + RelativeSizeAxes = Axes.Both, + } + }, + new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Select stable path", + Action = ChangePath + }, + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Auto detect", + Action = AutoDetect + }, + } + } + } + } + } + }, + }, + new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => sceneManager?.SetScreen(typeof(SetupScreen)) + } + }); + } + + protected virtual void ChangePath() + { + var target = directorySelector.CurrentDirectory.Value.FullName; + var fileBasedIpc = ipc as FileBasedIPC; + Logger.Log($"Changing Stable CE location to {target}"); + + if (!fileBasedIpc?.SetIPCLocation(target) ?? true) + { + overlay = new DialogOverlay(); + overlay.Push(new IPCErrorDialog("This is an invalid IPC Directory", "Select a directory that contains an osu! stable cutting edge installation and make sure it has an empty ipc.txt file in it.")); + AddInternal(overlay); + Logger.Log("Folder is not an osu! stable CE directory"); + return; + } + + sceneManager?.SetScreen(typeof(SetupScreen)); + } + + protected virtual void AutoDetect() + { + var fileBasedIpc = ipc as FileBasedIPC; + + if (!fileBasedIpc?.AutoDetectIPCLocation() ?? true) + { + overlay = new DialogOverlay(); + overlay.Push(new IPCErrorDialog("Failed to auto detect", "An osu! stable cutting-edge installation could not be auto detected.\nPlease try and manually point to the directory.")); + AddInternal(overlay); + } + else + { + sceneManager?.SetScreen(typeof(SetupScreen)); + } + } + } +} diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs new file mode 100644 index 0000000000..eed3cac9f0 --- /dev/null +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Ladder.Components; +using osuTK; + +namespace osu.Game.Tournament.Screens.TeamIntro +{ + public class SeedingScreen : TournamentScreen, IProvideVideo + { + private Container mainContainer; + + private readonly Bindable currentMatch = new Bindable(); + + private readonly Bindable currentTeam = new Bindable(); + + [BackgroundDependencyLoader] + private void load(Storage storage) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new TourneyVideo("seeding") + { + RelativeSizeAxes = Axes.Both, + Loop = true, + }, + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + new ControlPanel + { + Children = new Drawable[] + { + new TourneyButton + { + RelativeSizeAxes = Axes.X, + Text = "Show first team", + Action = () => currentTeam.Value = currentMatch.Value.Team1.Value, + }, + new TourneyButton + { + RelativeSizeAxes = Axes.X, + Text = "Show second team", + Action = () => currentTeam.Value = currentMatch.Value.Team2.Value, + }, + new SettingsTeamDropdown(LadderInfo.Teams) + { + LabelText = "Show specific team", + Bindable = currentTeam, + } + } + } + }; + + currentMatch.BindValueChanged(matchChanged); + currentMatch.BindTo(LadderInfo.CurrentMatch); + + currentTeam.BindValueChanged(teamChanged, true); + } + + private void teamChanged(ValueChangedEvent team) + { + if (team.NewValue == null) + { + mainContainer.Clear(); + return; + } + + showTeam(team.NewValue); + } + + private void matchChanged(ValueChangedEvent match) => + currentTeam.Value = currentMatch.Value.Team1.Value; + + private void showTeam(TournamentTeam team) + { + mainContainer.Children = new Drawable[] + { + new LeftInfo(team) { Position = new Vector2(55, 150), }, + new RightInfo(team) { Position = new Vector2(500, 150), }, + }; + } + + private class RightInfo : CompositeDrawable + { + public RightInfo(TournamentTeam team) + { + FillFlowContainer fill; + + Width = 400; + + InternalChildren = new Drawable[] + { + fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + }; + + foreach (var seeding in team.SeedingResults) + { + fill.Add(new ModRow(seeding.Mod.Value, seeding.Seed.Value)); + foreach (var beatmap in seeding.Beatmaps) + fill.Add(new BeatmapScoreRow(beatmap)); + } + } + + private class BeatmapScoreRow : CompositeDrawable + { + public BeatmapScoreRow(SeedingBeatmap beatmap) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new TournamentSpriteText { Text = beatmap.BeatmapInfo.Metadata.Title, Colour = TournamentGame.TEXT_COLOUR, }, + new TournamentSpriteText { Text = "by", Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + new TournamentSpriteText { Text = beatmap.BeatmapInfo.Metadata.Artist, Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(40), + 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) }, + } + }, + }; + } + } + + private class ModRow : CompositeDrawable + { + private readonly string mods; + private readonly int seeding; + + public ModRow(string mods, int seeding) + { + this.mods = mods; + this.seeding = seeding; + + Padding = new MarginPadding { Vertical = 10 }; + + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new Sprite + { + Texture = textures.Get($"mods/{mods.ToLower()}"), + Scale = new Vector2(0.5f) + }, + new Container + { + Size = new Vector2(50, 16), + CornerRadius = 10, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, + }, + new TournamentSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = seeding.ToString("#,0"), + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR + }, + } + }, + } + }, + }; + } + } + } + + private class LeftInfo : CompositeDrawable + { + public LeftInfo(TournamentTeam team) + { + FillFlowContainer fill; + + Width = 200; + + if (team == null) return; + + InternalChildren = new Drawable[] + { + fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TeamDisplay(team) { Margin = new MarginPadding { Bottom = 30 } }, + new RowDisplay("Average Rank:", $"#{team.AverageRank:#,0}"), + new RowDisplay("Seed:", team.Seed.Value), + new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "0"), + new Container { Margin = new MarginPadding { Bottom = 30 } }, + } + }, + }; + + foreach (var p in team.Players) + fill.Add(new RowDisplay(p.Username, p.Statistics?.Ranks.Global?.ToString("\\##,0") ?? "-")); + } + + internal class RowDisplay : CompositeDrawable + { + public RowDisplay(string left, string right) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new TournamentSpriteText + { + Text = left, + Colour = TournamentGame.TEXT_COLOUR, + Font = OsuFont.Torus.With(size: 22, weight: FontWeight.SemiBold), + }, + new TournamentSpriteText + { + Text = right, + Colour = TournamentGame.TEXT_COLOUR, + Anchor = Anchor.TopRight, + Origin = Anchor.TopLeft, + Font = OsuFont.Torus.With(size: 22, weight: FontWeight.Regular), + }, + }; + } + } + + private class TeamDisplay : DrawableTournamentTeam + { + public TeamDisplay(TournamentTeam team) + : base(team) + { + AutoSizeAxes = Axes.Both; + + Flag.RelativeSizeAxes = Axes.None; + Flag.Size = new Vector2(300, 200); + Flag.Scale = new Vector2(0.3f); + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + Flag, + new OsuSpriteText + { + Text = team?.FullName.Value ?? "???", + Font = OsuFont.Torus.With(size: 32, weight: FontWeight.SemiBold), + Colour = TournamentGame.TEXT_COLOUR, + }, + } + }; + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index 47c923ff30..6c2848897b 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -6,13 +6,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.TeamIntro { @@ -29,12 +25,11 @@ namespace osu.Game.Tournament.Screens.TeamIntro InternalChildren = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Team - Both OWC.m4v")) + new TourneyVideo("teamintro") { RelativeSizeAxes = Axes.Both, Loop = true, }, - new TournamentLogo(false), mainContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -53,171 +48,33 @@ namespace osu.Game.Tournament.Screens.TeamIntro return; } + const float y_flag_offset = 292; + + const float y_offset = 460; + mainContainer.Children = new Drawable[] { - new TeamWithPlayers(match.NewValue.Team1.Value, true) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight - }, - new TeamWithPlayers(match.NewValue.Team2.Value) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft - }, new RoundDisplay(match.NewValue) { - RelativeSizeAxes = Axes.Both, - Height = 0.25f, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - } + Position = new Vector2(100, 100) + }, + new DrawableTeamFlag(match.NewValue.Team1.Value) + { + Position = new Vector2(165, y_flag_offset), + }, + new DrawableTeamWithPlayers(match.NewValue.Team1.Value, TeamColour.Red) + { + Position = new Vector2(165, y_offset), + }, + new DrawableTeamFlag(match.NewValue.Team2.Value) + { + Position = new Vector2(740, y_flag_offset), + }, + new DrawableTeamWithPlayers(match.NewValue.Team2.Value, TeamColour.Blue) + { + Position = new Vector2(740, y_offset), + }, }; } - - private class RoundDisplay : CompositeDrawable - { - public RoundDisplay(TournamentMatch match) - { - var col = OsuColour.Gray(0.33f); - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = "COMING UP NEXT", - Spacing = new Vector2(2, 0), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Black) - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Round.Value?.Name.Value ?? "Unknown Round", - Spacing = new Vector2(10, 0), - Font = OsuFont.GetFont(size: 50, weight: FontWeight.Light) - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Date.Value.ToUniversalTime().ToString("dd MMMM HH:mm UTC"), - Font = OsuFont.GetFont(size: 20) - }, - } - } - }; - } - } - - private class TeamWithPlayers : CompositeDrawable - { - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - public TeamWithPlayers(TournamentTeam team, bool left = false) - { - FillFlowContainer players; - var colour = left ? red : blue; - InternalChildren = new Drawable[] - { - new TeamDisplay(team, left ? "Team Red" : "Team Blue", colour) - { - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Both, - X = (left ? -1 : 1) * 0.36f, - }, - players = new FillFlowContainer - { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Padding = new MarginPadding(20), - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = left ? Anchor.CentreRight : Anchor.CentreLeft, - RelativePositionAxes = Axes.Both, - X = (left ? -1 : 1) * 0.66f, - }, - }; - - if (team != null) - { - foreach (var p in team.Players) - { - players.Add(new OsuSpriteText - { - Text = p.Username, - Font = OsuFont.GetFont(size: 24), - Colour = colour, - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = left ? Anchor.CentreRight : Anchor.CentreLeft, - }); - } - } - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, string teamName, Color4 colour) - : base(team) - { - AutoSizeAxes = Axes.Both; - - Flag.Anchor = Flag.Origin = Anchor.TopCentre; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(300, 200); - Flag.Scale = new Vector2(0.4f); - Flag.Margin = new MarginPadding { Bottom = 20 }; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 40, FontWeight.Light), - Colour = Color4.Black, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = teamName.ToUpper(), - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 20, FontWeight.Regular), - Colour = colour, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - } - } - }; - } - } - } } } diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index a0216c5db3..3870f486e1 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -7,12 +7,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.TeamWin { @@ -33,22 +30,18 @@ namespace osu.Game.Tournament.Screens.TeamWin InternalChildren = new Drawable[] { - blueWinVideo = new TourneyVideo(storage.GetStream(@"BG Team - Win Blue.m4v")) + blueWinVideo = new TourneyVideo("teamwin-blue") { Alpha = 1, RelativeSizeAxes = Axes.Both, Loop = true, }, - redWinVideo = new TourneyVideo(storage.GetStream(@"BG Team - Win Red.m4v")) + redWinVideo = new TourneyVideo("teamwin-red") { Alpha = 0, RelativeSizeAxes = Axes.Both, Loop = true, }, - new TournamentLogo(false) - { - Y = 40, - }, mainContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -69,7 +62,9 @@ namespace osu.Game.Tournament.Screens.TeamWin update(); } - private void update() + private bool firstDisplay = true; + + private void update() => Schedule(() => { var match = currentMatch.Value; @@ -79,147 +74,53 @@ namespace osu.Game.Tournament.Screens.TeamWin return; } - bool redWin = match.Winner == match.Team1.Value; - redWinVideo.Alpha = redWin ? 1 : 0; - blueWinVideo.Alpha = redWin ? 0 : 1; + redWinVideo.Alpha = match.WinnerColour == TeamColour.Red ? 1 : 0; + blueWinVideo.Alpha = match.WinnerColour == TeamColour.Blue ? 1 : 0; + + if (firstDisplay) + { + if (match.WinnerColour == TeamColour.Red) + redWinVideo.Reset(); + else + blueWinVideo.Reset(); + firstDisplay = false; + } mainContainer.Children = new Drawable[] { - new TeamWithPlayers(match.Winner, redWin) + new DrawableTeamFlag(match.Winner) { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, + Size = new Vector2(300, 200), + Scale = new Vector2(0.5f), Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + Position = new Vector2(-300, 10), }, - new RoundDisplay(match) + new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Height = 0.25f, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - } - }; - } - - private class RoundDisplay : CompositeDrawable - { - public RoundDisplay(TournamentMatch match) - { - var col = OsuColour.Gray(0.33f); - - InternalChildren = new Drawable[] - { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = 260, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = new Drawable[] + new RoundDisplay(match) { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = "WINNER", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 15, FontWeight.Regular), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Round.Value?.Name.Value ?? "Unknown Round", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 50, FontWeight.Light), - Spacing = new Vector2(10, 0), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Date.Value.ToUniversalTime().ToString("dd MMMM HH:mm UTC"), - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 20, FontWeight.Light), - }, - } + Margin = new MarginPadding { Bottom = 30 }, + }, + new TournamentSpriteText + { + Text = "WINNER", + Font = OsuFont.Torus.With(size: 100, weight: FontWeight.Bold), + Margin = new MarginPadding { Bottom = 50 }, + }, + new DrawableTeamWithPlayers(match.Winner, match.WinnerColour) } - }; - } - } - - private class TeamWithPlayers : CompositeDrawable - { - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - public TeamWithPlayers(TournamentTeam team, bool left = false) - { - var colour = left ? red : blue; - InternalChildren = new Drawable[] - { - new TeamDisplay(team, left ? "Team Red" : "Team Blue", colour) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new FillFlowContainer - { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Padding = new MarginPadding(20), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Both, - }, - }; - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, string teamName, Color4 colour) - : base(team) - { - AutoSizeAxes = Axes.Both; - - Flag.Anchor = Flag.Origin = Anchor.TopCentre; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(300, 200); - Flag.Scale = new Vector2(0.4f); - Flag.Margin = new MarginPadding { Bottom = 20 }; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 40, FontWeight.Light), - Colour = Color4.Black, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = teamName.ToUpper(), - Font = OsuFont.GetFont(size: 20), - Colour = colour, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - } - } - }; - } - } - } + }, + }; + mainContainer.FadeOut(); + mainContainer.Delay(2000).FadeIn(1600, Easing.OutQuint); + }); } } diff --git a/osu.Game.Tournament/Screens/TournamentScreen.cs b/osu.Game.Tournament/Screens/TournamentScreen.cs index 0b5b3e728b..5da7c7a5d2 100644 --- a/osu.Game.Tournament/Screens/TournamentScreen.cs +++ b/osu.Game.Tournament/Screens/TournamentScreen.cs @@ -18,6 +18,9 @@ namespace osu.Game.Tournament.Screens protected TournamentScreen() { RelativeSizeAxes = Axes.Both; + + FillMode = FillMode.Fit; + FillAspectRatio = 16 / 9f; } public override void Hide() => this.FadeOut(FADE_DELAY); diff --git a/osu.Game.Tournament/TournamentFont.cs b/osu.Game.Tournament/TournamentFont.cs deleted file mode 100644 index f9e60ff2bc..0000000000 --- a/osu.Game.Tournament/TournamentFont.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; - -namespace osu.Game.Tournament -{ - public static class TournamentFont - { - /// - /// The default font size. - /// - public const float DEFAULT_FONT_SIZE = 16; - - /// - /// Retrieves a . - /// - /// The font typeface. - /// The size of the text in local space. For a value of 16, a single line will have a height of 16px. - /// The font weight. - /// Whether the font is italic. - /// Whether all characters should be spaced the same distance apart. - /// The . - public static FontUsage GetFont(TournamentTypeface typeface = TournamentTypeface.Aquatico, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), italics, fixedWidth); - - /// - /// Retrieves the string representation of a . - /// - /// The . - /// The string representation. - public static string GetFamilyString(TournamentTypeface typeface) - { - switch (typeface) - { - case TournamentTypeface.Aquatico: - return "Aquatico"; - } - - return null; - } - - /// - /// Retrieves the string representation of a . - /// - /// The . - /// The . - /// The string representation of in the specified . - public static string GetWeightString(TournamentTypeface typeface, FontWeight weight) - => GetWeightString(GetFamilyString(typeface), weight); - - /// - /// 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) - { - string weightString = weight.ToString(); - - // Only exo has an explicit "regular" weight, other fonts do not - if (weight == FontWeight.Regular && family != GetFamilyString(TournamentTypeface.Aquatico) && family != GetFamilyString(TournamentTypeface.Aquatico)) - weightString = string.Empty; - - return weightString; - } - } - - public enum TournamentTypeface - { - Aquatico - } -} diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 7dbcf37af6..7b1a174c1e 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -1,25 +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.Drawing; +using osu.Framework.Extensions.Color4Extensions; +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.Framework.Graphics.Colour; using osu.Game.Graphics.Cursor; +using osu.Game.Tournament.Models; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Tournament { public class TournamentGame : TournamentGameBase { - protected override void LoadComplete() + public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE; + + public static readonly Color4 COLOUR_RED = Color4Extensions.FromHex("#AA1414"); + public static readonly Color4 COLOUR_BLUE = Color4Extensions.FromHex("#1462AA"); + + public static readonly Color4 ELEMENT_BACKGROUND_COLOUR = Color4Extensions.FromHex("#fff"); + public static readonly Color4 ELEMENT_FOREGROUND_COLOUR = Color4Extensions.FromHex("#000"); + + public static readonly Color4 TEXT_COLOUR = Color4Extensions.FromHex("#fff"); + private Drawable heightWarning; + private Bindable windowSize; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager frameworkConfig) { - base.LoadComplete(); - - Add(new OsuContextMenuContainer + windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); + windowSize.BindValueChanged(size => ScheduleAfterChildren(() => { - RelativeSizeAxes = Axes.Both, - Child = new TournamentSceneManager() - }); + var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; - // we don't want to show the menu cursor as it would appear on stream output. - MenuCursorContainer.Cursor.Alpha = 0; + heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; + }), true); + + AddRange(new[] + { + new Container + { + 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 Container + { + Masking = true, + CornerRadius = 5, + Depth = float.MinValue, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Red, + RelativeSizeAxes = Axes.Both, + }, + new TournamentSpriteText + { + Text = "Please make the window wider", + Font = OsuFont.Torus.With(weight: FontWeight.Bold), + Colour = Color4.White, + Padding = new MarginPadding(20) + } + } + }, + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new TournamentSceneManager() + } + }); } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 4d7abfe272..5fc1d03f6d 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -2,34 +2,25 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Drawing; using System.IO; using System.Linq; using Newtonsoft.Json; 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.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; using osu.Game.Users; -using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tournament { [Cached(typeof(TournamentGameBase))] - public abstract class TournamentGameBase : OsuGameBase + public class TournamentGameBase : OsuGameBase { private const string bracket_filename = "bracket.json"; @@ -37,83 +28,35 @@ namespace osu.Game.Tournament private Storage storage; + private TournamentStorage tournamentStorage; + private DependencyContainer dependencies; - - private Bindable windowSize; private FileBasedIPC ipc; - private Drawable heightWarning; - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { return dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); } [BackgroundDependencyLoader] - private void load(Storage storage, FrameworkConfigManager frameworkConfig) + private void load(Storage storage) { - Resources.AddStore(new DllResourceStore(@"osu.Game.Tournament.dll")); + Resources.AddStore(new DllResourceStore(typeof(TournamentGameBase).Assembly)); - AddFont(Resources, @"Resources/Fonts/Aquatico-Regular"); - AddFont(Resources, @"Resources/Fonts/Aquatico-Light"); + dependencies.CacheAs(tournamentStorage = new TournamentStorage(storage)); - Textures.AddStore(new TextureLoaderStore(new ResourceStore(new StorageBackedResourceStore(storage)))); + Textures.AddStore(new TextureLoaderStore(tournamentStorage)); this.storage = storage; - windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); - windowSize.BindValueChanged(size => ScheduleAfterChildren(() => - { - var minWidth = (int)(size.NewValue.Height / 9f * 16 + 400); - - heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; - }), true); - readBracket(); ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + dependencies.CacheAs(new StableInfo(storage)); + dependencies.CacheAs(ipc = new FileBasedIPC()); Add(ipc); - - AddRange(new[] - { - new TourneyButton - { - Text = "Save Changes", - Width = 140, - Height = 50, - Depth = float.MinValue, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Padding = new MarginPadding(10), - Action = SaveChanges, - }, - heightWarning = new Container - { - Masking = true, - CornerRadius = 5, - Depth = float.MinValue, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.Red, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Please make the window wider", - Font = OsuFont.Default.With(weight: "bold"), - Colour = Color4.White, - Padding = new MarginPadding(20) - } - } - }, - }); } private void readBracket() @@ -124,13 +67,9 @@ namespace osu.Game.Tournament using (var sr = new StreamReader(stream)) ladder = JsonConvert.DeserializeObject(sr.ReadToEnd()); } - else - { - ladder = new LadderInfo(); - } - if (ladder.Ruleset.Value == null) - ladder.Ruleset.Value = RulesetStore.AvailableRulesets.First(); + ladder ??= new LadderInfo(); + ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); Ruleset.BindTo(ladder.Ruleset); @@ -205,9 +144,11 @@ namespace osu.Game.Tournament { foreach (var p in t.Players) { - if (p.Username == null || p.Statistics == null) + if (string.IsNullOrEmpty(p.Username) || p.Statistics == null) + { PopulateUser(p); - addedInfo = true; + addedInfo = true; + } } } @@ -223,9 +164,12 @@ namespace osu.Game.Tournament foreach (var r in ladder.Rounds) { - foreach (var b in r.Beatmaps) + foreach (var b in r.Beatmaps.ToList()) { - if (b.BeatmapInfo == null && b.ID > 0) + if (b.BeatmapInfo != null) + continue; + + if (b.ID > 0) { var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); API.Perform(req); @@ -233,6 +177,28 @@ namespace osu.Game.Tournament addedInfo = true; } + + if (b.BeatmapInfo == null) + // if online population couldn't be performed, ensure we don't leave a null value behind + r.Beatmaps.Remove(b); + } + } + + foreach (var t in ladder.Teams) + { + foreach (var s in t.SeedingResults) + { + foreach (var b in s.Beatmaps) + { + if (b.BeatmapInfo == null && b.ID > 0) + { + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); + req.Perform(API); + b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + + addedInfo = true; + } + } } } @@ -265,6 +231,8 @@ namespace osu.Game.Tournament protected override void LoadComplete() { MenuCursorContainer.Cursor.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; base.LoadComplete(); @@ -296,7 +264,7 @@ namespace osu.Game.Tournament private class TournamentInputManager : UserInputManager { - protected override MouseButtonEventManager CreateButtonManagerFor(MouseButton button) + protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { switch (button) { @@ -304,7 +272,7 @@ namespace osu.Game.Tournament return new RightMouseManager(button); } - return base.CreateButtonManagerFor(button); + return base.CreateButtonEventManagerFor(button); } private class RightMouseManager : MouseButtonEventManager diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index de3d685c31..2c539cdd43 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -33,6 +33,12 @@ namespace osu.Game.Tournament private Container screens; private TourneyVideo video; + public const float CONTROL_AREA_WIDTH = 160; + + public const float STREAM_AREA_WIDTH = 1366; + + public const double REQUIRED_WIDTH = CONTROL_AREA_WIDTH * 2 + STREAM_AREA_WIDTH; + [Cached] private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay(); @@ -51,17 +57,17 @@ namespace osu.Game.Tournament { new Container { - RelativeSizeAxes = Axes.Both, - X = 200, + RelativeSizeAxes = Axes.Y, + X = CONTROL_AREA_WIDTH, FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f, Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, - Size = new Vector2(0.8f, 1), + Width = STREAM_AREA_WIDTH, //Masking = true, Children = new Drawable[] { - video = new TourneyVideo(storage.GetStream("BG Logoless - OWC.m4v")) + video = new TourneyVideo("main", true) { Loop = true, RelativeSizeAxes = Axes.Both, @@ -80,6 +86,7 @@ namespace osu.Game.Tournament new ShowcaseScreen(), new MapPoolScreen(), new TeamIntroScreen(), + new SeedingScreen(), new DrawingsScreen(), new GameplayScreen(), new TeamWinScreen() @@ -95,7 +102,7 @@ namespace osu.Game.Tournament new Container { RelativeSizeAxes = Axes.Y, - Width = 200, + Width = CONTROL_AREA_WIDTH, Children = new Drawable[] { new Box @@ -107,8 +114,8 @@ namespace osu.Game.Tournament { RelativeSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Spacing = new Vector2(2), - Padding = new MarginPadding(2), + Spacing = new Vector2(5), + Padding = new MarginPadding(5), Children = new Drawable[] { new ScreenButton(typeof(SetupScreen)) { Text = "Setup", RequestSelection = SetScreen }, @@ -121,6 +128,7 @@ namespace osu.Game.Tournament new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen }, new Separator(), new ScreenButton(typeof(TeamIntroScreen)) { Text = "TeamIntro", RequestSelection = SetScreen }, + new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen }, new Separator(), new ScreenButton(typeof(MapPoolScreen)) { Text = "MapPool", RequestSelection = SetScreen }, new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen }, @@ -146,8 +154,20 @@ namespace osu.Game.Tournament private Drawable currentScreen; private ScheduledDelegate scheduledHide; + private Drawable temporaryScreen; + + public void SetScreen(Drawable screen) + { + currentScreen?.Hide(); + currentScreen = null; + + screens.Add(temporaryScreen = screen); + } + public void SetScreen(Type screenType) { + temporaryScreen?.Expire(); + var target = screens.FirstOrDefault(s => s.GetType() == screenType); if (target == null || currentScreen == target) return; @@ -180,9 +200,14 @@ namespace osu.Game.Tournament switch (currentScreen) { - case GameplayScreen _: case MapPoolScreen _: chatContainer.FadeIn(TournamentScreen.FADE_DELAY); + chatContainer.ResizeWidthTo(1, 500, Easing.OutQuint); + break; + + case GameplayScreen _: + chatContainer.FadeIn(TournamentScreen.FADE_DELAY); + chatContainer.ResizeWidthTo(0.5f, 500, Easing.OutQuint); break; default: diff --git a/osu.Game.Tournament/TournamentSpriteText.cs b/osu.Game.Tournament/TournamentSpriteText.cs new file mode 100644 index 0000000000..e550dfbfae --- /dev/null +++ b/osu.Game.Tournament/TournamentSpriteText.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.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tournament +{ + public class TournamentSpriteText : OsuSpriteText + { + public TournamentSpriteText() + { + Font = OsuFont.Torus; + } + } +} diff --git a/osu.Game.Tournament/TournamentStorage.cs b/osu.Game.Tournament/TournamentStorage.cs new file mode 100644 index 0000000000..139ad3857b --- /dev/null +++ b/osu.Game.Tournament/TournamentStorage.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.IO.Stores; +using osu.Framework.Platform; + +namespace osu.Game.Tournament +{ + internal class TournamentStorage : NamespacedResourceStore + { + public TournamentStorage(Storage storage) + : base(new StorageBackedResourceStore(storage), "tournament") + { + AddExtension("m4v"); + AddExtension("avi"); + AddExtension("mp4"); + } + } +} diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 6f0b62543d..862be41c1a 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -19,13 +19,15 @@ namespace osu.Game.Audio { private readonly BindableDouble muteBindable = new BindableDouble(); - private AudioManager audio; + [Resolved] + private AudioManager audio { get; set; } + private PreviewTrackStore trackStore; protected TrackManagerPreviewTrack CurrentTrack; [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { // this is a temporary solution to get around muting ourselves. // todo: update this once we have a BackgroundTrackManager or similar. @@ -33,8 +35,6 @@ namespace osu.Game.Audio audio.AddItem(trackStore); trackStore.AddAdjustment(AdjustableProperty.Volume, audio.VolumeTrack); - - this.audio = audio; } /// @@ -90,6 +90,7 @@ namespace osu.Game.Audio public class TrackManagerPreviewTrack : PreviewTrack { + [Resolved] public IPreviewTrackOwner Owner { get; private set; } private readonly BeatmapSetInfo beatmapSetInfo; @@ -101,12 +102,6 @@ namespace osu.Game.Audio this.trackManager = trackManager; } - [BackgroundDependencyLoader] - private void load(IPreviewTrackOwner owner) - { - Owner = owner; - } - protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo?.OnlineBeatmapSetID}.mp3"); } diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 66c07209f3..2406b0bef2 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.cs @@ -19,6 +19,6 @@ namespace osu.Game.Audio public IEnumerable LookupNames => new[] { sampleName }; - public int Volume { get; set; } = 100; + public int Volume { get; } = 100; } } diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 99e0bf4e33..11fee030f8 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -57,7 +57,7 @@ namespace osu.Game.Beatmaps beatmap.BeatmapInfo = original.BeatmapInfo; beatmap.ControlPointInfo = original.ControlPointInfo; - beatmap.HitObjects = convertHitObjects(original.HitObjects, original); + beatmap.HitObjects = convertHitObjects(original.HitObjects, original).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; return beatmap; diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index bcc9ab885e..3860f12baa 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -51,6 +51,9 @@ namespace osu.Game.Beatmaps [NotMapped] public BeatmapOnlineInfo OnlineInfo { get; set; } + [NotMapped] + public int? MaxCombo { get; set; } + /// /// The playable length in milliseconds of this beatmap. /// @@ -146,7 +149,17 @@ namespace osu.Game.Beatmaps } } - public override string ToString() => $"{Metadata} [{Version}]".Trim(); + public string[] SearchableTerms => new[] + { + Version + }.Concat(Metadata?.SearchableTerms ?? Enumerable.Empty()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); + + public override string ToString() + { + string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]"; + + return $"{Metadata} {version}".Trim(); + } public bool Equals(BeatmapInfo other) { diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index a10ad73817..b4b341634c 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -6,18 +6,18 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Threading; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.IO; @@ -26,23 +26,28 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; namespace osu.Game.Beatmaps { /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// - public partial class BeatmapManager : DownloadableArchiveModelManager + public partial class BeatmapManager : DownloadableArchiveModelManager, IDisposable { /// /// Fired when a single difficulty has been hidden. /// - public event Action BeatmapHidden; + public IBindable> BeatmapHidden => beatmapHidden; + + private readonly Bindable> beatmapHidden = new Bindable>(); /// /// Fired when a single difficulty has been restored. /// - public event Action BeatmapRestored; + public IBindable> BeatmapRestored => beatmapRestored; + + private readonly Bindable> beatmapRestored = new Bindable>(); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. @@ -56,14 +61,10 @@ namespace osu.Game.Beatmaps protected override string ImportFromStablePath => "Songs"; private readonly RulesetStore rulesets; - private readonly BeatmapStore beatmaps; - private readonly AudioManager audioManager; - private readonly GameHost host; - - private readonly BeatmapUpdateQueue updateQueue; + private readonly BeatmapOnlineLookupQueue onlineLookupQueue; public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, AudioManager audioManager, GameHost host = null, WorkingBeatmap defaultBeatmap = null) @@ -76,10 +77,12 @@ namespace osu.Game.Beatmaps DefaultBeatmap = defaultBeatmap; beatmaps = (BeatmapStore)ModelStore; - beatmaps.BeatmapHidden += b => BeatmapHidden?.Invoke(b); - beatmaps.BeatmapRestored += b => BeatmapRestored?.Invoke(b); + beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b); + beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b); + beatmaps.ItemRemoved += removeWorkingCache; + beatmaps.ItemUpdated += removeWorkingCache; - updateQueue = new BeatmapUpdateQueue(api); + onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage); } protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => @@ -87,7 +90,7 @@ namespace osu.Game.Beatmaps protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; - protected override Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) + protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) { if (archive != null) beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files); @@ -103,7 +106,19 @@ namespace osu.Game.Beatmaps validateOnlineIds(beatmapSet); - return updateQueue.UpdateAsync(beatmapSet, cancellationToken); + bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); + + await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken); + + // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. + if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0)) + { + if (beatmapSet.OnlineBeatmapSetID != null) + { + beatmapSet.OnlineBeatmapSetID = null; + LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); + } + } } protected override void PreImport(BeatmapSetInfo beatmapSet) @@ -129,7 +144,7 @@ namespace osu.Game.Beatmaps { var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); - LogForModel(beatmapSet, "Validating online IDs..."); + LogForModel(beatmapSet, $"Validating online IDs for {beatmapSet.Beatmaps.Count} beatmaps..."); // ensure all IDs are unique if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) @@ -174,6 +189,35 @@ namespace osu.Game.Beatmaps /// The beatmap difficulty to restore. public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap); + /// + /// Saves an file against a given . + /// + /// The to save the content against. The file referenced by will be replaced. + /// The content to write. + public void Save(BeatmapInfo info, IBeatmap beatmapContent) + { + var setInfo = QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == info.ID)); + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmapContent).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + using (ContextFactory.GetForWrite()) + { + var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID); + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); + + stream.Seek(0, SeekOrigin.Begin); + UpdateFile(setInfo, setInfo.Files.Single(f => string.Equals(f.Filename, info.Path, StringComparison.OrdinalIgnoreCase)), stream); + } + } + + removeWorkingCache(info); + } + private readonly WeakList workingCache = new WeakList(); /// @@ -190,14 +234,22 @@ namespace osu.Game.Beatmaps if (beatmapInfo?.BeatmapSet == null || beatmapInfo == DefaultBeatmap?.BeatmapInfo) return DefaultBeatmap; + if (beatmapInfo.BeatmapSet.Files == null) + { + var info = beatmapInfo; + beatmapInfo = QueryBeatmap(b => b.ID == info.ID); + } + + if (beatmapInfo == null) + return DefaultBeatmap; + lock (workingCache) { var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); if (working == null) { - if (beatmapInfo.Metadata == null) - beatmapInfo.Metadata = beatmapInfo.BeatmapSet.Metadata; + beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata; workingCache.Add(working = new BeatmapManagerWorkingBeatmap(Files.Store, new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store)), beatmapInfo, audioManager)); @@ -215,9 +267,9 @@ namespace osu.Game.Beatmaps /// The first result for the provided query, or null if no results were found. public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query); - protected override bool CanUndelete(BeatmapSetInfo existing, BeatmapSetInfo import) + protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import) { - if (!base.CanUndelete(existing, import)) + if (!base.CanReuseExisting(existing, import)) return false; var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); @@ -231,13 +283,39 @@ namespace osu.Game.Beatmaps /// Returns a list of all usable s. /// /// A list of available . - public List GetAllUsableBeatmapSets() => GetAllUsableBeatmapSetsEnumerable().ToList(); + public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => + GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList(); /// - /// Returns a list of all usable s. + /// Returns a list of all usable s. Note that files are not populated. /// + /// The level of detail to include in the returned objects. + /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases. /// A list of available . - public IQueryable GetAllUsableBeatmapSetsEnumerable() => beatmaps.ConsumableItems.Where(s => !s.DeletePending && !s.Protected); + public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) + { + IQueryable queryable; + + switch (includes) + { + case IncludedDetails.Minimal: + queryable = beatmaps.BeatmapSetsOverview; + break; + + case IncludedDetails.AllButFiles: + queryable = beatmaps.BeatmapSetsWithoutFiles; + break; + + default: + queryable = beatmaps.ConsumableItems; + break; + } + + // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY + // clause which causes queries to take 5-10x longer. + // TODO: remove if upgrading to EF core 3.x. + return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected)); + } /// /// Perform a lookup query on available s. @@ -296,7 +374,7 @@ namespace osu.Game.Beatmaps foreach (var file in files.Where(f => f.Filename.EndsWith(".osu"))) { using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath)) - using (var ms = new MemoryStream()) //we need a memory stream so we can seek + using (var ms = new MemoryStream()) // we need a memory stream so we can seek using (var sr = new LineBufferedReader(ms)) { raw.CopyTo(ms); @@ -343,6 +421,29 @@ namespace osu.Game.Beatmaps return endTime - startTime; } + private void removeWorkingCache(BeatmapSetInfo info) + { + if (info.Beatmaps == null) return; + + foreach (var b in info.Beatmaps) + removeWorkingCache(b); + } + + private void removeWorkingCache(BeatmapInfo info) + { + lock (workingCache) + { + var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID); + if (working != null) + workingCache.Remove(working); + } + } + + public void Dispose() + { + onlineLookupQueue?.Dispose(); + } + /// /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. /// @@ -358,70 +459,28 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; - protected override VideoSprite GetVideo() => null; protected override Track GetTrack() => null; } + } - private class BeatmapUpdateQueue - { - private readonly IAPIProvider api; + /// + /// The level of detail to include in database results. + /// + public enum IncludedDetails + { + /// + /// Only include beatmap difficulties and set level metadata. + /// + Minimal, - private const int update_queue_request_concurrency = 4; + /// + /// Include all difficulties, rulesets, difficulty metadata but no files. + /// + AllButFiles, - private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapUpdateQueue)); - - public BeatmapUpdateQueue(IAPIProvider api) - { - this.api = api; - } - - public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) - { - if (api?.State != APIState.Online) - return Task.CompletedTask; - - LogForModel(beatmapSet, "Performing online lookups..."); - return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); - } - - // todo: expose this when we need to do individual difficulty lookups. - protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => update(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler, updateScheduler); - - private void update(BeatmapSetInfo set, BeatmapInfo beatmap) - { - if (api?.State != APIState.Online) - return; - - var req = new GetBeatmapRequest(beatmap); - - req.Failure += fail; - - try - { - // intentionally blocking to limit web request concurrency - api.Perform(req); - - var res = req.Result; - - beatmap.Status = res.Status; - beatmap.BeatmapSet.Status = res.BeatmapSet.Status; - beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; - beatmap.OnlineBeatmapID = res.OnlineBeatmapID; - - LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); - } - catch (Exception e) - { - fail(e); - } - - void fail(Exception e) - { - beatmap.OnlineBeatmapID = null; - LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); - } - } - } + /// + /// Include everything. + /// + All } } diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs new file mode 100644 index 0000000000..16207c7d2a --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -0,0 +1,198 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Data.Sqlite; +using osu.Framework.Development; +using osu.Framework.IO.Network; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Threading; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using SharpCompress.Compressors; +using SharpCompress.Compressors.BZip2; + +namespace osu.Game.Beatmaps +{ + public partial class BeatmapManager + { + private class BeatmapOnlineLookupQueue : IDisposable + { + private readonly IAPIProvider api; + private readonly Storage storage; + + private const int update_queue_request_concurrency = 4; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); + + private FileWebRequest cacheDownloadRequest; + + private const string cache_database_name = "online.db"; + + public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) + { + this.api = api; + this.storage = storage; + + // avoid downloading / using cache for unit tests. + if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name)) + prepareLocalCache(); + } + + public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) + { + LogForModel(beatmapSet, "Performing online lookups..."); + return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); + } + + // todo: expose this when we need to do individual difficulty lookups. + protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) + => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + + private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap) + { + if (checkLocalCache(set, beatmap)) + return; + + if (api?.State != APIState.Online) + return; + + var req = new GetBeatmapRequest(beatmap); + + req.Failure += fail; + + try + { + // intentionally blocking to limit web request concurrency + api.Perform(req); + + var res = req.Result; + + if (res != null) + { + beatmap.Status = res.Status; + beatmap.BeatmapSet.Status = res.BeatmapSet.Status; + beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; + beatmap.OnlineBeatmapID = res.OnlineBeatmapID; + + LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); + } + } + catch (Exception e) + { + fail(e); + } + + void fail(Exception e) + { + beatmap.OnlineBeatmapID = null; + LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); + } + } + + private void prepareLocalCache() + { + string cacheFilePath = storage.GetFullPath(cache_database_name); + string compressedCacheFilePath = $"{cacheFilePath}.bz2"; + + cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2"); + + cacheDownloadRequest.Failed += ex => + { + File.Delete(compressedCacheFilePath); + File.Delete(cacheFilePath); + + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); + }; + + cacheDownloadRequest.Finished += () => + { + try + { + using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) + using (var outStream = File.OpenWrite(cacheFilePath)) + using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) + bz2.CopyTo(outStream); + + // set to null on completion to allow lookups to begin using the new source + cacheDownloadRequest = null; + } + catch (Exception ex) + { + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + File.Delete(cacheFilePath); + } + finally + { + File.Delete(compressedCacheFilePath); + } + }; + + cacheDownloadRequest.PerformAsync(); + } + + private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap) + { + // download is in progress (or was, and failed). + if (cacheDownloadRequest != null) + return false; + + // database is unavailable. + if (!storage.Exists(cache_database_name)) + return false; + + try + { + using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online"))) + { + var found = db.QuerySingleOrDefault( + "SELECT * FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path", beatmap); + + if (found != null) + { + var status = (BeatmapSetOnlineStatus)found.approved; + + beatmap.Status = status; + beatmap.BeatmapSet.Status = status; + beatmap.BeatmapSet.OnlineBeatmapSetID = found.beatmapset_id; + beatmap.OnlineBeatmapID = found.beatmap_id; + + LogForModel(set, $"Cached local retrieval for {beatmap}."); + return true; + } + } + } + catch (Exception ex) + { + LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}."); + } + + return false; + } + + public void Dispose() + { + cacheDownloadRequest?.Dispose(); + updateScheduler?.Dispose(); + } + + [Serializable] + [SuppressMessage("ReSharper", "InconsistentNaming")] + private class CachedOnlineBeatmapLookup + { + public int approved { get; set; } + + public int? beatmapset_id { get; set; } + + public int? beatmap_id { get; set; } + } + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs index f9d71a2a6e..39c5ccab27 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Game.Beatmaps.Formats; @@ -36,13 +35,14 @@ namespace osu.Game.Beatmaps using (var stream = new LineBufferedReader(store.GetStream(getPathForFile(BeatmapInfo.Path)))) return Decoder.GetDecoder(stream).Decode(stream); } - catch + catch (Exception e) { + Logger.Error(e, "Beatmap failed to load"); return null; } } - private string getPathForFile(string filename) => BeatmapSetInfo.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + private string getPathForFile(string filename) => BeatmapSetInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; private TextureStore textureStore; @@ -59,23 +59,9 @@ namespace osu.Game.Beatmaps { return textureStore.Get(getPathForFile(Metadata.BackgroundFile)); } - catch - { - return null; - } - } - - protected override VideoSprite GetVideo() - { - if (Metadata?.VideoFile == null) - return null; - - try - { - return new VideoSprite(textureStore.GetStream(getPathForFile(Metadata.VideoFile))); - } - catch + catch (Exception e) { + Logger.Error(e, "Background failed to load"); return null; } } @@ -86,8 +72,9 @@ namespace osu.Game.Beatmaps { return (trackStore ??= AudioManager.GetTrackStore(store)).Get(getPathForFile(Metadata.AudioFile)); } - catch + catch (Exception e) { + Logger.Error(e, "Track failed to load"); return null; } } @@ -115,8 +102,9 @@ namespace osu.Game.Beatmaps var trackData = store.GetStream(getPathForFile(Metadata.AudioFile)); return trackData == null ? null : new Waveform(trackData); } - catch + catch (Exception e) { + Logger.Error(e, "Waveform failed to load"); return null; } } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 9267527d79..775d78f1fb 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -52,9 +52,12 @@ namespace osu.Game.Beatmaps public int PreviewTime { get; set; } public string AudioFile { get; set; } public string BackgroundFile { get; set; } - public string VideoFile { get; set; } - public override string ToString() => $"{Artist} - {Title} ({Author})"; + public override string ToString() + { + string author = Author == null ? string.Empty : $"({Author})"; + return $"{Artist} - {Title} {author}".Trim(); + } [JsonIgnore] public string[] SearchableTerms => new[] @@ -82,8 +85,7 @@ namespace osu.Game.Beatmaps && Tags == other.Tags && PreviewTime == other.PreviewTime && AudioFile == other.AudioFile - && BackgroundFile == other.BackgroundFile - && VideoFile == other.VideoFile; + && BackgroundFile == other.BackgroundFile; } } } diff --git a/osu.Game/Beatmaps/BeatmapStore.cs b/osu.Game/Beatmaps/BeatmapStore.cs index a2279fdb14..642bafd2ac 100644 --- a/osu.Game/Beatmaps/BeatmapStore.cs +++ b/osu.Game/Beatmaps/BeatmapStore.cs @@ -87,6 +87,18 @@ namespace osu.Game.Beatmaps base.Purge(items, context); } + public IQueryable BeatmapSetsOverview => ContextFactory.Get().BeatmapSetInfo + .Include(s => s.Metadata) + .Include(s => s.Beatmaps) + .AsNoTracking(); + + public IQueryable BeatmapSetsWithoutFiles => ContextFactory.Get().BeatmapSetInfo + .Include(s => s.Metadata) + .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) + .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) + .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) + .AsNoTracking(); + public IQueryable Beatmaps => ContextFactory.Get().BeatmapInfo .Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata) diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 39a0e6f6d4..a1822a1163 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -5,7 +5,7 @@ using System; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable, IEquatable + public abstract class ControlPoint : IComparable { /// /// The time at which the control point takes effect. @@ -19,12 +19,10 @@ namespace osu.Game.Beatmaps.ControlPoints public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time); /// - /// Whether this control point is equivalent to another, ignoring time. + /// Determines whether this results in a meaningful change when placed alongside another. /// - /// Another control point to compare with. - /// Whether equivalent. - public abstract bool EquivalentTo(ControlPoint other); - - public bool Equals(ControlPoint other) => Time == other?.Time && EquivalentTo(other); + /// An existing control point to compare with. + /// Whether this is redundant when placed alongside . + public abstract bool IsRedundant(ControlPoint existing); } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index df68d8acd2..e7788b75f3 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -56,6 +56,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// All control points, of all types. /// + [JsonIgnore] public IEnumerable AllControlPoints => Groups.SelectMany(g => g.ControlPoints).ToArray(); /// @@ -63,49 +64,49 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the difficulty control point at. /// The difficulty control point. - public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time); + public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); /// /// Finds the effect control point that is active at . /// /// The time to find the effect control point at. /// The effect control point. - public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time); + public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// /// Finds the sound control point that is active at . /// /// The time to find the sound control point at. /// The sound control point. - public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : null); + public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT); /// /// Finds the timing control point that is active at . /// /// The time to find the timing control point at. /// The timing control point. - public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : null); + public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// /// Finds the maximum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMaximum => - 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the minimum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMinimum => - 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the mode BPM (most common BPM) represented by the control points. /// [JsonIgnore] public double BPMMode => - 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Remove all s and return to a pristine state. @@ -169,12 +170,12 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The list to search. /// The time to find the control point at. - /// The control point to use when is before any control points. If null, a new control point will be constructed. + /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - private T binarySearchWithFallback(IReadOnlyList list, double time, T prePoint = null) - where T : ControlPoint, new() + private T binarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : ControlPoint { - return binarySearch(list, time) ?? prePoint ?? new T(); + return binarySearch(list, time) ?? fallback; } /// @@ -247,7 +248,7 @@ namespace osu.Game.Beatmaps.ControlPoints break; } - return existing?.EquivalentTo(newPoint) == true; + return newPoint?.IsRedundant(existing) == true; } private void groupItemAdded(ControlPoint controlPoint) diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 8b21098a51..1d38790f87 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -7,6 +7,11 @@ namespace osu.Game.Beatmaps.ControlPoints { public class DifficultyControlPoint : ControlPoint { + public static readonly DifficultyControlPoint DEFAULT = new DifficultyControlPoint + { + SpeedMultiplierBindable = { Disabled = true }, + }; + /// /// The speed multiplier at this control point. /// @@ -27,7 +32,8 @@ namespace osu.Game.Beatmaps.ControlPoints set => SpeedMultiplierBindable.Value = value; } - public override bool EquivalentTo(ControlPoint other) => - other is DifficultyControlPoint otherTyped && otherTyped.SpeedMultiplier.Equals(SpeedMultiplier); + public override bool IsRedundant(ControlPoint existing) + => existing is DifficultyControlPoint existingDifficulty + && SpeedMultiplier == existingDifficulty.SpeedMultiplier; } } diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 369b93ff3d..9e8e3978be 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -7,6 +7,12 @@ namespace osu.Game.Beatmaps.ControlPoints { public class EffectControlPoint : ControlPoint { + public static readonly EffectControlPoint DEFAULT = new EffectControlPoint + { + KiaiModeBindable = { Disabled = true }, + OmitFirstBarLineBindable = { Disabled = true } + }; + /// /// Whether the first bar line of this control point is ignored. /// @@ -35,8 +41,10 @@ namespace osu.Game.Beatmaps.ControlPoints set => KiaiModeBindable.Value = value; } - public override bool EquivalentTo(ControlPoint other) => - other is EffectControlPoint otherTyped && - KiaiMode == otherTyped.KiaiMode && OmitFirstBarLine == otherTyped.OmitFirstBarLine; + public override bool IsRedundant(ControlPoint existing) + => !OmitFirstBarLine + && existing is EffectControlPoint existingEffect + && KiaiMode == existingEffect.KiaiMode + && OmitFirstBarLine == existingEffect.OmitFirstBarLine; } } diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 393bcfdb3c..c052c04ea0 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -10,6 +10,12 @@ namespace osu.Game.Beatmaps.ControlPoints { public const string DEFAULT_BANK = "normal"; + public static readonly SampleControlPoint DEFAULT = new SampleControlPoint + { + SampleBankBindable = { Disabled = true }, + SampleVolumeBindable = { Disabled = true } + }; + /// /// The default sample bank at this control point. /// @@ -68,8 +74,9 @@ namespace osu.Game.Beatmaps.ControlPoints return newSampleInfo; } - public override bool EquivalentTo(ControlPoint other) => - other is SampleControlPoint otherTyped && - SampleBank == otherTyped.SampleBank && SampleVolume == otherTyped.SampleVolume; + public override bool IsRedundant(ControlPoint existing) + => existing is SampleControlPoint existingSample + && SampleBank == existingSample.SampleBank + && SampleVolume == existingSample.SampleVolume; } } diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 51b3377394..9345299c3a 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -13,6 +13,21 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly Bindable TimeSignatureBindable = new Bindable(TimeSignatures.SimpleQuadruple) { Default = TimeSignatures.SimpleQuadruple }; + /// + /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. + /// + private const double default_beat_length = 60000.0 / 60.0; + + public static readonly TimingControlPoint DEFAULT = new TimingControlPoint + { + BeatLengthBindable = + { + Value = default_beat_length, + Disabled = true + }, + TimeSignatureBindable = { Disabled = true } + }; + /// /// The time signature at this control point. /// @@ -48,8 +63,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// public double BPM => 60000 / BeatLength; - public override bool EquivalentTo(ControlPoint other) => - other is TimingControlPoint otherTyped - && TimeSignature == otherTyped.TimeSignature && BeatLength.Equals(otherTyped.BeatLength); + // Timing points are never redundant as they can change the time signature. + public override bool IsRedundant(ControlPoint existing) => false; } } diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 351e5df17a..f6e03d40ff 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -13,6 +13,7 @@ namespace osu.Game.Beatmaps.Drawables public class BeatmapSetOnlineStatusPill : CircularContainer { private readonly OsuSpriteText statusText; + private readonly Box background; private BeatmapSetOnlineStatus status; @@ -43,6 +44,12 @@ namespace osu.Game.Beatmaps.Drawables set => statusText.Padding = value; } + public Color4 BackgroundColour + { + get => background.Colour; + set => background.Colour = value; + } + public BeatmapSetOnlineStatusPill() { AutoSizeAxes = Axes.Both; @@ -50,7 +57,7 @@ namespace osu.Game.Beatmaps.Drawables Children = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, Colour = Color4.Black, diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 7bd40af512..8a0d981e49 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -48,11 +48,9 @@ namespace osu.Game.Beatmaps.Drawables InternalChild = iconContainer = new Container { Size = new Vector2(20f) }; } - public string TooltipText { get; set; } - public ITooltip GetCustomTooltip() => new DifficultyIconTooltip(); - public object TooltipContent { get; set; } + public object TooltipContent { get; } [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -152,12 +150,12 @@ namespace osu.Game.Beatmaps.Drawables }; } - private OsuColour colours; + [Resolved] + private OsuColour colours { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - this.colours = colours; background.Colour = colours.Gray3; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 30346a8a96..eb05cbaf85 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -16,6 +16,8 @@ namespace osu.Game.Beatmaps.Drawables { public readonly Bindable Beatmap = new Bindable(); + protected override double LoadDelay => 500; + [Resolved] private BeatmapManager beatmaps { get; set; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs index 16eecb7198..c60bd0286e 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; @@ -50,7 +49,7 @@ namespace osu.Game.Beatmaps.Drawables Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.1f)), + Colour = OsuColour.Gray(0.2f), }; } diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index bfcc38e4a9..8080e94075 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -7,7 +7,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -45,8 +44,6 @@ namespace osu.Game.Beatmaps protected override Texture GetBackground() => textures?.Get(@"Backgrounds/bg4"); - protected override VideoSprite GetVideo() => null; - protected override Track GetTrack() => GetVirtualTrack(); private class DummyRulesetInfo : RulesetInfo diff --git a/osu.Game/Beatmaps/Formats/Decoder.cs b/osu.Game/Beatmaps/Formats/Decoder.cs index 45122f6312..845ac20db0 100644 --- a/osu.Game/Beatmaps/Formats/Decoder.cs +++ b/osu.Game/Beatmaps/Formats/Decoder.cs @@ -63,7 +63,7 @@ namespace osu.Game.Beatmaps.Formats if (line == null) throw new IOException("Unknown file format (null)"); - var decoder = typedDecoders.Select(d => line.StartsWith(d.Key, StringComparison.InvariantCulture) ? d.Value : null).FirstOrDefault(); + var decoder = typedDecoders.Where(d => line.StartsWith(d.Key, StringComparison.InvariantCulture)).Select(d => d.Value).FirstOrDefault(); // it's important the magic does NOT get consumed here, since sometimes it's part of the structure // (see JsonBeatmapDecoder - the magic string is the opening brace) diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 447d52d980..b30ec0ca2c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -6,11 +6,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; using osu.Framework.Extensions; -using osu.Game.Beatmaps.Timing; -using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.IO; using osu.Game.Beatmaps.Legacy; +using osu.Game.Beatmaps.Timing; +using osu.Game.IO; +using osu.Game.Rulesets.Objects.Legacy; namespace osu.Game.Beatmaps.Formats { @@ -64,7 +64,7 @@ namespace osu.Game.Beatmaps.Formats hitObject.ApplyDefaults(this.beatmap.ControlPointInfo, this.beatmap.BeatmapInfo.BaseDifficulty); } - protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(" ", StringComparison.Ordinal) || line.StartsWith("_", StringComparison.Ordinal); + protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(' ') || line.StartsWith('_'); protected override void ParseLine(Beatmap beatmap, Section section, string line) { @@ -239,11 +239,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"Source": - beatmap.BeatmapInfo.Metadata.Source = pair.Value; + metadata.Source = pair.Value; break; case @"Tags": - beatmap.BeatmapInfo.Metadata.Tags = pair.Value; + metadata.Tags = pair.Value; break; case @"BeatmapID": @@ -300,23 +300,14 @@ namespace osu.Game.Beatmaps.Formats switch (type) { case LegacyEventType.Background: - string bgFilename = split[2].Trim('"'); - beatmap.BeatmapInfo.Metadata.BackgroundFile = bgFilename.ToStandardisedPath(); - break; - - case LegacyEventType.Video: - string videoFilename = split[2].Trim('"'); - beatmap.BeatmapInfo.Metadata.VideoFile = videoFilename.ToStandardisedPath(); + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; case LegacyEventType.Break: double start = getOffsetTime(Parsing.ParseDouble(split[1])); + double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); - var breakEvent = new BreakPeriod - { - StartTime = start, - EndTime = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))) - }; + var breakEvent = new BreakPeriod(start, end); if (!breakEvent.HasEffect) return; @@ -378,7 +369,9 @@ namespace osu.Game.Beatmaps.Formats addControlPoint(time, controlPoint, true); } - addControlPoint(time, new LegacyDifficultyControlPoint +#pragma warning disable 618 + addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) +#pragma warning restore 618 { SpeedMultiplier = speedMultiplier, }, timingChange); @@ -395,17 +388,10 @@ namespace osu.Game.Beatmaps.Formats SampleVolume = sampleVolume, CustomSampleBank = customSampleBank, }, timingChange); - - // To handle the scenario where a non-timing line shares the same time value as a subsequent timing line but - // appears earlier in the file, we buffer non-timing control points and rewrite them *after* control points from the timing line - // with the same time value (allowing them to overwrite as necessary). - // - // The expected outcome is that we prefer the non-timing line's adjustments over the timing line's adjustments when time is equal. - if (timingChange) - flushPendingPoints(); } private readonly List pendingControlPoints = new List(); + private readonly HashSet pendingControlPointTypes = new HashSet(); private double pendingControlPointsTime; private void addControlPoint(double time, ControlPoint point, bool timingChange) @@ -414,28 +400,34 @@ namespace osu.Game.Beatmaps.Formats flushPendingPoints(); if (timingChange) - { - beatmap.ControlPointInfo.Add(time, point); - return; - } + pendingControlPoints.Insert(0, point); + else + pendingControlPoints.Add(point); - pendingControlPoints.Add(point); pendingControlPointsTime = time; } private void flushPendingPoints() { - foreach (var p in pendingControlPoints) - beatmap.ControlPointInfo.Add(pendingControlPointsTime, p); + // Changes from non-timing-points are added to the end of the list (see addControlPoint()) and should override any changes from timing-points (added to the start of the list). + for (int i = pendingControlPoints.Count - 1; i >= 0; i--) + { + var type = pendingControlPoints[i].GetType(); + if (pendingControlPointTypes.Contains(type)) + continue; + + pendingControlPointTypes.Add(type); + beatmap.ControlPointInfo.Add(pendingControlPointsTime, pendingControlPoints[i]); + } pendingControlPoints.Clear(); + pendingControlPointTypes.Clear(); } private void handleHitObject(string line) { // If the ruleset wasn't specified, assume the osu!standard ruleset. - if (parser == null) - parser = new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); + parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); var obj = parser.Parse(line); if (obj != null) diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 433becd8cc..57555cce90 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -10,7 +11,9 @@ using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Beatmaps.Formats { @@ -48,7 +51,7 @@ namespace osu.Game.Beatmaps.Formats handleEvents(writer); writer.WriteLine(); - handleTimingPoints(writer); + handleControlPoints(writer); writer.WriteLine(); handleHitObjects(writer); @@ -58,12 +61,12 @@ namespace osu.Game.Beatmaps.Formats { writer.WriteLine("[General]"); - writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); + if (beatmap.Metadata.AudioFile != null) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); // Todo: Not all countdown types are supported by lazer yet writer.WriteLine(FormattableString.Invariant($"Countdown: {(beatmap.BeatmapInfo.Countdown ? '1' : '0')}")); - writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank(beatmap.ControlPointInfo.SamplePoints[0].SampleBank)}")); + writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank(beatmap.ControlPointInfo.SamplePointAt(double.MinValue).SampleBank)}")); writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); writer.WriteLine(FormattableString.Invariant($"Mode: {beatmap.BeatmapInfo.RulesetID}")); writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); @@ -103,15 +106,15 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[Metadata]"); writer.WriteLine(FormattableString.Invariant($"Title: {beatmap.Metadata.Title}")); - writer.WriteLine(FormattableString.Invariant($"TitleUnicode: {beatmap.Metadata.TitleUnicode}")); + if (beatmap.Metadata.TitleUnicode != null) writer.WriteLine(FormattableString.Invariant($"TitleUnicode: {beatmap.Metadata.TitleUnicode}")); writer.WriteLine(FormattableString.Invariant($"Artist: {beatmap.Metadata.Artist}")); - writer.WriteLine(FormattableString.Invariant($"ArtistUnicode: {beatmap.Metadata.ArtistUnicode}")); + if (beatmap.Metadata.ArtistUnicode != null) writer.WriteLine(FormattableString.Invariant($"ArtistUnicode: {beatmap.Metadata.ArtistUnicode}")); writer.WriteLine(FormattableString.Invariant($"Creator: {beatmap.Metadata.AuthorString}")); writer.WriteLine(FormattableString.Invariant($"Version: {beatmap.BeatmapInfo.Version}")); - writer.WriteLine(FormattableString.Invariant($"Source: {beatmap.Metadata.Source}")); - writer.WriteLine(FormattableString.Invariant($"Tags: {beatmap.Metadata.Tags}")); - writer.WriteLine(FormattableString.Invariant($"BeatmapID: {beatmap.BeatmapInfo.OnlineBeatmapID ?? 0}")); - writer.WriteLine(FormattableString.Invariant($"BeatmapSetID: {beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID ?? -1}")); + if (beatmap.Metadata.Source != null) writer.WriteLine(FormattableString.Invariant($"Source: {beatmap.Metadata.Source}")); + if (beatmap.Metadata.Tags != null) writer.WriteLine(FormattableString.Invariant($"Tags: {beatmap.Metadata.Tags}")); + if (beatmap.BeatmapInfo.OnlineBeatmapID != null) writer.WriteLine(FormattableString.Invariant($"BeatmapID: {beatmap.BeatmapInfo.OnlineBeatmapID}")); + if (beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID != null) writer.WriteLine(FormattableString.Invariant($"BeatmapSetID: {beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID}")); } private void handleDifficulty(TextWriter writer) @@ -122,7 +125,12 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine(FormattableString.Invariant($"CircleSize: {beatmap.BeatmapInfo.BaseDifficulty.CircleSize}")); writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty}")); writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.BeatmapInfo.BaseDifficulty.ApproachRate}")); - writer.WriteLine(FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}")); + + // Taiko adjusts the slider multiplier (see: TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER) + writer.WriteLine(beatmap.BeatmapInfo.RulesetID == 1 + ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / 1.4f}") + : FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}")); + writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate}")); } @@ -133,14 +141,11 @@ namespace osu.Game.Beatmaps.Formats if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Background},0,\"{beatmap.BeatmapInfo.Metadata.BackgroundFile}\",0,0")); - if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.VideoFile)) - writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Video},0,\"{beatmap.BeatmapInfo.Metadata.VideoFile}\",0,0")); - foreach (var b in beatmap.Breaks) writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Break},{b.StartTime},{b.EndTime}")); } - private void handleTimingPoints(TextWriter writer) + private void handleControlPoints(TextWriter writer) { if (beatmap.ControlPointInfo.Groups.Count == 0) return; @@ -149,20 +154,30 @@ namespace osu.Game.Beatmaps.Formats foreach (var group in beatmap.ControlPointInfo.Groups) { - var timingPoint = group.ControlPoints.OfType().FirstOrDefault(); - var difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(group.Time); - var samplePoint = beatmap.ControlPointInfo.SamplePointAt(group.Time); - var effectPoint = beatmap.ControlPointInfo.EffectPointAt(group.Time); + var groupTimingPoint = group.ControlPoints.OfType().FirstOrDefault(); - // Convert beat length the legacy format - double beatLength; - if (timingPoint != null) - beatLength = timingPoint.BeatLength; - else - beatLength = -100 / difficultyPoint.SpeedMultiplier; + // If the group contains a timing control point, it needs to be output separately. + if (groupTimingPoint != null) + { + writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},")); + writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},")); + outputControlPointEffectsAt(groupTimingPoint.Time, true); + } + + // Output any remaining effects as secondary non-timing control point. + var difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(group.Time); + writer.Write(FormattableString.Invariant($"{group.Time},")); + writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SpeedMultiplier},")); + outputControlPointEffectsAt(group.Time, false); + } + + void outputControlPointEffectsAt(double time, bool isTimingPoint) + { + var samplePoint = beatmap.ControlPointInfo.SamplePointAt(time); + var effectPoint = beatmap.ControlPointInfo.EffectPointAt(time); // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) - HitSampleInfo tempHitSample = samplePoint.ApplyTo(new HitSampleInfo()); + HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo()); // Convert effect flags to the legacy format LegacyEffectFlags effectFlags = LegacyEffectFlags.None; @@ -171,13 +186,11 @@ namespace osu.Game.Beatmaps.Formats if (effectPoint.OmitFirstBarLine) effectFlags |= LegacyEffectFlags.OmitFirstBarLine; - writer.Write(FormattableString.Invariant($"{group.Time},")); - writer.Write(FormattableString.Invariant($"{beatLength},")); - writer.Write(FormattableString.Invariant($"{(int)beatmap.ControlPointInfo.TimingPointAt(group.Time).TimeSignature},")); + writer.Write(FormattableString.Invariant($"{(int)beatmap.ControlPointInfo.TimingPointAt(time).TimeSignature},")); writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},")); - writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample.Suffix)},")); + writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},")); writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},")); - writer.Write(FormattableString.Invariant($"{(timingPoint != null ? '1' : '0')},")); + writer.Write(FormattableString.Invariant($"{(isTimingPoint ? '1' : '0')},")); writer.Write(FormattableString.Invariant($"{(int)effectFlags}")); writer.WriteLine(); } @@ -190,74 +203,75 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[HitObjects]"); + foreach (var h in beatmap.HitObjects) + handleHitObject(writer, h); + } + + private void handleHitObject(TextWriter writer, HitObject hitObject) + { + Vector2 position = new Vector2(256, 192); + switch (beatmap.BeatmapInfo.RulesetID) { case 0: - foreach (var h in beatmap.HitObjects) - handleOsuHitObject(writer, h); - break; - - case 1: - foreach (var h in beatmap.HitObjects) - handleTaikoHitObject(writer, h); + position = ((IHasPosition)hitObject).Position; break; case 2: - foreach (var h in beatmap.HitObjects) - handleCatchHitObject(writer, h); + position.X = ((IHasXPosition)hitObject).X; break; case 3: - foreach (var h in beatmap.HitObjects) - handleManiaHitObject(writer, h); + int totalColumns = (int)Math.Max(1, beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + position.X = (int)Math.Ceiling(((IHasXPosition)hitObject).X * (512f / totalColumns)); break; } - } - private void handleOsuHitObject(TextWriter writer, HitObject hitObject) - { - var positionData = (IHasPosition)hitObject; - - writer.Write(FormattableString.Invariant($"{positionData.X},")); - writer.Write(FormattableString.Invariant($"{positionData.Y},")); + writer.Write(FormattableString.Invariant($"{position.X},")); + writer.Write(FormattableString.Invariant($"{position.Y},")); writer.Write(FormattableString.Invariant($"{hitObject.StartTime},")); writer.Write(FormattableString.Invariant($"{(int)getObjectType(hitObject)},")); + writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(hitObject.Samples)},")); - writer.Write(hitObject is IHasCurve - ? FormattableString.Invariant($"0,") - : FormattableString.Invariant($"{(int)toLegacyHitSoundType(hitObject.Samples)},")); - - if (hitObject is IHasCurve curveData) + if (hitObject is IHasPath path) { - addCurveData(writer, curveData, positionData); + addPathData(writer, path, position); writer.Write(getSampleBank(hitObject.Samples, zeroBanks: true)); } else { - if (hitObject is IHasEndTime endTimeData) - writer.Write(FormattableString.Invariant($"{endTimeData.EndTime},")); + if (hitObject is IHasDuration) + addEndTimeData(writer, hitObject); + writer.Write(getSampleBank(hitObject.Samples)); } writer.WriteLine(); } - private static LegacyHitObjectType getObjectType(HitObject hitObject) + private LegacyHitObjectType getObjectType(HitObject hitObject) { - var comboData = (IHasCombo)hitObject; + LegacyHitObjectType type = 0; - var type = (LegacyHitObjectType)(comboData.ComboOffset << 4); + if (hitObject is IHasCombo combo) + { + type = (LegacyHitObjectType)(combo.ComboOffset << 4); - if (comboData.NewCombo) type |= LegacyHitObjectType.NewCombo; + if (combo.NewCombo) + type |= LegacyHitObjectType.NewCombo; + } switch (hitObject) { - case IHasCurve _: + case IHasPath _: type |= LegacyHitObjectType.Slider; break; - case IHasEndTime _: - type |= LegacyHitObjectType.Spinner | LegacyHitObjectType.NewCombo; + case IHasDuration _: + if (beatmap.BeatmapInfo.RulesetID == 3) + type |= LegacyHitObjectType.Hold; + else + type |= LegacyHitObjectType.Spinner; break; default: @@ -268,13 +282,13 @@ namespace osu.Game.Beatmaps.Formats return type; } - private void addCurveData(TextWriter writer, IHasCurve curveData, IHasPosition positionData) + private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position) { PathType? lastType = null; - for (int i = 0; i < curveData.Path.ControlPoints.Count; i++) + for (int i = 0; i < pathData.Path.ControlPoints.Count; i++) { - PathControlPoint point = curveData.Path.ControlPoints[i]; + PathControlPoint point = pathData.Path.ControlPoints[i]; if (point.Type.Value != null) { @@ -304,38 +318,51 @@ namespace osu.Game.Beatmaps.Formats else { // New segment with the same type - duplicate the control point - writer.Write(FormattableString.Invariant($"{positionData.X + point.Position.Value.X}:{positionData.Y + point.Position.Value.Y}|")); + writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}|")); } } if (i != 0) { - writer.Write(FormattableString.Invariant($"{positionData.X + point.Position.Value.X}:{positionData.Y + point.Position.Value.Y}")); - writer.Write(i != curveData.Path.ControlPoints.Count - 1 ? "|" : ","); + writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}")); + writer.Write(i != pathData.Path.ControlPoints.Count - 1 ? "|" : ","); } } - writer.Write(FormattableString.Invariant($"{curveData.RepeatCount + 1},")); - writer.Write(FormattableString.Invariant($"{curveData.Path.Distance},")); + var curveData = pathData as IHasPathWithRepeats; - for (int i = 0; i < curveData.NodeSamples.Count; i++) - { - writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(curveData.NodeSamples[i])}")); - writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); - } + writer.Write(FormattableString.Invariant($"{(curveData?.RepeatCount ?? 0) + 1},")); + writer.Write(FormattableString.Invariant($"{pathData.Path.Distance},")); - for (int i = 0; i < curveData.NodeSamples.Count; i++) + if (curveData != null) { - writer.Write(getSampleBank(curveData.NodeSamples[i], true)); - writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + for (int i = 0; i < curveData.NodeSamples.Count; i++) + { + writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(curveData.NodeSamples[i])}")); + writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + } + + for (int i = 0; i < curveData.NodeSamples.Count; i++) + { + writer.Write(getSampleBank(curveData.NodeSamples[i], true)); + writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + } } } - private void handleTaikoHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); + private void addEndTimeData(TextWriter writer, HitObject hitObject) + { + var endTimeData = (IHasDuration)hitObject; + var type = getObjectType(hitObject); - private void handleCatchHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); + char suffix = ','; - private void handleManiaHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); + // Holds write the end time as if it's part of sample data. + if (type == LegacyHitObjectType.Hold) + suffix = ':'; + + writer.Write(FormattableString.Invariant($"{endTimeData.EndTime}{suffix}")); + } private string getSampleBank(IList samples, bool banksOnly = false, bool zeroBanks = false) { @@ -349,7 +376,7 @@ namespace osu.Game.Beatmaps.Formats if (!banksOnly) { - string customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))?.Suffix); + string customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty; int volume = samples.FirstOrDefault()?.Volume ?? 100; @@ -405,6 +432,15 @@ namespace osu.Game.Beatmaps.Formats } } - private string toLegacyCustomSampleBank(string sampleSuffix) => string.IsNullOrEmpty(sampleSuffix) ? "0" : sampleSuffix; + private string toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) + { + if (hitSampleInfo == null) + return "0"; + + if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) + return legacy.CustomSampleBank.ToString(CultureInfo.InvariantCulture); + + return "0"; + } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index f55e24245b..44ef9bcacc 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.IO; +using osu.Game.Rulesets.Objects.Legacy; using osuTK.Graphics; namespace osu.Game.Beatmaps.Formats @@ -32,7 +34,7 @@ namespace osu.Game.Beatmaps.Formats if (ShouldSkipLine(line)) continue; - if (line.StartsWith(@"[", StringComparison.Ordinal) && line.EndsWith(@"]", StringComparison.Ordinal)) + if (line.StartsWith('[') && line.EndsWith(']')) { if (!Enum.TryParse(line[1..^1], out section)) { @@ -40,6 +42,7 @@ namespace osu.Game.Beatmaps.Formats section = Section.None; } + OnBeginNewSection(section); continue; } @@ -56,6 +59,14 @@ namespace osu.Game.Beatmaps.Formats protected virtual bool ShouldSkipLine(string line) => string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart().StartsWith("//".AsSpan(), StringComparison.Ordinal); + /// + /// Invoked when a new has been entered. + /// + /// The entered . + protected virtual void OnBeginNewSection(Section section) + { + } + protected virtual void ParseLine(T output, Section section, string line) { line = StripComments(line); @@ -63,7 +74,7 @@ namespace osu.Game.Beatmaps.Formats switch (section) { case Section.Colours: - handleColours(output, line); + HandleColours(output, line); return; } } @@ -77,7 +88,7 @@ namespace osu.Game.Beatmaps.Formats return line; } - private void handleColours(T output, string line) + protected void HandleColours(TModel output, string line) { var pair = SplitKeyVal(line); @@ -92,7 +103,12 @@ namespace osu.Game.Beatmaps.Formats try { - colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), split.Length == 4 ? byte.Parse(split[3]) : (byte)255); + byte alpha = split.Length == 4 ? byte.Parse(split[3]) : (byte)255; + + if (alpha == 0) + alpha = 255; + + colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), alpha); } catch { @@ -115,7 +131,7 @@ namespace osu.Game.Beatmaps.Formats protected KeyValuePair SplitKeyVal(string line, char separator = ':') { - var split = line.Trim().Split(new[] { separator }, 2); + var split = line.Split(separator, 2); return new KeyValuePair ( @@ -124,6 +140,8 @@ namespace osu.Game.Beatmaps.Formats ); } + protected string CleanFilename(string path) => path.Trim('"').ToStandardisedPath(); + protected enum Section { None, @@ -136,14 +154,25 @@ namespace osu.Game.Beatmaps.Formats Colours, HitObjects, Variables, - Fonts + Fonts, + CatchTheBeat, + Mania, } - internal class LegacyDifficultyControlPoint : DifficultyControlPoint + [Obsolete("Do not use unless you're a legacy ruleset and 100% sure.")] + public class LegacyDifficultyControlPoint : DifficultyControlPoint { - public LegacyDifficultyControlPoint() + /// + /// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it. + /// DO NOT USE THIS UNLESS 100% SURE. + /// + public readonly float BpmMultiplier; + + public LegacyDifficultyControlPoint(double beatLength) { SpeedMultiplierBindable.Precision = double.Epsilon; + + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100f : 1; } } @@ -155,15 +184,19 @@ namespace osu.Game.Beatmaps.Formats { var baseInfo = base.ApplyTo(hitSampleInfo); - if (string.IsNullOrEmpty(baseInfo.Suffix) && CustomSampleBank > 1) - baseInfo.Suffix = CustomSampleBank.ToString(); + if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy + && legacy.CustomSampleBank == 0) + { + legacy.CustomSampleBank = CustomSampleBank; + } return baseInfo; } - public override bool EquivalentTo(ControlPoint other) => - base.EquivalentTo(other) && other is LegacySampleControlPoint otherTyped && - CustomSampleBank == otherTyped.CustomSampleBank; + public override bool IsRedundant(ControlPoint existing) + => base.IsRedundant(existing) + && existing is LegacySampleControlPoint existingSample + && CustomSampleBank == existingSample.CustomSampleBank; } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs index 527f520172..3420fcf260 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Beatmaps.Formats { @@ -26,17 +25,5 @@ namespace osu.Game.Beatmaps.Formats AddDecoder(@"osu file format v", m => new LegacyDifficultyCalculatorBeatmapDecoder(int.Parse(m.Split('v').Last()))); SetFallbackDecoder(() => new LegacyDifficultyCalculatorBeatmapDecoder()); } - - protected override TimingControlPoint CreateTimingControlPoint() - => new LegacyDifficultyCalculatorTimingControlPoint(); - - private class LegacyDifficultyCalculatorTimingControlPoint : TimingControlPoint - { - public LegacyDifficultyCalculatorTimingControlPoint() - { - BeatLengthBindable.MinValue = double.MinValue; - BeatLengthBindable.MaxValue = double.MaxValue; - } - } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index b1b27278fe..269449ef80 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -3,15 +3,14 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Legacy; using osu.Game.IO; using osu.Game.Storyboards; -using osu.Game.Beatmaps.Legacy; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Beatmaps.Formats { @@ -65,12 +64,16 @@ namespace osu.Game.Beatmaps.Formats { var depth = 0; - while (line.StartsWith(" ", StringComparison.Ordinal) || line.StartsWith("_", StringComparison.Ordinal)) + foreach (char c in line) { - ++depth; - line = line.Substring(1); + if (c == ' ' || c == '_') + depth++; + else + break; } + line = line.Substring(depth); + decodeVariables(ref line); string[] split = line.Split(','); @@ -84,13 +87,22 @@ namespace osu.Game.Beatmaps.Formats switch (type) { + case LegacyEventType.Video: + { + var offset = Parsing.ParseInt(split[1]); + var path = CleanFilename(split[2]); + + storyboard.GetLayer("Video").Add(new StoryboardVideo(path, offset)); + break; + } + case LegacyEventType.Sprite: { var layer = parseLayer(split[1]); var origin = parseOrigin(split[2]); - var path = cleanFilename(split[3]); - var x = float.Parse(split[4], NumberFormatInfo.InvariantInfo); - var y = float.Parse(split[5], NumberFormatInfo.InvariantInfo); + var path = CleanFilename(split[3]); + var x = Parsing.ParseFloat(split[4], Parsing.MAX_COORDINATE_VALUE); + var y = Parsing.ParseFloat(split[5], Parsing.MAX_COORDINATE_VALUE); storyboardSprite = new StoryboardSprite(path, origin, new Vector2(x, y)); storyboard.GetLayer(layer).Add(storyboardSprite); break; @@ -100,11 +112,11 @@ namespace osu.Game.Beatmaps.Formats { var layer = parseLayer(split[1]); var origin = parseOrigin(split[2]); - var path = cleanFilename(split[3]); - var x = float.Parse(split[4], NumberFormatInfo.InvariantInfo); - var y = float.Parse(split[5], NumberFormatInfo.InvariantInfo); - var frameCount = int.Parse(split[6]); - var frameDelay = double.Parse(split[7], NumberFormatInfo.InvariantInfo); + var path = CleanFilename(split[3]); + var x = Parsing.ParseFloat(split[4], Parsing.MAX_COORDINATE_VALUE); + var y = Parsing.ParseFloat(split[5], Parsing.MAX_COORDINATE_VALUE); + var frameCount = Parsing.ParseInt(split[6]); + var frameDelay = Parsing.ParseDouble(split[7]); var loopType = split.Length > 8 ? (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), split[8]) : AnimationLoopType.LoopForever; storyboardSprite = new StoryboardAnimation(path, origin, new Vector2(x, y), frameCount, frameDelay, loopType); storyboard.GetLayer(layer).Add(storyboardSprite); @@ -113,10 +125,10 @@ namespace osu.Game.Beatmaps.Formats case LegacyEventType.Sample: { - var time = double.Parse(split[1], CultureInfo.InvariantCulture); + var time = Parsing.ParseDouble(split[1]); var layer = parseLayer(split[2]); - var path = cleanFilename(split[3]); - var volume = split.Length > 4 ? float.Parse(split[4], CultureInfo.InvariantCulture) : 100; + var path = CleanFilename(split[3]); + var volume = split.Length > 4 ? Parsing.ParseFloat(split[4]) : 100; storyboard.GetLayer(layer).Add(new StoryboardSampleInfo(path, time, (int)volume)); break; } @@ -134,17 +146,17 @@ namespace osu.Game.Beatmaps.Formats case "T": { var triggerName = split[1]; - var startTime = split.Length > 2 ? double.Parse(split[2], CultureInfo.InvariantCulture) : double.MinValue; - var endTime = split.Length > 3 ? double.Parse(split[3], CultureInfo.InvariantCulture) : double.MaxValue; - var groupNumber = split.Length > 4 ? int.Parse(split[4]) : 0; + var startTime = split.Length > 2 ? Parsing.ParseDouble(split[2]) : double.MinValue; + var endTime = split.Length > 3 ? Parsing.ParseDouble(split[3]) : double.MaxValue; + var groupNumber = split.Length > 4 ? Parsing.ParseInt(split[4]) : 0; timelineGroup = storyboardSprite?.AddTrigger(triggerName, startTime, endTime, groupNumber); break; } case "L": { - var startTime = double.Parse(split[1], CultureInfo.InvariantCulture); - var loopCount = int.Parse(split[2]); + var startTime = Parsing.ParseDouble(split[1]); + var loopCount = Parsing.ParseInt(split[2]); timelineGroup = storyboardSprite?.AddLoop(startTime, loopCount); break; } @@ -154,52 +166,52 @@ namespace osu.Game.Beatmaps.Formats if (string.IsNullOrEmpty(split[3])) split[3] = split[2]; - var easing = (Easing)int.Parse(split[1]); - var startTime = double.Parse(split[2], CultureInfo.InvariantCulture); - var endTime = double.Parse(split[3], CultureInfo.InvariantCulture); + var easing = (Easing)Parsing.ParseInt(split[1]); + var startTime = Parsing.ParseDouble(split[2]); + var endTime = Parsing.ParseDouble(split[3]); switch (commandType) { case "F": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.Alpha.Add(easing, startTime, endTime, startValue, endValue); break; } case "S": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.Scale.Add(easing, startTime, endTime, startValue, endValue); break; } case "V": { - var startX = float.Parse(split[4], CultureInfo.InvariantCulture); - var startY = float.Parse(split[5], CultureInfo.InvariantCulture); - var endX = split.Length > 6 ? float.Parse(split[6], CultureInfo.InvariantCulture) : startX; - var endY = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startY; + var startX = Parsing.ParseFloat(split[4]); + var startY = Parsing.ParseFloat(split[5]); + var endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; + var endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; timelineGroup?.VectorScale.Add(easing, startTime, endTime, new Vector2(startX, startY), new Vector2(endX, endY)); break; } case "R": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; - timelineGroup?.Rotation.Add(easing, startTime, endTime, MathHelper.RadiansToDegrees(startValue), MathHelper.RadiansToDegrees(endValue)); + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; + timelineGroup?.Rotation.Add(easing, startTime, endTime, MathUtils.RadiansToDegrees(startValue), MathUtils.RadiansToDegrees(endValue)); break; } case "M": { - var startX = float.Parse(split[4], CultureInfo.InvariantCulture); - var startY = float.Parse(split[5], CultureInfo.InvariantCulture); - var endX = split.Length > 6 ? float.Parse(split[6], CultureInfo.InvariantCulture) : startX; - var endY = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startY; + var startX = Parsing.ParseFloat(split[4]); + var startY = Parsing.ParseFloat(split[5]); + var endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; + var endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; timelineGroup?.X.Add(easing, startTime, endTime, startX, endX); timelineGroup?.Y.Add(easing, startTime, endTime, startY, endY); break; @@ -207,28 +219,28 @@ namespace osu.Game.Beatmaps.Formats case "MX": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.X.Add(easing, startTime, endTime, startValue, endValue); break; } case "MY": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.Y.Add(easing, startTime, endTime, startValue, endValue); break; } case "C": { - var startRed = float.Parse(split[4], CultureInfo.InvariantCulture); - var startGreen = float.Parse(split[5], CultureInfo.InvariantCulture); - var startBlue = float.Parse(split[6], CultureInfo.InvariantCulture); - var endRed = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startRed; - var endGreen = split.Length > 8 ? float.Parse(split[8], CultureInfo.InvariantCulture) : startGreen; - var endBlue = split.Length > 9 ? float.Parse(split[9], CultureInfo.InvariantCulture) : startBlue; + var startRed = Parsing.ParseFloat(split[4]); + var startGreen = Parsing.ParseFloat(split[5]); + var startBlue = Parsing.ParseFloat(split[6]); + var endRed = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startRed; + var endGreen = split.Length > 8 ? Parsing.ParseFloat(split[8]) : startGreen; + var endBlue = split.Length > 9 ? Parsing.ParseFloat(split[9]) : startBlue; timelineGroup?.Colour.Add(easing, startTime, endTime, new Color4(startRed / 255f, startGreen / 255f, startBlue / 255f, 1), new Color4(endRed / 255f, endGreen / 255f, endBlue / 255f, 1)); @@ -330,7 +342,5 @@ namespace osu.Game.Beatmaps.Formats break; } } - - private string cleanFilename(string path) => path.Trim('"').ToStandardisedPath(); } } diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs index c3efb8c760..c4795a6931 100644 --- a/osu.Game/Beatmaps/Formats/Parsing.cs +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -11,7 +11,7 @@ namespace osu.Game.Beatmaps.Formats /// public static class Parsing { - public const int MAX_COORDINATE_VALUE = 65536; + public const int MAX_COORDINATE_VALUE = 131072; public const double MAX_PARSE_VALUE = int.MaxValue; diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index 5f1f0d1e40..31975157a0 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -26,11 +26,6 @@ namespace osu.Game.Beatmaps /// Texture Background { get; } - /// - /// Retrieves the video background file for this . - /// - VideoSprite Video { get; } - /// /// Retrieves the audio track for this . /// @@ -60,8 +55,9 @@ namespace osu.Game.Beatmaps /// /// The to create a playable for. /// The s to apply to the . + /// The maximum length in milliseconds to wait for load to complete. Defaults to 10,000ms. /// The converted . /// If could not be converted to . - IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null); + IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null); } } diff --git a/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs index 5237445640..ea23c49c4a 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs @@ -8,6 +8,8 @@ namespace osu.Game.Beatmaps.Legacy Background = 0, Fail = 1, Pass = 2, - Foreground = 3 + Foreground = 3, + Overlay = 4, + Video = 5 } } diff --git a/osu.Game/Beatmaps/Timing/BreakPeriod.cs b/osu.Game/Beatmaps/Timing/BreakPeriod.cs index 5d79c7a86b..bb8ae4a66a 100644 --- a/osu.Game/Beatmaps/Timing/BreakPeriod.cs +++ b/osu.Game/Beatmaps/Timing/BreakPeriod.cs @@ -32,6 +32,17 @@ namespace osu.Game.Beatmaps.Timing ///
public bool HasEffect => Duration >= MIN_BREAK_DURATION; + /// + /// Constructs a new break period. + /// + /// The start time of the break period. + /// The end time of the break period. + public BreakPeriod(double startTime, double endTime) + { + StartTime = startTime; + EndTime = endTime; + } + /// /// Whether this break contains a specified time. /// diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 6aba5257f5..ac399e37c4 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -1,28 +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.Audio.Track; -using osu.Framework.Graphics.Textures; -using osu.Game.Rulesets.Mods; using System; using System.Collections.Generic; -using osu.Game.Storyboards; -using System.IO; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; using osu.Framework.Statistics; -using osu.Game.IO.Serialization; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Skinning; -using osu.Framework.Graphics.Video; +using osu.Game.Storyboards; namespace osu.Game.Beatmaps { - public abstract class WorkingBeatmap : IWorkingBeatmap, IDisposable + public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; @@ -41,7 +40,7 @@ namespace osu.Game.Beatmaps BeatmapSetInfo = beatmapInfo.BeatmapSet; Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - track = new RecyclableLazy(() => GetTrack() ?? GetVirtualTrack()); + track = new RecyclableLazy(() => GetTrack() ?? GetVirtualTrack(1000)); background = new RecyclableLazy(GetBackground, BackgroundStillValid); waveform = new RecyclableLazy(GetWaveform); storyboard = new RecyclableLazy(GetStoryboard); @@ -50,7 +49,7 @@ namespace osu.Game.Beatmaps total_count.Value++; } - protected virtual Track GetVirtualTrack() + protected virtual Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; @@ -61,10 +60,10 @@ namespace osu.Game.Beatmaps switch (lastObject) { case null: - length = excess_length; + length = emptyLength; break; - case IHasEndTime endTime: + case IHasDuration endTime: length = endTime.EndTime + excess_length; break; @@ -76,21 +75,6 @@ namespace osu.Game.Beatmaps return AudioManager.Tracks.GetVirtual(length); } - /// - /// Saves the . - /// - /// The absolute path of the output file. - public string Save() - { - string directory = Path.Combine(Path.GetTempPath(), @"osu!"); - Directory.CreateDirectory(directory); - - var path = Path.Combine(directory, Guid.NewGuid().ToString().Replace("-", string.Empty) + ".json"); - using (var sw = new StreamWriter(path)) - sw.WriteLine(Beatmap.Serialize()); - return path; - } - /// /// Creates a to convert a for a specified . /// @@ -99,62 +83,122 @@ namespace osu.Game.Beatmaps /// The applicable . protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); - public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null) + public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null) { - mods ??= Array.Empty(); - - var rulesetInstance = ruleset.CreateInstance(); - - IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); - - // Check if the beatmap can be converted - if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert()) - throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); - - // Apply conversion mods - foreach (var mod in mods.OfType()) - mod.ApplyToBeatmapConverter(converter); - - // Convert - IBeatmap converted = converter.Convert(); - - // Apply difficulty mods - if (mods.Any(m => m is IApplicableToDifficulty)) + using (var cancellationSource = createCancellationTokenSource(timeout)) { - converted.BeatmapInfo = converted.BeatmapInfo.Clone(); - converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); + mods ??= Array.Empty(); - foreach (var mod in mods.OfType()) - mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); + var rulesetInstance = ruleset.CreateInstance(); + + IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); + + // Check if the beatmap can be converted + if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert()) + throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); + + // Apply conversion mods + foreach (var mod in mods.OfType()) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToBeatmapConverter(converter); + } + + // Convert + IBeatmap converted = converter.Convert(); + + // Apply difficulty mods + if (mods.Any(m => m is IApplicableToDifficulty)) + { + converted.BeatmapInfo = converted.BeatmapInfo.Clone(); + converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); + + foreach (var mod in mods.OfType()) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); + } + } + + IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + + processor?.PreProcess(); + + // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed + try + { + foreach (var obj in converted.HitObjects) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty, cancellationSource.Token); + } + } + catch (OperationCanceledException) + { + throw new BeatmapLoadTimeoutException(BeatmapInfo); + } + + foreach (var mod in mods.OfType()) + { + foreach (var obj in converted.HitObjects) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToHitObject(obj); + } + } + + processor?.PostProcess(); + + foreach (var mod in mods.OfType()) + { + cancellationSource.Token.ThrowIfCancellationRequested(); + mod.ApplyToBeatmap(converted); + } + + return converted; } - - IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); - - processor?.PreProcess(); - - // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed - foreach (var obj in converted.HitObjects) - obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty); - - foreach (var mod in mods.OfType()) - { - foreach (var obj in converted.HitObjects) - mod.ApplyToHitObject(obj); - } - - processor?.PostProcess(); - - foreach (var mod in mods.OfType()) - mod.ApplyToBeatmap(converted); - - return converted; } - public override string ToString() => BeatmapInfo.ToString(); + private CancellationTokenSource loadCancellation = new CancellationTokenSource(); - public bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + /// + /// Beings loading the contents of this asynchronously. + /// + public void BeginAsyncLoad() + { + loadBeatmapAsync(); + } - public Task LoadBeatmapAsync() => beatmapLoadTask ??= Task.Factory.StartNew(() => + /// + /// Cancels the asynchronous loading of the contents of this . + /// + public void CancelAsyncLoad() + { + loadCancellation?.Cancel(); + loadCancellation = new CancellationTokenSource(); + + if (beatmapLoadTask?.IsCompleted != true) + beatmapLoadTask = null; + } + + private CancellationTokenSource createCancellationTokenSource(TimeSpan? timeout) + { + if (Debugger.IsAttached) + // ignore timeout when debugger is attached (may be breakpointing / debugging). + return new CancellationTokenSource(); + + return new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(10)); + } + + private Task loadBeatmapAsync() => beatmapLoadTask ??= Task.Factory.StartNew(() => { // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); @@ -166,7 +210,11 @@ namespace osu.Game.Beatmaps b.BeatmapInfo = BeatmapInfo; return b; - }, beatmapCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + public override string ToString() => BeatmapInfo.ToString(); + + public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public IBeatmap Beatmap { @@ -174,16 +222,25 @@ namespace osu.Game.Beatmaps { try { - return LoadBeatmapAsync().Result; + return loadBeatmapAsync().Result; } - catch (TaskCanceledException) + catch (AggregateException ae) { + // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load + if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) + return null; + + Logger.Error(ae, "Beatmap failed to load"); + return null; + } + catch (Exception e) + { + Logger.Error(e, "Beatmap failed to load"); return null; } } } - private readonly CancellationTokenSource beatmapCancellation = new CancellationTokenSource(); protected abstract IBeatmap GetBeatmap(); private Task beatmapLoadTask; @@ -193,11 +250,7 @@ namespace osu.Game.Beatmaps protected abstract Texture GetBackground(); private readonly RecyclableLazy background; - public VideoSprite Video => GetVideo(); - - protected abstract VideoSprite GetVideo(); - - public bool TrackLoaded => track.IsResultAvailable; + public virtual bool TrackLoaded => track.IsResultAvailable; public Track Track => track.Value; protected abstract Track GetTrack(); private RecyclableLazy track; @@ -234,40 +287,11 @@ namespace osu.Game.Beatmaps ///
public virtual void RecycleTrack() => track.Recycle(); - #region Disposal - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private bool isDisposed; - - protected virtual void Dispose(bool isDisposing) - { - if (isDisposed) - return; - - isDisposed = true; - - // recycling logic is not here for the time being, as components which use - // retrieved objects from WorkingBeatmap may not hold a reference to the WorkingBeatmap itself. - // this should be fine as each retrieved component do have their own finalizers. - - // cancelling the beatmap load is safe for now since the retrieval is a synchronous - // operation. if we add an async retrieval method this may need to be reconsidered. - beatmapCancellation?.Cancel(); - total_count.Value--; - } - ~WorkingBeatmap() { - Dispose(false); + total_count.Value--; } - #endregion - public class RecyclableLazy { private Lazy lazy; @@ -311,5 +335,13 @@ namespace osu.Game.Beatmaps private void recreate() => lazy = new Lazy(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication); } + + private class BeatmapLoadTimeoutException : TimeoutException + { + public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo) + : base($"Timed out while loading beatmap ({beatmapInfo}).") + { + } + } } } diff --git a/osu.Game/Configuration/IntroSequence.cs b/osu.Game/Configuration/IntroSequence.cs index 1ee7da8bac..5672c44bbe 100644 --- a/osu.Game/Configuration/IntroSequence.cs +++ b/osu.Game/Configuration/IntroSequence.cs @@ -6,6 +6,7 @@ namespace osu.Game.Configuration public enum IntroSequence { Circles, + Welcome, Triangles, Random } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 947e864a87..268328272c 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.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.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -20,11 +21,11 @@ namespace osu.Game.Configuration Set(OsuSetting.Ruleset, 0, 0, int.MaxValue); Set(OsuSetting.Skin, 0, -1, int.MaxValue); - Set(OsuSetting.BeatmapDetailTab, BeatmapDetailTab.Details); + Set(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); Set(OsuSetting.ShowConvertedBeatmaps, true); Set(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); - Set(OsuSetting.DisplayStarsMaximum, 10.0, 0, 10, 0.1); + Set(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); Set(OsuSetting.SongSelectGroupingMode, GroupMode.All); Set(OsuSetting.SongSelectSortingMode, SortMode.Title); @@ -48,6 +49,7 @@ namespace osu.Game.Configuration }; Set(OsuSetting.ExternalLinkWarning, true); + Set(OsuSetting.PreferNoVideo, false); // Audio Set(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -69,7 +71,6 @@ namespace osu.Game.Configuration Set(OsuSetting.ShowFpsDisplay, false); Set(OsuSetting.ShowStoryboard, true); - Set(OsuSetting.ShowVideoBackground, true); Set(OsuSetting.BeatmapSkins, true); Set(OsuSetting.BeatmapHitsounds, true); @@ -78,15 +79,19 @@ namespace osu.Game.Configuration Set(OsuSetting.MenuParallax, true); // Gameplay - Set(OsuSetting.DimLevel, 0.3, 0, 1, 0.01); + Set(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); Set(OsuSetting.BlurLevel, 0, 0, 1, 0.01); Set(OsuSetting.LightenDuringBreaks, true); Set(OsuSetting.HitLighting, true); Set(OsuSetting.ShowInterface, true); + Set(OsuSetting.ShowProgressGraph, true); Set(OsuSetting.ShowHealthDisplayWhenCantFail, true); + Set(OsuSetting.FadePlayfieldWhenHealthLow, true); Set(OsuSetting.KeyOverlay, false); + Set(OsuSetting.PositionalHitSounds, true); + Set(OsuSetting.AlwaysPlayFirstComboBreak, true); Set(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth); Set(OsuSetting.FloatingComments, false); @@ -125,6 +130,35 @@ namespace osu.Game.Configuration public OsuConfigManager(Storage storage) : base(storage) { + Migrate(); + } + + public void Migrate() + { + // arrives as 2020.123.0 + var rawVersion = Get(OsuSetting.Version); + + if (rawVersion.Length < 6) + return; + + var pieces = rawVersion.Split('.'); + + // on a fresh install or when coming from a non-release build, execution will end here. + // we don't want to run migrations in such cases. + if (!int.TryParse(pieces[0], out int year)) return; + if (!int.TryParse(pieces[1], out int monthDay)) return; + + int combined = (year * 10000) + monthDay; + + if (combined < 20200305) + { + // the maximum value of this setting was changed. + // if we don't manually increase this, it causes song select to filter out beatmaps the user expects to see. + var maxStars = (BindableDouble)GetOriginalBindable(OsuSetting.DisplayStarsMaximum); + + if (maxStars.Value == 10) + maxStars.Value = maxStars.MaxValue; + } } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings @@ -145,12 +179,15 @@ namespace osu.Game.Configuration BlurLevel, LightenDuringBreaks, ShowStoryboard, - ShowVideoBackground, KeyOverlay, + PositionalHitSounds, + AlwaysPlayFirstComboBreak, ScoreMeter, FloatingComments, ShowInterface, + ShowProgressGraph, ShowHealthDisplayWhenCantFail, + FadePlayfieldWhenHealthLow, MouseDisableButtons, MouseDisableWheel, AudioOffset, @@ -182,6 +219,7 @@ namespace osu.Game.Configuration IncreaseFirstObjectVisibility, ScoreDisplayMode, ExternalLinkWarning, + PreferNoVideo, Scaling, ScalingPositionX, ScalingPositionY, diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs index b85ef9309d..156c4b1377 100644 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ b/osu.Game/Configuration/ScoreMeterType.cs @@ -18,5 +18,14 @@ namespace osu.Game.Configuration [Description("Hit Error (both)")] HitErrorBoth, + + [Description("Colour (left)")] + ColourLeft, + + [Description("Colour (right)")] + ColourRight, + + [Description("Colour (both)")] + ColourBoth, } } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 056fa8bcc0..fe487cb1d0 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -3,11 +3,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.UserInterface; using osu.Game.Overlays.Settings; namespace osu.Game.Configuration @@ -16,6 +16,10 @@ namespace osu.Game.Configuration /// An attribute to mark a bindable as being exposed to the user via settings controls. /// Can be used in conjunction with to automatically create UI controls. ///
+ /// + /// All controls with set will be placed first in ascending order. + /// All controls with no will come afterward in default order. + /// [MeansImplicitUse] [AttributeUsage(AttributeTargets.Property)] public class SettingSourceAttribute : Attribute @@ -24,33 +28,37 @@ namespace osu.Game.Configuration public string Description { get; } + public int? OrderPosition { get; } + public SettingSourceAttribute(string label, string description = null) { Label = label ?? string.Empty; Description = description ?? string.Empty; } + + public SettingSourceAttribute(string label, string description, int orderPosition) + : this(label, description) + { + OrderPosition = orderPosition; + } } public static class SettingSourceExtensions { public static IEnumerable CreateSettingsControls(this object obj) { - foreach (var property in obj.GetType().GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance)) + foreach (var (attr, property) in obj.GetOrderedSettingsSourceProperties()) { - var attr = property.GetCustomAttribute(true); + object value = property.GetValue(obj); - if (attr == null) - continue; - - var prop = property.GetValue(obj); - - switch (prop) + switch (value) { case BindableNumber bNumber: yield return new SettingsSlider { LabelText = attr.Label, - Bindable = bNumber + Bindable = bNumber, + KeyboardStep = 0.1f, }; break; @@ -59,7 +67,8 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, - Bindable = bNumber + Bindable = bNumber, + KeyboardStep = 0.1f, }; break; @@ -95,16 +104,40 @@ namespace osu.Game.Configuration var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdown = (Drawable)Activator.CreateInstance(dropdownType); - dropdown.GetType().GetProperty(nameof(IHasCurrentValue.Current))?.SetValue(dropdown, obj); + dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); + dropdownType.GetProperty(nameof(SettingsDropdown.Bindable))?.SetValue(dropdown, bindable); yield return dropdown; break; default: - throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} was attached to an unsupported type ({prop})"); + throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} was attached to an unsupported type ({value})"); } } } + + public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetSettingsSourceProperties(this object obj) + { + foreach (var property in obj.GetType().GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance)) + { + var attr = property.GetCustomAttribute(true); + + if (attr == null) + continue; + + yield return (attr, property); + } + } + + public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetOrderedSettingsSourceProperties(this object obj) + { + var original = obj.GetSettingsSourceProperties(); + + var orderedRelative = original.Where(attr => attr.Item1.OrderPosition != null).OrderBy(attr => attr.Item1.OrderPosition); + var unordered = original.Except(orderedRelative); + + return orderedRelative.Concat(unordered); + } } } diff --git a/osu.Game/Configuration/StorageConfigManager.cs b/osu.Game/Configuration/StorageConfigManager.cs new file mode 100644 index 0000000000..929f8f22ad --- /dev/null +++ b/osu.Game/Configuration/StorageConfigManager.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.Configuration; +using osu.Framework.Platform; + +namespace osu.Game.Configuration +{ + public class StorageConfigManager : IniConfigManager + { + protected override string Filename => "storage.ini"; + + public StorageConfigManager(Storage storage) + : base(storage) + { + } + + protected override void InitialiseDefaults() + { + base.InitialiseDefaults(); + + Set(StorageConfig.FullPath, string.Empty); + } + } + + public enum StorageConfig + { + FullPath, + } +} diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 45fe034a70..915d980d24 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -11,6 +11,7 @@ using Humanizer; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using osu.Framework; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; @@ -21,6 +22,7 @@ using osu.Game.IO.Archives; using osu.Game.IPC; using osu.Game.Overlays.Notifications; using osu.Game.Utils; +using SharpCompress.Archives.Zip; using SharpCompress.Common; using FileInfo = osu.Game.IO.FileInfo; @@ -34,7 +36,7 @@ namespace osu.Game.Database /// The associated file join type. public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete - where TFileModel : INamedFileInfo, new() + where TFileModel : class, INamedFileInfo, new() { private const int import_queue_request_concurrency = 1; @@ -53,16 +55,20 @@ namespace osu.Game.Database public Action PostNotification { protected get; set; } /// - /// Fired when a new becomes available in the database. + /// Fired when a new or updated becomes available in the database. /// This is not guaranteed to run on the update thread. /// - public event Action ItemAdded; + public IBindable> ItemUpdated => itemUpdated; + + private readonly Bindable> itemUpdated = new Bindable>(); /// /// Fired when a is removed from the database. /// This is not guaranteed to run on the update thread. /// - public event Action ItemRemoved; + public IBindable> ItemRemoved => itemRemoved; + + private readonly Bindable> itemRemoved = new Bindable>(); public virtual string[] HandledExtensions => new[] { ".zip" }; @@ -77,13 +83,17 @@ namespace osu.Game.Database // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) private ArchiveImportIPCChannel ipc; + private readonly Storage exportStorage; + protected ArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, MutableDatabaseBackedStoreWithFileIncludes modelStore, IIpcHost importHost = null) { ContextFactory = contextFactory; ModelStore = modelStore; - ModelStore.ItemAdded += item => handleEvent(() => ItemAdded?.Invoke(item)); - ModelStore.ItemRemoved += s => handleEvent(() => ItemRemoved?.Invoke(s)); + ModelStore.ItemUpdated += item => handleEvent(() => itemUpdated.Value = new WeakReference(item)); + ModelStore.ItemRemoved += item => handleEvent(() => itemRemoved.Value = new WeakReference(item)); + + exportStorage = storage.GetStorageForDirectory("exports"); Files = new FileStore(contextFactory, storage); @@ -222,9 +232,8 @@ namespace osu.Game.Database { model = CreateModel(archive); - if (model == null) return Task.FromResult(null); - - model.Hash = computeHash(archive); + if (model == null) + return Task.FromResult(null); } catch (TaskCanceledException) { @@ -246,7 +255,7 @@ namespace osu.Game.Database /// protected abstract string[] HashableFileTypes { get; } - protected static void LogForModel(TModel model, string message, Exception e = null) + internal static void LogForModel(TModel model, string message, Exception e = null) { string prefix = $"[{(model?.Hash ?? "?????").Substring(0, 5)}]"; @@ -262,18 +271,24 @@ namespace osu.Game.Database /// /// In the case of no matching files, a hash will be generated from the passed archive's . /// - private string computeHash(ArchiveReader reader) + private string computeHash(TModel item, ArchiveReader reader = null) { // for now, concatenate all .osu files in the set to create a unique hash. MemoryStream hashable = new MemoryStream(); - foreach (string file in reader.Filenames.Where(f => HashableFileTypes.Any(f.EndsWith))) + foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(f.Filename.EndsWith)).OrderBy(f => f.Filename)) { - using (Stream s = reader.GetStream(file)) + using (Stream s = Files.Store.GetStream(file.FileInfo.StoragePath)) s.CopyTo(hashable); } - return hashable.Length > 0 ? hashable.ComputeSHA2Hash() : reader.Name.ComputeSHA2Hash(); + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + if (reader != null) + return reader.Name.ComputeSHA2Hash(); + + return item.Hash; } /// @@ -303,6 +318,7 @@ namespace osu.Game.Database LogForModel(item, "Beginning import..."); item.Files = archive != null ? createFileInfos(archive, Files) : new List(); + item.Hash = computeHash(item, archive); await Populate(item, archive, cancellationToken); @@ -316,7 +332,7 @@ namespace osu.Game.Database if (existing != null) { - if (CanUndelete(existing, item)) + if (CanReuseExisting(existing, item)) { Undelete(existing); LogForModel(item, $"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); @@ -358,12 +374,64 @@ namespace osu.Game.Database return item; }, cancellationToken, TaskCreationOptions.HideScheduler, import_scheduler).Unwrap(); + /// + /// Exports an item to a legacy (.zip based) package. + /// + /// The item to export. + public void Export(TModel item) + { + var retrievedItem = ModelStore.ConsumableItems.FirstOrDefault(s => s.ID == item.ID); + + if (retrievedItem == null) + throw new ArgumentException("Specified model could not be found", nameof(item)); + + using (var archive = ZipArchive.Create()) + { + foreach (var file in retrievedItem.Files) + archive.AddEntry(file.Filename, Files.Storage.GetStream(file.FileInfo.StoragePath)); + + using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) + archive.SaveTo(outputStream); + + exportStorage.OpenInNativeExplorer(); + } + } + + public void UpdateFile(TModel model, TFileModel file, Stream contents) + { + using (var usage = ContextFactory.GetForWrite()) + { + // Dereference the existing file info, since the file model will be removed. + Files.Dereference(file.FileInfo); + + // Remove the file model. + usage.Context.Set().Remove(file); + + // Add the new file info and containing file model. + model.Files.Remove(file); + model.Files.Add(new TFileModel + { + Filename = file.Filename, + FileInfo = Files.Add(contents) + }); + + Update(model); + } + } + /// /// Perform an update of the specified item. - /// TODO: Support file changes. + /// TODO: Support file additions/removals. /// /// The item to update. - public void Update(TModel item) => ModelStore.Update(item); + public void Update(TModel item) + { + using (ContextFactory.GetForWrite()) + { + item.Hash = computeHash(item); + ModelStore.Update(item); + } + } /// /// Delete an item from the manager. @@ -591,13 +659,29 @@ namespace osu.Game.Database protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash); /// - /// After an existing is found during an import process, the default behaviour is to restore the existing + /// After an existing is found during an import process, the default behaviour is to use/restore the existing /// item and skip the import. This method allows changing that behaviour. /// /// The existing model. /// The newly imported model. /// Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import. - protected virtual bool CanUndelete(TModel existing, TModel import) => true; + protected virtual bool CanReuseExisting(TModel existing, TModel import) => + // 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 FileInfo IDs. + getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); + + private IEnumerable getIDs(List files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.FileInfo.ID; + } + + private IEnumerable getFilenames(List files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.Filename; + } private DbSet queryModel() => ContextFactory.Get().Set(); @@ -669,5 +753,12 @@ namespace osu.Game.Database } #endregion + + private string getValidFilename(string filename) + { + foreach (char c in Path.GetInvalidFileNameChars()) + filename = filename.Replace(c, '_'); + return filename; + } } } diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs index 1ed5fb3268..1cceb59b11 100644 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ b/osu.Game/Database/DatabaseContextFactory.cs @@ -160,5 +160,13 @@ namespace osu.Game.Database } } } + + public void FlushConnections() + { + foreach (var context in threadContexts.Values) + context.Dispose(); + + recycleThreadContexts(); + } } } diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/DownloadableArchiveModelManager.cs index 5f688c149d..8f469ca590 100644 --- a/osu.Game/Database/DownloadableArchiveModelManager.cs +++ b/osu.Game/Database/DownloadableArchiveModelManager.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using osu.Framework.Bindables; namespace osu.Game.Database { @@ -21,11 +22,15 @@ namespace osu.Game.Database /// The associated file join type. public abstract class DownloadableArchiveModelManager : ArchiveModelManager, IModelDownloader where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable - where TFileModel : INamedFileInfo, new() + where TFileModel : class, INamedFileInfo, new() { - public event Action> DownloadBegan; + public IBindable>> DownloadBegan => downloadBegan; - public event Action> DownloadFailed; + private readonly Bindable>> downloadBegan = new Bindable>>(); + + public IBindable>> DownloadFailed => downloadFailed; + + private readonly Bindable>> downloadFailed = new Bindable>>(); private readonly IAPIProvider api; @@ -81,7 +86,7 @@ namespace osu.Game.Database // for now a failed import will be marked as a failed download for simplicity. if (!imported.Any()) - DownloadFailed?.Invoke(request); + downloadFailed.Value = new WeakReference>(request); currentDownloads.Remove(request); }, TaskCreationOptions.LongRunning); @@ -92,8 +97,6 @@ namespace osu.Game.Database notification.CancelRequested += () => { request.Cancel(); - currentDownloads.Remove(request); - notification.State = ProgressNotificationState.Cancelled; return true; }; @@ -102,18 +105,19 @@ namespace osu.Game.Database api.PerformAsync(request); - DownloadBegan?.Invoke(request); + downloadBegan.Value = new WeakReference>(request); return true; void triggerFailure(Exception error) { - DownloadFailed?.Invoke(request); + currentDownloads.Remove(request); - if (error is OperationCanceledException) return; + downloadFailed.Value = new WeakReference>(request); notification.State = ProgressNotificationState.Cancelled; - Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!"); - currentDownloads.Remove(request); + + if (!(error is OperationCanceledException)) + Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!"); } } diff --git a/osu.Game/Database/IModelDownloader.cs b/osu.Game/Database/IModelDownloader.cs index 17f1ccab06..0cb633280e 100644 --- a/osu.Game/Database/IModelDownloader.cs +++ b/osu.Game/Database/IModelDownloader.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. -using osu.Game.Online.API; using System; +using osu.Game.Online.API; +using osu.Framework.Bindables; namespace osu.Game.Database { @@ -15,13 +16,15 @@ namespace osu.Game.Database { /// /// Fired when a download begins. + /// This is NOT run on the update thread and should be scheduled. /// - event Action> DownloadBegan; + IBindable>> DownloadBegan { get; } /// /// Fired when a download is interrupted, either due to user cancellation or failure. + /// This is NOT run on the update thread and should be scheduled. /// - event Action> DownloadFailed; + IBindable>> DownloadFailed { get; } /// /// Checks whether a given is already available in the local store. diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs index 1bdbbb48e6..7f7e5565f1 100644 --- a/osu.Game/Database/IModelManager.cs +++ b/osu.Game/Database/IModelManager.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; namespace osu.Game.Database { @@ -9,11 +10,11 @@ namespace osu.Game.Database /// Represents a model manager that publishes events when s are added or removed. /// /// The model type. - public interface IModelManager + public interface IModelManager where TModel : class { - event Action ItemAdded; + IBindable> ItemUpdated { get; } - event Action ItemRemoved; + IBindable> ItemRemoved { get; } } } diff --git a/osu.Game/Database/MutableDatabaseBackedStore.cs b/osu.Game/Database/MutableDatabaseBackedStore.cs index 4ca1eef989..c9d0c4bc41 100644 --- a/osu.Game/Database/MutableDatabaseBackedStore.cs +++ b/osu.Game/Database/MutableDatabaseBackedStore.cs @@ -16,7 +16,14 @@ namespace osu.Game.Database public abstract class MutableDatabaseBackedStore : DatabaseBackedStore where T : class, IHasPrimaryKey, ISoftDelete { - public event Action ItemAdded; + /// + /// Fired when an item was added or updated. + /// + public event Action ItemUpdated; + + /// + /// Fired when an item was removed. + /// public event Action ItemRemoved; protected MutableDatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null) @@ -41,7 +48,7 @@ namespace osu.Game.Database context.Attach(item); } - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); } /// @@ -53,8 +60,7 @@ namespace osu.Game.Database using (var usage = ContextFactory.GetForWrite()) usage.Context.Update(item); - ItemRemoved?.Invoke(item); - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); } /// @@ -91,7 +97,7 @@ namespace osu.Game.Database item.DeletePending = false; } - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); return true; } diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs new file mode 100644 index 0000000000..1790eb608e --- /dev/null +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Input.Bindings; +using osu.Framework.Threading; + +namespace osu.Game.Extensions +{ + public static class DrawableExtensions + { + /// + /// Helper method that is used while doesn't support repetitions of . + /// Simulates repetitions by continually invoking a delegate according to the default key repeat rate. + /// + /// + /// The returned delegate can be cancelled to stop repeat events from firing (usually in ). + /// + /// The which is handling the repeat. + /// The to schedule repetitions on. + /// The to be invoked once immediately and with every repetition. + /// A which can be cancelled to stop the repeat events from firing. + public static ScheduledDelegate BeginKeyRepeat(this IKeyBindingHandler handler, Scheduler scheduler, Action action) + { + action(); + + ScheduledDelegate repeatDelegate = new ScheduledDelegate(action, handler.Time.Current + 250, 70); + scheduler.Add(repeatDelegate); + return repeatDelegate; + } + } +} diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs new file mode 100644 index 0000000000..b940c7498b --- /dev/null +++ b/osu.Game/Extensions/WebRequestExtensions.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.IO.Network; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Extensions +{ + public static class WebRequestExtensions + { + /// + /// Add a pagination cursor to the web request in the format required by osu-web. + /// + public static void AddCursor(this WebRequest webRequest, Cursor cursor) + { + cursor?.Properties.ForEach(x => + { + webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString()); + }); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 6d88808150..27027202ce 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osuTK; using osuTK.Graphics; using System; @@ -29,8 +29,33 @@ namespace osu.Game.Graphics.Backgrounds /// private const float edge_smoothness = 1; - public Color4 ColourLight = Color4.White; - public Color4 ColourDark = Color4.Black; + private Color4 colourLight = Color4.White; + + public Color4 ColourLight + { + get => colourLight; + set + { + if (colourLight == value) return; + + colourLight = value; + updateColours(); + } + } + + private Color4 colourDark = Color4.Black; + + public Color4 ColourDark + { + get => colourDark; + set + { + if (colourDark == value) return; + + colourDark = value; + updateColours(); + } + } /// /// Whether we want to expire triangles as they exit our draw area completely. @@ -104,7 +129,7 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); if (CreateNewTriangles) addTriangles(false); @@ -151,7 +176,8 @@ namespace osu.Game.Graphics.Backgrounds TriangleParticle particle = CreateTriangle(); particle.Position = new Vector2(RNG.NextSingle(), randomY ? RNG.NextSingle() : 1); - particle.Colour = CreateTriangleShade(); + particle.ColourShade = RNG.NextSingle(); + particle.Colour = CreateTriangleShade(particle.ColourShade); return particle; } @@ -167,8 +193,8 @@ namespace osu.Game.Graphics.Backgrounds float u1 = 1 - RNG.NextSingle(); //uniform(0,1] random floats float u2 = 1 - RNG.NextSingle(); - float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); //random normal(0,1) - var scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); //random normal(mean,stdDev^2) + float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1) + var scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) return new TriangleParticle { Scale = scale }; } @@ -177,7 +203,17 @@ namespace osu.Game.Graphics.Backgrounds /// Creates a shade of colour for the triangles. /// /// The colour. - protected virtual Color4 CreateTriangleShade() => Interpolation.ValueAt(RNG.NextSingle(), ColourDark, ColourLight, 0, 1); + protected virtual Color4 CreateTriangleShade(float shade) => Interpolation.ValueAt(shade, colourDark, colourLight, 0, 1); + + private void updateColours() + { + for (int i = 0; i < parts.Count; i++) + { + TriangleParticle newParticle = parts[i]; + newParticle.Colour = CreateTriangleShade(newParticle.ColourShade); + parts[i] = newParticle; + } + } protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this); @@ -264,6 +300,12 @@ namespace osu.Game.Graphics.Backgrounds /// public Vector2 Position; + /// + /// The colour shade of the triangle. + /// This is needed for colour recalculation of visible triangles when or is changed. + /// + public float ColourShade; + /// /// The colour of the triangle. /// diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index b9ef279f5c..df063f57d5 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -18,7 +18,7 @@ namespace osu.Game.Graphics.Containers private TimingControlPoint lastTimingPoint; /// - /// The amount of time before a beat we should fire . + /// The amount of time before a beat we should fire . /// This allows for adding easing to animations that may be synchronised to the beat. /// protected double EarlyActivationMilliseconds; @@ -39,13 +39,9 @@ namespace osu.Game.Graphics.Containers public int Divisor { get; set; } = 1; /// - /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. + /// An optional minimum beat length. Any beat length below this will be multiplied by two until valid. /// - private const double default_beat_length = 60000.0 / 60.0; - - private TimingControlPoint defaultTiming; - private EffectControlPoint defaultEffect; - private TrackAmplitudes defaultAmplitudes; + public double MinimumBeatLength { get; set; } protected bool IsBeatSyncedWithTrack { get; private set; } @@ -54,9 +50,9 @@ namespace osu.Game.Graphics.Containers Track track = null; IBeatmap beatmap = null; - double currentTrackTime; - TimingControlPoint timingPoint; - EffectControlPoint effectPoint; + double currentTrackTime = 0; + TimingControlPoint timingPoint = null; + EffectControlPoint effectPoint = null; if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) { @@ -64,31 +60,28 @@ namespace osu.Game.Graphics.Containers beatmap = Beatmap.Value.Beatmap; } - if (track != null && beatmap != null && track.IsRunning) + if (track != null && beatmap != null && track.IsRunning && track.Length > 0) { - currentTrackTime = track.Length > 0 ? track.CurrentTime + EarlyActivationMilliseconds : Clock.CurrentTime; + currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds; timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); - - if (timingPoint.BeatLength == 0) - { - IsBeatSyncedWithTrack = false; - return; - } - - IsBeatSyncedWithTrack = true; } - else + + IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0; + + if (timingPoint == null || !IsBeatSyncedWithTrack) { - IsBeatSyncedWithTrack = false; currentTrackTime = Clock.CurrentTime; - timingPoint = defaultTiming; - effectPoint = defaultEffect; + timingPoint = TimingControlPoint.DEFAULT; + effectPoint = EffectControlPoint.DEFAULT; } double beatLength = timingPoint.BeatLength / Divisor; + while (beatLength < MinimumBeatLength) + beatLength *= 2; + int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (effectPoint.OmitFirstBarLine ? 1 : 0); // The beats before the start of the first control point are off by 1, this should do the trick @@ -101,11 +94,11 @@ namespace osu.Game.Graphics.Containers TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (timingPoint.Equals(lastTimingPoint) && beatIndex == lastBeat) + if (timingPoint == lastTimingPoint && beatIndex == lastBeat) return; using (BeginDelayedSequence(-TimeSinceLastBeat, true)) - OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? defaultAmplitudes); + OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); lastBeat = beatIndex; lastTimingPoint = timingPoint; @@ -115,27 +108,9 @@ namespace osu.Game.Graphics.Containers private void load(IBindable beatmap) { Beatmap.BindTo(beatmap); - - defaultTiming = new TimingControlPoint - { - BeatLength = default_beat_length, - }; - - defaultEffect = new EffectControlPoint - { - KiaiMode = false, - OmitFirstBarLine = false - }; - - defaultAmplitudes = new TrackAmplitudes - { - FrequencyAmplitudes = new float[256], - LeftChannel = 0, - RightChannel = 0 - }; } - protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { } } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 9735f6373d..e3a9a5fe9d 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -63,7 +63,7 @@ namespace osu.Game.Graphics.Containers } public void AddUserLink(User user, Action creationParameters = null) - => createLink(AddText(user.Username, creationParameters), new LinkDetails(LinkAction.OpenUserProfile, user.Id.ToString()), "View Profile"); + => createLink(AddText(user.Username, creationParameters), new LinkDetails(LinkAction.OpenUserProfile, user.Id.ToString()), "view profile"); private void createLink(IEnumerable drawables, LinkDetails link, string tooltipText, Action action = null) { diff --git a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs index 23015e8bf5..dadd7d5240 100644 --- a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs +++ b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs @@ -4,7 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Screens.Menu; using osuTK; diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index facf70b47a..93ac69bdbf 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -76,12 +76,12 @@ namespace osu.Game.Graphics.Containers return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (closeOnMouseUp && !base.ReceivePositionalInputAt(e.ScreenSpaceMousePosition)) Hide(); - return base.OnMouseUp(e); + base.OnMouseUp(e); } public virtual bool OnPressed(GlobalAction action) @@ -99,7 +99,9 @@ namespace osu.Game.Graphics.Containers return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } protected override void UpdateState(ValueChangedEvent state) { diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs new file mode 100644 index 0000000000..47aed1c500 --- /dev/null +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Graphics.Containers +{ + public abstract class OsuRearrangeableListContainer : RearrangeableListContainer + { + /// + /// Whether any item is currently being dragged. Used to hide other items' drag handles. + /// + private readonly BindableBool playlistDragActive = new BindableBool(); + + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + + protected sealed override RearrangeableListItem CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d => + { + d.PlaylistDragActive.BindTo(playlistDragActive); + }); + + protected abstract OsuRearrangeableListItem CreateOsuDrawable(TModel item); + } +} diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs new file mode 100644 index 0000000000..29553954fe --- /dev/null +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.Containers +{ + public abstract class OsuRearrangeableListItem : RearrangeableListItem + { + public const float FADE_DURATION = 100; + + /// + /// Whether any item is currently being dragged. Used to hide other items' drag handles. + /// + public readonly BindableBool PlaylistDragActive = new BindableBool(); + + private Color4 handleColour = Color4.White; + + /// + /// The colour of the drag handle. + /// + protected Color4 HandleColour + { + get => handleColour; + set + { + if (handleColour == value) + return; + + handleColour = value; + + if (handle != null) + handle.Colour = value; + } + } + + /// + /// Whether the drag handle should be shown. + /// + protected virtual bool ShowDragHandle => true; + + private PlaylistItemHandle handle; + + protected OsuRearrangeableListItem(TModel item) + : base(item) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Container handleContainer; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new[] + { + handleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5 }, + Child = handle = new PlaylistItemHandle + { + Size = new Vector2(12), + Colour = HandleColour, + AlwaysPresent = true, + Alpha = 0 + } + }, + CreateContent() + } + }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }; + + if (!ShowDragHandle) + handleContainer.Alpha = 0; + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (!base.OnDragStart(e)) + return false; + + PlaylistDragActive.Value = true; + return true; + } + + protected override void OnDragEnd(DragEndEvent e) + { + PlaylistDragActive.Value = false; + base.OnDragEnd(e); + } + + protected override bool IsDraggableAt(Vector2 screenSpacePos) => handle.HandlingDrag; + + protected override bool OnHover(HoverEvent e) + { + handle.UpdateHoverState(IsDragged || !PlaylistDragActive.Value); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) => handle.UpdateHoverState(false); + + protected abstract Drawable CreateContent(); + + public class PlaylistItemHandle : SpriteIcon + { + public bool HandlingDrag { get; private set; } + private bool isHovering; + + public PlaylistItemHandle() + { + Icon = FontAwesome.Solid.Bars; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + base.OnMouseDown(e); + + HandlingDrag = true; + UpdateHoverState(isHovering); + + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + + HandlingDrag = false; + UpdateHoverState(isHovering); + } + + public void UpdateHoverState(bool hovering) + { + isHovering = hovering; + + if (isHovering || HandlingDrag) + this.FadeIn(FADE_DURATION); + else + this.FadeOut(FADE_DURATION); + } + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index ab72276ad0..d504a11b22 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -14,6 +14,9 @@ namespace osu.Game.Graphics.Containers { public class OsuScrollContainer : ScrollContainer { + public const float SCROLL_BAR_HEIGHT = 10; + public const float SCROLL_BAR_PADDING = 3; + /// /// Allows controlling the scroll bar from any position in the container using the right mouse button. /// Uses the value of to smoothly scroll to the dragged location. @@ -50,15 +53,15 @@ namespace osu.Game.Graphics.Containers return base.OnMouseDown(e); } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { if (rightMouseDragging) { scrollFromMouseEvent(e); - return true; + return; } - return base.OnDrag(e); + base.OnDrag(e); } protected override bool OnDragStart(DragStartEvent e) @@ -72,15 +75,15 @@ namespace osu.Game.Graphics.Containers return base.OnDragStart(e); } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { if (rightMouseDragging) { rightMouseDragging = false; - return true; + return; } - return base.OnDragEnd(e); + base.OnDragEnd(e); } protected override bool OnScroll(ScrollEvent e) @@ -96,8 +99,6 @@ namespace osu.Game.Graphics.Containers protected class OsuScrollbar : ScrollbarContainer { - private const float dim_size = 10; - private Color4 hoverColour; private Color4 defaultColour; private Color4 highlightColour; @@ -123,8 +124,6 @@ namespace osu.Game.Graphics.Containers Masking = true; Child = box = new Box { RelativeSizeAxes = Axes.Both }; - - ResizeTo(1); } [BackgroundDependencyLoader] @@ -137,7 +136,7 @@ namespace osu.Game.Graphics.Containers public override void ResizeTo(float val, int duration = 0, Easing easing = Easing.None) { - Vector2 size = new Vector2(dim_size) + Vector2 size = new Vector2(SCROLL_BAR_HEIGHT) { [(int)ScrollDirection] = val }; @@ -159,18 +158,18 @@ namespace osu.Game.Graphics.Containers { if (!base.OnMouseDown(e)) return false; - //note that we are changing the colour of the box here as to not interfere with the hover effect. + // note that we are changing the colour of the box here as to not interfere with the hover effect. box.FadeColour(highlightColour, 100); return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button != MouseButton.Left) return false; + if (e.Button != MouseButton.Left) return; box.FadeColour(Color4.White, 100); - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs index bf743b90ed..4cd3934cde 100644 --- a/osu.Game/Graphics/Containers/ParallaxContainer.cs +++ b/osu.Game/Graphics/Containers/ParallaxContainer.cs @@ -9,7 +9,7 @@ using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 9d886c457f..d739f56828 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -3,9 +3,11 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; namespace osu.Game.Graphics.Containers { @@ -15,12 +17,7 @@ namespace osu.Game.Graphics.Containers public class SectionsContainer : Container where T : Drawable { - private Drawable expandableHeader, fixedHeader, footer, headerBackground; - private readonly OsuScrollContainer scrollContainer; - private readonly Container headerBackgroundContainer; - private readonly FlowContainer scrollContentContainer; - - protected override Container Content => scrollContentContainer; + public Bindable SelectedSection { get; } = new Bindable(); public Drawable ExpandableHeader { @@ -82,6 +79,7 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Clear(); headerBackground = value; + if (value == null) return; headerBackgroundContainer.Add(headerBackground); @@ -90,15 +88,37 @@ namespace osu.Game.Graphics.Containers } } - public Bindable SelectedSection { get; } = new Bindable(); + protected override Container Content => scrollContentContainer; - protected virtual FlowContainer CreateScrollContentContainer() - => new FillFlowContainer + private readonly OsuScrollContainer scrollContainer; + private readonly Container headerBackgroundContainer; + private readonly MarginPadding originalSectionsMargin; + private Drawable expandableHeader, fixedHeader, footer, headerBackground; + private FlowContainer scrollContentContainer; + + private float headerHeight, footerHeight; + + private float lastKnownScroll; + + public SectionsContainer() + { + AddRangeInternal(new Drawable[] { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - }; + scrollContainer = CreateScrollContainer().With(s => + { + s.RelativeSizeAxes = Axes.Both; + s.Masking = true; + s.ScrollbarVisible = false; + s.Child = scrollContentContainer = CreateScrollContentContainer(); + }), + headerBackgroundContainer = new Container + { + RelativeSizeAxes = Axes.X + } + }); + + originalSectionsMargin = scrollContentContainer.Margin; + } public override void Add(T drawable) { @@ -108,52 +128,35 @@ namespace osu.Game.Graphics.Containers footerHeight = float.NaN; } - private float headerHeight, footerHeight; - private readonly MarginPadding originalSectionsMargin; - - private void updateSectionsMargin() - { - if (!Children.Any()) return; - - var newMargin = originalSectionsMargin; - newMargin.Top += headerHeight; - newMargin.Bottom += footerHeight; - - scrollContentContainer.Margin = newMargin; - } - - public SectionsContainer() - { - AddInternal(scrollContainer = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - ScrollbarVisible = false, - Children = new Drawable[] { scrollContentContainer = CreateScrollContentContainer() } - }); - AddInternal(headerBackgroundContainer = new Container - { - RelativeSizeAxes = Axes.X - }); - originalSectionsMargin = scrollContentContainer.Margin; - } - - public void ScrollTo(Drawable section) => scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0)); + public void ScrollTo(Drawable section) => + scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0)); public void ScrollToTop() => scrollContainer.ScrollTo(0); - public override void InvalidateFromChild(Invalidation invalidation, Drawable source = null) - { - base.InvalidateFromChild(invalidation, source); + [NotNull] + protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); - if ((invalidation & Invalidation.DrawSize) != 0) + [NotNull] + protected virtual FlowContainer CreateScrollContentContainer() => + new FillFlowContainer { - if (source == ExpandableHeader) //We need to recalculate the positions if the ExpandableHeader changed its size - lastKnownScroll = -1; - } - } + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }; - private float lastKnownScroll; + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + var result = base.OnInvalidate(invalidation, source); + + if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0) + { + lastKnownScroll = -1; + result = true; + } + + return result; + } protected override void UpdateAfterChildren() { @@ -186,24 +189,30 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Height = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0; - T bestMatch = null; - float minDiff = float.MaxValue; float scrollOffset = FixedHeader?.LayoutSize.Y ?? 0; + Func diff = section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset; - foreach (var section in Children) + if (scrollContainer.IsScrolledToEnd()) { - float diff = Math.Abs(scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset); - - if (diff < minDiff) - { - minDiff = diff; - bestMatch = section; - } + SelectedSection.Value = Children.LastOrDefault(); + } + else + { + SelectedSection.Value = Children.TakeWhile(section => diff(section) <= 0).LastOrDefault() + ?? Children.FirstOrDefault(); } - - if (bestMatch != null) - SelectedSection.Value = bestMatch; } } + + private void updateSectionsMargin() + { + if (!Children.Any()) return; + + var newMargin = originalSectionsMargin; + newMargin.Top += headerHeight; + newMargin.Bottom += footerHeight; + + scrollContentContainer.Margin = newMargin; + } } } diff --git a/osu.Game/Graphics/Containers/UserDimContainer.cs b/osu.Game/Graphics/Containers/UserDimContainer.cs index 65c104b92f..39c1fdad52 100644 --- a/osu.Game/Graphics/Containers/UserDimContainer.cs +++ b/osu.Game/Graphics/Containers/UserDimContainer.cs @@ -40,7 +40,7 @@ namespace osu.Game.Graphics.Containers /// /// Whether player is in break time. - /// Must be bound to to allow for dim adjustments in gameplay. + /// Must be bound to to allow for dim adjustments in gameplay. /// public readonly IBindable IsBreakTime = new Bindable(); @@ -55,8 +55,6 @@ namespace osu.Game.Graphics.Containers protected Bindable ShowStoryboard { get; private set; } - protected Bindable ShowVideo { get; private set; } - private float breakLightening => LightenDuringBreaks.Value && IsBreakTime.Value ? BREAK_LIGHTEN_AMOUNT : 0; protected float DimLevel => Math.Max(EnableUserDim.Value && !IgnoreUserSettings.Value ? (float)UserDimLevel.Value - breakLightening : 0, 0); @@ -79,14 +77,12 @@ namespace osu.Game.Graphics.Containers UserDimLevel = config.GetBindable(OsuSetting.DimLevel); LightenDuringBreaks = config.GetBindable(OsuSetting.LightenDuringBreaks); ShowStoryboard = config.GetBindable(OsuSetting.ShowStoryboard); - ShowVideo = config.GetBindable(OsuSetting.ShowVideoBackground); EnableUserDim.ValueChanged += _ => UpdateVisuals(); UserDimLevel.ValueChanged += _ => UpdateVisuals(); LightenDuringBreaks.ValueChanged += _ => UpdateVisuals(); IsBreakTime.ValueChanged += _ => UpdateVisuals(); ShowStoryboard.ValueChanged += _ => UpdateVisuals(); - ShowVideo.ValueChanged += _ => UpdateVisuals(); StoryboardReplacesBackground.ValueChanged += _ => UpdateVisuals(); IgnoreUserSettings.ValueChanged += _ => UpdateVisuals(); } diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs index 5a83d8e4ce..fd8f016860 100644 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ b/osu.Game/Graphics/Cursor/MenuCursor.cs @@ -13,7 +13,7 @@ using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; -using osuTK.Input; +using osu.Framework.Utils; namespace osu.Game.Graphics.Cursor { @@ -55,7 +55,7 @@ namespace osu.Game.Graphics.Cursor if (dragRotationState == DragRotationState.Rotating && distance > 0) { Vector2 offset = e.MousePosition - positionMouseDown; - float degrees = (float)MathHelper.RadiansToDegrees(Math.Atan2(-offset.X, offset.Y)) + 24.3f; + 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; @@ -73,17 +73,15 @@ namespace osu.Game.Graphics.Cursor protected override bool OnMouseDown(MouseDownEvent e) { // only trigger animation for main mouse buttons - if (e.Button <= MouseButton.Right) - { - activeCursor.Scale = new Vector2(1); - activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); + activeCursor.Scale = new Vector2(1); + activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - activeCursor.AdditiveLayer.Alpha = 0; - activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - } + activeCursor.AdditiveLayer.Alpha = 0; + activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - if (e.Button == MouseButton.Left && cursorRotate.Value) + if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) { + // if cursor is already rotating don't reset its rotate origin dragRotationState = DragRotationState.DragStarted; positionMouseDown = e.MousePosition; } @@ -91,22 +89,21 @@ namespace osu.Game.Graphics.Cursor return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (!e.IsPressed(MouseButton.Left) && !e.IsPressed(MouseButton.Right)) + if (!e.HasAnyButtonPressed) { activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); activeCursor.ScaleTo(1, 500, Easing.OutElastic); - } - if (e.Button == MouseButton.Left) - { - if (dragRotationState == DragRotationState.Rotating) + if (dragRotationState != DragRotationState.NotDragging) + { activeCursor.RotateTo(0, 600 * (1 + Math.Abs(activeCursor.Rotation / 720)), Easing.OutElasticHalf); - dragRotationState = DragRotationState.NotDragging; + dragRotationState = DragRotationState.NotDragging; + } } - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override void PopIn() diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs new file mode 100644 index 0000000000..67fcab43f7 --- /dev/null +++ b/osu.Game/Graphics/DateTooltip.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics +{ + public class DateTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText dateText, timeText; + private readonly Box background; + + public DateTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + dateText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + timeText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.GreySeafoamDarker; + timeText.Colour = colours.BlueLighter; + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public bool SetContent(object content) + { + if (!(content is DateTimeOffset date)) + return false; + + dateText.Text = $"{date:d MMMM yyyy} "; + timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; + return true; + } + + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 533f02af7b..259d9c8d6e 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -10,7 +10,7 @@ using osu.Game.Utils; namespace osu.Game.Graphics { - public class DrawableDate : OsuSpriteText, IHasTooltip + public class DrawableDate : OsuSpriteText, IHasCustomTooltip { private DateTimeOffset date; @@ -29,9 +29,9 @@ namespace osu.Game.Graphics } } - public DrawableDate(DateTimeOffset date) + public DrawableDate(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) { - Font = OsuFont.GetFont(weight: FontWeight.Regular, italics: true); + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: textSize, italics: italic); Date = date; } @@ -75,6 +75,8 @@ namespace osu.Game.Graphics private void updateTime() => Text = Format(); - public virtual string TooltipText => string.Format($"{Date:MMMM d, yyyy h:mm tt \"UTC\"z}"); + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => Date; } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 2dc12b3e67..466d59b08b 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using osu.Framework.Extensions.Color4Extensions; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Graphics @@ -12,32 +14,6 @@ namespace osu.Game.Graphics public static Color4 Gray(float amt) => new Color4(amt, amt, amt, 1f); public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); - public static Color4 FromHex(string hex) - { - if (hex[0] == '#') - hex = hex.Substring(1); - - switch (hex.Length) - { - default: - throw new ArgumentException(@"Invalid hex string length!"); - - case 3: - return new Color4( - (byte)(Convert.ToByte(hex.Substring(0, 1), 16) * 17), - (byte)(Convert.ToByte(hex.Substring(1, 1), 16) * 17), - (byte)(Convert.ToByte(hex.Substring(2, 1), 16) * 17), - 255); - - case 6: - return new Color4( - Convert.ToByte(hex.Substring(0, 2), 16), - Convert.ToByte(hex.Substring(2, 2), 16), - Convert.ToByte(hex.Substring(4, 2), 16), - 255); - } - } - public Color4 ForDifficultyRating(DifficultyRating difficulty, bool useLighterColour = false) { switch (difficulty) @@ -63,106 +39,161 @@ namespace osu.Game.Graphics } } + /// + /// Retrieves the colour for a . + /// + public static Color4 ForRank(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.XH: + case ScoreRank.X: + return Color4Extensions.FromHex(@"de31ae"); + + case ScoreRank.SH: + case ScoreRank.S: + return Color4Extensions.FromHex(@"02b5c3"); + + case ScoreRank.A: + return Color4Extensions.FromHex(@"88da20"); + + case ScoreRank.B: + return Color4Extensions.FromHex(@"e3b130"); + + case ScoreRank.C: + return Color4Extensions.FromHex(@"ff8e5d"); + + default: + return Color4Extensions.FromHex(@"ff5a5a"); + } + } + + /// + /// Retrieves the colour for a . + /// + public Color4 ForHitResult(HitResult judgement) + { + switch (judgement) + { + case HitResult.Perfect: + case HitResult.Great: + return Blue; + + case HitResult.Ok: + case HitResult.Good: + return Green; + + case HitResult.Meh: + return Yellow; + + case HitResult.Miss: + return Red; + + default: + return Color4.White; + } + } + // See https://github.com/ppy/osu-web/blob/master/resources/assets/less/colors.less - public readonly Color4 PurpleLighter = FromHex(@"eeeeff"); - public readonly Color4 PurpleLight = FromHex(@"aa88ff"); - public readonly Color4 PurpleLightAlternative = FromHex(@"cba4da"); - public readonly Color4 Purple = FromHex(@"8866ee"); - public readonly Color4 PurpleDark = FromHex(@"6644cc"); - public readonly Color4 PurpleDarkAlternative = FromHex(@"312436"); - public readonly Color4 PurpleDarker = FromHex(@"441188"); + public readonly Color4 PurpleLighter = Color4Extensions.FromHex(@"eeeeff"); + public readonly Color4 PurpleLight = Color4Extensions.FromHex(@"aa88ff"); + public readonly Color4 PurpleLightAlternative = Color4Extensions.FromHex(@"cba4da"); + public readonly Color4 Purple = Color4Extensions.FromHex(@"8866ee"); + public readonly Color4 PurpleDark = Color4Extensions.FromHex(@"6644cc"); + public readonly Color4 PurpleDarkAlternative = Color4Extensions.FromHex(@"312436"); + public readonly Color4 PurpleDarker = Color4Extensions.FromHex(@"441188"); - public readonly Color4 PinkLighter = FromHex(@"ffddee"); - public readonly Color4 PinkLight = FromHex(@"ff99cc"); - public readonly Color4 Pink = FromHex(@"ff66aa"); - public readonly Color4 PinkDark = FromHex(@"cc5288"); - public readonly Color4 PinkDarker = FromHex(@"bb1177"); + public readonly Color4 PinkLighter = Color4Extensions.FromHex(@"ffddee"); + public readonly Color4 PinkLight = Color4Extensions.FromHex(@"ff99cc"); + public readonly Color4 Pink = Color4Extensions.FromHex(@"ff66aa"); + public readonly Color4 PinkDark = Color4Extensions.FromHex(@"cc5288"); + public readonly Color4 PinkDarker = Color4Extensions.FromHex(@"bb1177"); - public readonly Color4 BlueLighter = FromHex(@"ddffff"); - public readonly Color4 BlueLight = FromHex(@"99eeff"); - public readonly Color4 Blue = FromHex(@"66ccff"); - public readonly Color4 BlueDark = FromHex(@"44aadd"); - public readonly Color4 BlueDarker = FromHex(@"2299bb"); + public readonly Color4 BlueLighter = Color4Extensions.FromHex(@"ddffff"); + public readonly Color4 BlueLight = Color4Extensions.FromHex(@"99eeff"); + public readonly Color4 Blue = Color4Extensions.FromHex(@"66ccff"); + public readonly Color4 BlueDark = Color4Extensions.FromHex(@"44aadd"); + public readonly Color4 BlueDarker = Color4Extensions.FromHex(@"2299bb"); - public readonly Color4 YellowLighter = FromHex(@"ffffdd"); - public readonly Color4 YellowLight = FromHex(@"ffdd55"); - public readonly Color4 Yellow = FromHex(@"ffcc22"); - public readonly Color4 YellowDark = FromHex(@"eeaa00"); - public readonly Color4 YellowDarker = FromHex(@"cc6600"); + public readonly Color4 YellowLighter = Color4Extensions.FromHex(@"ffffdd"); + public readonly Color4 YellowLight = Color4Extensions.FromHex(@"ffdd55"); + public readonly Color4 Yellow = Color4Extensions.FromHex(@"ffcc22"); + public readonly Color4 YellowDark = Color4Extensions.FromHex(@"eeaa00"); + public readonly Color4 YellowDarker = Color4Extensions.FromHex(@"cc6600"); - public readonly Color4 GreenLighter = FromHex(@"eeffcc"); - public readonly Color4 GreenLight = FromHex(@"b3d944"); - public readonly Color4 Green = FromHex(@"88b300"); - public readonly Color4 GreenDark = FromHex(@"668800"); - public readonly Color4 GreenDarker = FromHex(@"445500"); + public readonly Color4 GreenLighter = Color4Extensions.FromHex(@"eeffcc"); + public readonly Color4 GreenLight = Color4Extensions.FromHex(@"b3d944"); + public readonly Color4 Green = Color4Extensions.FromHex(@"88b300"); + public readonly Color4 GreenDark = Color4Extensions.FromHex(@"668800"); + public readonly Color4 GreenDarker = Color4Extensions.FromHex(@"445500"); - public readonly Color4 Sky = FromHex(@"6bb5ff"); - public readonly Color4 GreySkyLighter = FromHex(@"c6e3f4"); - public readonly Color4 GreySkyLight = FromHex(@"8ab3cc"); - public readonly Color4 GreySky = FromHex(@"405461"); - public readonly Color4 GreySkyDark = FromHex(@"303d47"); - public readonly Color4 GreySkyDarker = FromHex(@"21272c"); + public readonly Color4 Sky = Color4Extensions.FromHex(@"6bb5ff"); + public readonly Color4 GreySkyLighter = Color4Extensions.FromHex(@"c6e3f4"); + public readonly Color4 GreySkyLight = Color4Extensions.FromHex(@"8ab3cc"); + public readonly Color4 GreySky = Color4Extensions.FromHex(@"405461"); + public readonly Color4 GreySkyDark = Color4Extensions.FromHex(@"303d47"); + public readonly Color4 GreySkyDarker = Color4Extensions.FromHex(@"21272c"); - public readonly Color4 Seafoam = FromHex(@"05ffa2"); - public readonly Color4 GreySeafoamLighter = FromHex(@"9ebab1"); - public readonly Color4 GreySeafoamLight = FromHex(@"4d7365"); - public readonly Color4 GreySeafoam = FromHex(@"33413c"); - public readonly Color4 GreySeafoamDark = FromHex(@"2c3532"); - public readonly Color4 GreySeafoamDarker = FromHex(@"1e2422"); + public readonly Color4 Seafoam = Color4Extensions.FromHex(@"05ffa2"); + public readonly Color4 GreySeafoamLighter = Color4Extensions.FromHex(@"9ebab1"); + public readonly Color4 GreySeafoamLight = Color4Extensions.FromHex(@"4d7365"); + public readonly Color4 GreySeafoam = Color4Extensions.FromHex(@"33413c"); + public readonly Color4 GreySeafoamDark = Color4Extensions.FromHex(@"2c3532"); + public readonly Color4 GreySeafoamDarker = Color4Extensions.FromHex(@"1e2422"); - public readonly Color4 Cyan = FromHex(@"05f4fd"); - public readonly Color4 GreyCyanLighter = FromHex(@"77b1b3"); - public readonly Color4 GreyCyanLight = FromHex(@"436d6f"); - public readonly Color4 GreyCyan = FromHex(@"293d3e"); - public readonly Color4 GreyCyanDark = FromHex(@"243536"); - public readonly Color4 GreyCyanDarker = FromHex(@"1e2929"); + public readonly Color4 Cyan = Color4Extensions.FromHex(@"05f4fd"); + public readonly Color4 GreyCyanLighter = Color4Extensions.FromHex(@"77b1b3"); + public readonly Color4 GreyCyanLight = Color4Extensions.FromHex(@"436d6f"); + public readonly Color4 GreyCyan = Color4Extensions.FromHex(@"293d3e"); + public readonly Color4 GreyCyanDark = Color4Extensions.FromHex(@"243536"); + public readonly Color4 GreyCyanDarker = Color4Extensions.FromHex(@"1e2929"); - public readonly Color4 Lime = FromHex(@"82ff05"); - public readonly Color4 GreyLimeLighter = FromHex(@"deff87"); - public readonly Color4 GreyLimeLight = FromHex(@"657259"); - public readonly Color4 GreyLime = FromHex(@"3f443a"); - public readonly Color4 GreyLimeDark = FromHex(@"32352e"); - public readonly Color4 GreyLimeDarker = FromHex(@"2e302b"); + public readonly Color4 Lime = Color4Extensions.FromHex(@"82ff05"); + public readonly Color4 GreyLimeLighter = Color4Extensions.FromHex(@"deff87"); + public readonly Color4 GreyLimeLight = Color4Extensions.FromHex(@"657259"); + public readonly Color4 GreyLime = Color4Extensions.FromHex(@"3f443a"); + public readonly Color4 GreyLimeDark = Color4Extensions.FromHex(@"32352e"); + public readonly Color4 GreyLimeDarker = Color4Extensions.FromHex(@"2e302b"); - public readonly Color4 Violet = FromHex(@"bf04ff"); - public readonly Color4 GreyVioletLighter = FromHex(@"ebb8fe"); - public readonly Color4 GreyVioletLight = FromHex(@"685370"); - public readonly Color4 GreyViolet = FromHex(@"46334d"); - public readonly Color4 GreyVioletDark = FromHex(@"2c2230"); - public readonly Color4 GreyVioletDarker = FromHex(@"201823"); + public readonly Color4 Violet = Color4Extensions.FromHex(@"bf04ff"); + public readonly Color4 GreyVioletLighter = Color4Extensions.FromHex(@"ebb8fe"); + public readonly Color4 GreyVioletLight = Color4Extensions.FromHex(@"685370"); + public readonly Color4 GreyViolet = Color4Extensions.FromHex(@"46334d"); + public readonly Color4 GreyVioletDark = Color4Extensions.FromHex(@"2c2230"); + public readonly Color4 GreyVioletDarker = Color4Extensions.FromHex(@"201823"); - public readonly Color4 Carmine = FromHex(@"ff0542"); - public readonly Color4 GreyCarmineLighter = FromHex(@"deaab4"); - public readonly Color4 GreyCarmineLight = FromHex(@"644f53"); - public readonly Color4 GreyCarmine = FromHex(@"342b2d"); - public readonly Color4 GreyCarmineDark = FromHex(@"302a2b"); - public readonly Color4 GreyCarmineDarker = FromHex(@"241d1e"); + public readonly Color4 Carmine = Color4Extensions.FromHex(@"ff0542"); + public readonly Color4 GreyCarmineLighter = Color4Extensions.FromHex(@"deaab4"); + public readonly Color4 GreyCarmineLight = Color4Extensions.FromHex(@"644f53"); + public readonly Color4 GreyCarmine = Color4Extensions.FromHex(@"342b2d"); + public readonly Color4 GreyCarmineDark = Color4Extensions.FromHex(@"302a2b"); + public readonly Color4 GreyCarmineDarker = Color4Extensions.FromHex(@"241d1e"); - public readonly Color4 Gray0 = FromHex(@"000"); - public readonly Color4 Gray1 = FromHex(@"111"); - public readonly Color4 Gray2 = FromHex(@"222"); - public readonly Color4 Gray3 = FromHex(@"333"); - public readonly Color4 Gray4 = FromHex(@"444"); - public readonly Color4 Gray5 = FromHex(@"555"); - public readonly Color4 Gray6 = FromHex(@"666"); - public readonly Color4 Gray7 = FromHex(@"777"); - public readonly Color4 Gray8 = FromHex(@"888"); - public readonly Color4 Gray9 = FromHex(@"999"); - public readonly Color4 GrayA = FromHex(@"aaa"); - public readonly Color4 GrayB = FromHex(@"bbb"); - public readonly Color4 GrayC = FromHex(@"ccc"); - public readonly Color4 GrayD = FromHex(@"ddd"); - public readonly Color4 GrayE = FromHex(@"eee"); - public readonly Color4 GrayF = FromHex(@"fff"); + public readonly Color4 Gray0 = Color4Extensions.FromHex(@"000"); + public readonly Color4 Gray1 = Color4Extensions.FromHex(@"111"); + public readonly Color4 Gray2 = Color4Extensions.FromHex(@"222"); + public readonly Color4 Gray3 = Color4Extensions.FromHex(@"333"); + public readonly Color4 Gray4 = Color4Extensions.FromHex(@"444"); + public readonly Color4 Gray5 = Color4Extensions.FromHex(@"555"); + public readonly Color4 Gray6 = Color4Extensions.FromHex(@"666"); + public readonly Color4 Gray7 = Color4Extensions.FromHex(@"777"); + public readonly Color4 Gray8 = Color4Extensions.FromHex(@"888"); + public readonly Color4 Gray9 = Color4Extensions.FromHex(@"999"); + public readonly Color4 GrayA = Color4Extensions.FromHex(@"aaa"); + public readonly Color4 GrayB = Color4Extensions.FromHex(@"bbb"); + public readonly Color4 GrayC = Color4Extensions.FromHex(@"ccc"); + public readonly Color4 GrayD = Color4Extensions.FromHex(@"ddd"); + public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); + public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); - public readonly Color4 RedLighter = FromHex(@"ffeded"); - public readonly Color4 RedLight = FromHex(@"ed7787"); - public readonly Color4 Red = FromHex(@"ed1121"); - public readonly Color4 RedDark = FromHex(@"ba0011"); - public readonly Color4 RedDarker = FromHex(@"870000"); + public readonly Color4 RedLighter = Color4Extensions.FromHex(@"ffeded"); + public readonly Color4 RedLight = Color4Extensions.FromHex(@"ed7787"); + public readonly Color4 Red = Color4Extensions.FromHex(@"ed1121"); + public readonly Color4 RedDark = Color4Extensions.FromHex(@"ba0011"); + public readonly Color4 RedDarker = Color4Extensions.FromHex(@"870000"); - public readonly Color4 ChatBlue = FromHex(@"17292e"); + public readonly Color4 ChatBlue = Color4Extensions.FromHex(@"17292e"); - public readonly Color4 ContextMenuGray = FromHex(@"223034"); + public readonly Color4 ContextMenuGray = Color4Extensions.FromHex(@"223034"); } } diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index 22250d4a56..7c78141b4d 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -17,7 +17,9 @@ namespace osu.Game.Graphics /// public static FontUsage Default => GetFont(); - public static FontUsage Numeric => GetFont(Typeface.Venera, weight: FontWeight.Regular); + public static FontUsage Numeric => GetFont(Typeface.Venera, weight: FontWeight.Bold); + + public static FontUsage Torus => GetFont(Typeface.Torus, weight: FontWeight.Regular); /// /// Retrieves a . @@ -28,8 +30,15 @@ namespace osu.Game.Graphics /// Whether the font is italic. /// Whether all characters should be spaced the same distance apart. /// The . - public static FontUsage GetFont(Typeface typeface = Typeface.Exo, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), italics, fixedWidth); + 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); + + private static bool getItalics(in bool italicsRequested) + { + // right now none of our fonts support italics. + // should add exceptions to this rule if they come up. + return false; + } /// /// Retrieves the string representation of a . @@ -40,11 +49,11 @@ namespace osu.Game.Graphics { switch (typeface) { - case Typeface.Exo: - return "Exo2.0"; - case Typeface.Venera: return "Venera"; + + case Typeface.Torus: + return "Torus"; } return null; @@ -57,7 +66,13 @@ namespace osu.Game.Graphics /// The . /// The string representation of in the specified . public static string GetWeightString(Typeface typeface, FontWeight weight) - => GetWeightString(GetFamilyString(typeface), weight); + { + if (typeface == Typeface.Torus && weight == FontWeight.Medium) + // torus doesn't have a medium; fallback to regular. + weight = FontWeight.Regular; + + return GetWeightString(GetFamilyString(typeface), weight); + } /// /// Retrieves the string representation of a . @@ -65,16 +80,7 @@ namespace osu.Game.Graphics /// The family string. /// The . /// The string representation of in the specified . - public static string GetWeightString(string family, FontWeight weight) - { - string weightString = weight.ToString(); - - // Only exo has an explicit "regular" weight, other fonts do not - if (family != GetFamilyString(Typeface.Exo) && weight == FontWeight.Regular) - weightString = string.Empty; - - return weightString; - } + public static string GetWeightString(string family, FontWeight weight) => weight.ToString(); } public static class OsuFontExtensions @@ -100,17 +106,40 @@ namespace osu.Game.Graphics public enum Typeface { - Exo, Venera, + Torus } public enum FontWeight { - Light, - Regular, - Medium, - SemiBold, - Bold, - Black + /// + /// Equivalent to weight 300. + /// + Light = 300, + + /// + /// Equivalent to weight 400. + /// + Regular = 400, + + /// + /// Equivalent to weight 500. + /// + Medium = 500, + + /// + /// Equivalent to weight 600. + /// + SemiBold = 600, + + /// + /// Equivalent to weight 700. + /// + Bold = 700, + + /// + /// Equivalent to weight 900. + /// + Black = 900 } } diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index b9151b7393..9804aefce8 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -9,7 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Platform; @@ -22,7 +22,7 @@ using SixLabors.ImageSharp; namespace osu.Game.Graphics { - public class ScreenshotManager : Container, IKeyBindingHandler, IHandleGlobalKeyboardInput + public class ScreenshotManager : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly BindableBool cursorVisibility = new BindableBool(true); @@ -35,18 +35,20 @@ namespace osu.Game.Graphics private Bindable screenshotFormat; private Bindable captureMenuCursor; - private GameHost host; + [Resolved] + private GameHost host { get; set; } + private Storage storage; - private NotificationOverlay notificationOverlay; + + [Resolved] + private NotificationOverlay notificationOverlay { get; set; } private SampleChannel shutter; [BackgroundDependencyLoader] - private void load(GameHost host, OsuConfigManager config, Storage storage, NotificationOverlay notificationOverlay, AudioManager audio) + private void load(OsuConfigManager config, Storage storage, AudioManager audio) { - this.host = host; this.storage = storage.GetStorageForDirectory(@"screenshots"); - this.notificationOverlay = notificationOverlay; screenshotFormat = config.GetBindable(OsuSetting.ScreenshotFormat); captureMenuCursor = config.GetBindable(OsuSetting.ScreenshotCaptureMenuCursor); @@ -67,7 +69,9 @@ namespace osu.Game.Graphics return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private volatile int screenShotTasks; @@ -88,7 +92,7 @@ namespace osu.Game.Graphics { ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => { - if (framesWaited++ < frames_to_wait) + if (framesWaited++ >= frames_to_wait) // ReSharper disable once AccessToDisposedClosure framesWaitedEvent.Set(); }, 10, true); diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs index 12688da9df..4aea5aa518 100644 --- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs +++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs @@ -43,6 +43,18 @@ namespace osu.Game.Graphics.Sprites set => blurredText.Colour = value; } + public Vector2 Spacing + { + get => spriteText.Spacing; + set => spriteText.Spacing = blurredText.Spacing = value; + } + + public bool UseFullGlyphHeight + { + get => spriteText.UseFullGlyphHeight; + set => spriteText.UseFullGlyphHeight = blurredText.UseFullGlyphHeight = value; + } + public GlowingSpriteText() { AutoSizeAxes = Axes.Both; diff --git a/osu.Game/Graphics/Sprites/HueAnimation.cs b/osu.Game/Graphics/Sprites/HueAnimation.cs new file mode 100644 index 0000000000..8ad68ace05 --- /dev/null +++ b/osu.Game/Graphics/Sprites/HueAnimation.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Graphics.Sprites +{ + public class HueAnimation : Sprite + { + [BackgroundDependencyLoader] + private void load(ShaderManager shaders, TextureStore textures) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"HueAnimation"); + RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"HueAnimation"); // Masking isn't supported for now + } + + private float animationProgress; + + public float AnimationProgress + { + get => animationProgress; + set + { + if (animationProgress == value) return; + + animationProgress = value; + Invalidate(Invalidation.DrawInfo); + } + } + + public override bool IsPresent => true; + + protected override DrawNode CreateDrawNode() => new HueAnimationDrawNode(this); + + private class HueAnimationDrawNode : SpriteDrawNode + { + private HueAnimation source => (HueAnimation)Source; + + private float progress; + + public HueAnimationDrawNode(HueAnimation source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + progress = source.animationProgress; + } + + protected override void Blit(Action vertexAction) + { + Shader.GetUniform("progress").UpdateValue(ref progress); + + base.Blit(vertexAction); + } + + protected override bool CanDrawOpaqueInterior => false; + } + } +} diff --git a/osu.Game/Graphics/Sprites/OsuSpriteText.cs b/osu.Game/Graphics/Sprites/OsuSpriteText.cs index cd988c347b..76e46513ba 100644 --- a/osu.Game/Graphics/Sprites/OsuSpriteText.cs +++ b/osu.Game/Graphics/Sprites/OsuSpriteText.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Transforms; namespace osu.Game.Graphics.Sprites { @@ -15,23 +13,4 @@ namespace osu.Game.Graphics.Sprites Font = OsuFont.Default; } } - - public static class OsuSpriteTextTransformExtensions - { - /// - /// Sets Text to a new value after a duration. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformTextTo(this T spriteText, string newText, double duration = 0, Easing easing = Easing.None) - where T : OsuSpriteText - => spriteText.TransformTo(nameof(OsuSpriteText.Text), newText, duration, easing); - - /// - /// Sets Text to a new value after a duration. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformTextTo(this TransformSequence t, string newText, double duration = 0, Easing easing = Easing.None) - where T : OsuSpriteText - => t.Append(o => o.TransformTextTo(newText, duration, easing)); - } } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index 23565e8742..b941e5fcbd 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -16,10 +16,8 @@ namespace osu.Game.Graphics.UserInterface private readonly TwoLayerButton button; - public BackButton(Receptor receptor) + public BackButton(Receptor receptor = null) { - receptor.OnBackPressed = () => button.Click(); - Size = TwoLayerButton.SIZE_EXTENDED; Child = button = new TwoLayerButton @@ -30,6 +28,14 @@ namespace osu.Game.Graphics.UserInterface Icon = OsuIcon.LeftCircle, Action = () => Action?.Invoke() }; + + if (receptor == null) + { + // if a receptor wasn't provided, create our own locally. + Add(receptor = new Receptor()); + } + + receptor.OnBackPressed = () => button.Click(); } [BackgroundDependencyLoader] @@ -67,7 +73,9 @@ namespace osu.Game.Graphics.UserInterface return false; } - public bool OnReleased(GlobalAction action) => action == GlobalAction.Back; + public void OnReleased(GlobalAction action) + { + } } } } diff --git a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs index e2438cc4cd..fb5ff4aad3 100644 --- a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs @@ -27,6 +27,8 @@ namespace osu.Game.Graphics.UserInterface { Height = 32; TabContainer.Spacing = new Vector2(padding, 0f); + SwitchTabOnRemove = false; + Current.ValueChanged += index => { foreach (var t in TabContainer.Children.OfType()) @@ -34,13 +36,13 @@ namespace osu.Game.Graphics.UserInterface var tIndex = TabContainer.IndexOf(t); var tabIndex = TabContainer.IndexOf(TabMap[index.NewValue]); - t.State = tIndex < tabIndex ? Visibility.Hidden : Visibility.Visible; - t.Chevron.FadeTo(tIndex <= tabIndex ? 0f : 1f, 500, Easing.OutQuint); + t.State = tIndex > tabIndex ? Visibility.Hidden : Visibility.Visible; + t.Chevron.FadeTo(tIndex >= tabIndex ? 0f : 1f, 500, Easing.OutQuint); } }; } - protected class BreadcrumbTabItem : OsuTabItem, IStateful + public class BreadcrumbTabItem : OsuTabItem, IStateful { protected virtual float ChevronSize => 10; diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index aed07e56ee..9b53ee7b2d 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -232,11 +232,11 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (Selected.Value) colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Graphics/UserInterface/DimmedLoadingLayer.cs b/osu.Game/Graphics/UserInterface/DimmedLoadingLayer.cs deleted file mode 100644 index f2f6dd429b..0000000000 --- a/osu.Game/Graphics/UserInterface/DimmedLoadingLayer.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Extensions.Color4Extensions; -using osuTK; - -namespace osu.Game.Graphics.UserInterface -{ - public class DimmedLoadingLayer : OverlayContainer - { - private const float transition_duration = 250; - - private readonly LoadingAnimation loading; - - public DimmedLoadingLayer(float dimAmount = 0.5f, float iconScale = 1f) - { - RelativeSizeAxes = Axes.Both; - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(dimAmount), - }, - loading = new LoadingAnimation { Scale = new Vector2(iconScale) }, - }; - } - - protected override void PopIn() - { - this.FadeIn(transition_duration, Easing.OutQuint); - loading.Show(); - } - - protected override void PopOut() - { - this.FadeOut(transition_duration, Easing.OutQuint); - loading.Hide(); - } - } -} diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 41b90d3802..da6c95299e 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -19,7 +19,8 @@ namespace osu.Game.Graphics.UserInterface private readonly SpriteIcon checkmark; private readonly Box background; - private OsuColour colours; + [Resolved] + private OsuColour colours { get; set; } public DownloadButton() { @@ -49,10 +50,8 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - this.colours = colours; - State.BindValueChanged(updateState, true); } @@ -64,16 +63,19 @@ namespace osu.Game.Graphics.UserInterface background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Download"; break; case DownloadState.Downloading: background.FadeColour(colours.Blue, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Downloading..."; break; case DownloadState.Downloaded: background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); + TooltipText = "Importing"; break; case DownloadState.LocallyAvailable: diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index 591ed3df83..abaae7b43c 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -4,6 +4,7 @@ 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.Sprites; @@ -38,9 +39,11 @@ namespace osu.Game.Graphics.UserInterface sampleClick = audio.Samples.Get(@"UI/generic-select"); BackgroundColour = Color4.Transparent; - BackgroundColourHover = OsuColour.FromHex(@"172023"); + BackgroundColourHover = Color4Extensions.FromHex(@"172023"); updateTextColour(); + + Item.Action.BindDisabledChanged(_ => updateState(), true); } private void updateTextColour() @@ -57,26 +60,40 @@ namespace osu.Game.Graphics.UserInterface break; case MenuItemType.Highlighted: - text.Colour = OsuColour.FromHex(@"ffcc22"); + text.Colour = Color4Extensions.FromHex(@"ffcc22"); break; } } protected override bool OnHover(HoverEvent e) { - sampleHover.Play(); - text.BoldText.FadeIn(transition_length, Easing.OutQuint); - text.NormalText.FadeOut(transition_length, Easing.OutQuint); + updateState(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - text.BoldText.FadeOut(transition_length, Easing.OutQuint); - text.NormalText.FadeIn(transition_length, Easing.OutQuint); + updateState(); base.OnHoverLost(e); } + private void updateState() + { + Alpha = Item.Action.Disabled ? 0.2f : 1; + + if (IsHovered && !Item.Action.Disabled) + { + sampleHover.Play(); + text.BoldText.FadeIn(transition_length, Easing.OutQuint); + text.NormalText.FadeOut(transition_length, Easing.OutQuint); + } + else + { + text.BoldText.FadeOut(transition_length, Easing.OutQuint); + text.NormalText.FadeIn(transition_length, Easing.OutQuint); + } + } + protected override bool OnClick(ClickEvent e) { sampleClick.Play(); diff --git a/osu.Game/Graphics/UserInterface/ExpandingBar.cs b/osu.Game/Graphics/UserInterface/ExpandingBar.cs index 439a6002d8..60cb35b4c4 100644 --- a/osu.Game/Graphics/UserInterface/ExpandingBar.cs +++ b/osu.Game/Graphics/UserInterface/ExpandingBar.cs @@ -13,17 +13,17 @@ namespace osu.Game.Graphics.UserInterface /// public class ExpandingBar : Circle { - private bool isCollapsed; + private bool expanded = true; - public bool IsCollapsed + public bool Expanded { - get => isCollapsed; + get => expanded; set { - if (value == isCollapsed) + if (value == expanded) return; - isCollapsed = value; + expanded = value; updateState(); } } @@ -83,19 +83,21 @@ namespace osu.Game.Graphics.UserInterface updateState(); } - public void Collapse() => IsCollapsed = true; + public void Collapse() => Expanded = false; - public void Expand() => IsCollapsed = false; + public void Expand() => Expanded = true; private void updateState() { - float newSize = IsCollapsed ? CollapsedSize : ExpandedSize; - Easing easingType = IsCollapsed ? Easing.Out : Easing.OutElastic; + float newSize = expanded ? ExpandedSize : CollapsedSize; + Easing easingType = expanded ? Easing.OutElastic : Easing.Out; if (RelativeSizeAxes == Axes.X) this.ResizeHeightTo(newSize, 400, easingType); else this.ResizeWidthTo(newSize, 400, easingType); + + this.FadeTo(expanded ? 1 : 0.5f, 100, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 8c00cae08a..5a1eb53fe1 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -18,7 +18,9 @@ namespace osu.Game.Graphics.UserInterface public string Link { get; set; } private Color4 hoverColour; - private GameHost host; + + [Resolved] + private GameHost host { get; set; } public ExternalLinkButton(string link = null) { @@ -32,10 +34,9 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours, GameHost host) + private void load(OsuColour colours) { hoverColour = colours.Yellow; - this.host = host; } protected override bool OnHover(HoverEvent e) @@ -57,6 +58,6 @@ namespace osu.Game.Graphics.UserInterface return true; } - public string TooltipText => "View in browser"; + public string TooltipText => "view in browser"; } } diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index 0b183c0ec9..8977f014b6 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -36,13 +36,12 @@ namespace osu.Game.Graphics.UserInterface } } - private GameHost host; + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(GameHost host) + private void load() { - this.host = host; - BackgroundUnfocused = new Color4(10, 10, 10, 255); BackgroundFocused = new Color4(10, 10, 10, 255); } @@ -80,7 +79,9 @@ namespace osu.Game.Graphics.UserInterface return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } public override bool RequestsFocus => HoldFocus; } diff --git a/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs b/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs index baca57ea89..1d67c4e033 100644 --- a/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs +++ b/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs @@ -49,14 +49,7 @@ namespace osu.Game.Graphics.UserInterface public GradientLine() { RelativeSizeAxes = Axes.X; - Size = new Vector2(0.8f, 1.5f); - - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(mode: GridSizeMode.Relative, size: 0.4f), - new Dimension(), - }; + Size = new Vector2(0.8f, 1f); Content = new[] { @@ -65,16 +58,12 @@ namespace osu.Game.Graphics.UserInterface new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.Transparent, Color4.White) + Colour = ColourInfo.GradientHorizontal(Color4.Transparent, Colour) }, new Box { RelativeSizeAxes = Axes.Both, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.Transparent) + Colour = ColourInfo.GradientHorizontal(Colour, Color4.Transparent) }, } }; diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index fcd8940348..40899e7e95 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -24,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface /// /// Length of debounce for hover sound playback, in milliseconds. Default is 50ms. /// - public double HoverDebounceTime { get; set; } = 50; + public double HoverDebounceTime { get; } = 50; protected readonly HoverSampleSet SampleSet; diff --git a/osu.Game/Graphics/UserInterface/LineGraph.cs b/osu.Game/Graphics/UserInterface/LineGraph.cs index 6d65b77cbf..42b523fc5c 100644 --- a/osu.Game/Graphics/UserInterface/LineGraph.cs +++ b/osu.Game/Graphics/UserInterface/LineGraph.cs @@ -4,11 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Caching; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; +using osu.Framework.Layout; using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface @@ -83,17 +83,11 @@ namespace osu.Game.Graphics.UserInterface PathRadius = 1 } }); + + AddLayout(pathCached); } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - pathCached.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); - } - - private readonly Cached pathCached = new Cached(); + private readonly LayoutValue pathCached = new LayoutValue(Invalidation.DrawSize); protected override void Update() { diff --git a/osu.Game/Graphics/UserInterface/LoadingAnimation.cs b/osu.Game/Graphics/UserInterface/LoadingAnimation.cs deleted file mode 100644 index 5a8a0da135..0000000000 --- a/osu.Game/Graphics/UserInterface/LoadingAnimation.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// A loading spinner. - /// - public class LoadingAnimation : VisibilityContainer - { - private readonly SpriteIcon spinner; - private readonly SpriteIcon spinnerShadow; - - private const float spin_duration = 600; - private const float transition_duration = 200; - - public LoadingAnimation() - { - Size = new Vector2(20); - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - Children = new Drawable[] - { - spinnerShadow = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Position = new Vector2(1, 1), - Colour = Color4.Black, - Alpha = 0.4f, - Icon = FontAwesome.Solid.CircleNotch - }, - spinner = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.CircleNotch - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - spinner.Spin(spin_duration, RotationDirection.Clockwise); - spinnerShadow.Spin(spin_duration, RotationDirection.Clockwise); - } - - protected override void PopIn() => this.FadeIn(transition_duration * 2, Easing.OutQuint); - - protected override void PopOut() => this.FadeOut(transition_duration, Easing.OutQuint); - } -} diff --git a/osu.Game/Graphics/UserInterface/LoadingButton.cs b/osu.Game/Graphics/UserInterface/LoadingButton.cs index 49ec18ce8e..81dc023d7e 100644 --- a/osu.Game/Graphics/UserInterface/LoadingButton.cs +++ b/osu.Game/Graphics/UserInterface/LoadingButton.cs @@ -40,14 +40,14 @@ namespace osu.Game.Graphics.UserInterface set => loading.Size = value; } - private readonly LoadingAnimation loading; + private readonly LoadingSpinner loading; protected LoadingButton() { AddRange(new[] { CreateContent(), - loading = new LoadingAnimation + loading = new LoadingSpinner { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs new file mode 100644 index 0000000000..35b33c3d03 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A layer that will show a loading spinner and completely block input to an area. + /// 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 + { + private readonly Drawable dimTarget; + + /// + /// Constuct a new loading spinner. + /// + /// An optional target to dim when displayed. + /// Whether the spinner should have a surrounding black box for visibility. + public LoadingLayer(Drawable dimTarget = null, bool withBox = true) + : base(withBox) + { + RelativeSizeAxes = Axes.Both; + Size = new Vector2(1); + + this.dimTarget = dimTarget; + + MainContents.RelativeSizeAxes = Axes.None; + } + + public override bool HandleNonPositionalInput => false; + + protected override bool Handle(UIEvent e) + { + switch (e) + { + // blocking scroll can cause weird behaviour when this layer is used within a ScrollContainer. + case ScrollEvent _: + return false; + } + + return true; + } + + protected override void PopIn() + { + dimTarget?.FadeColour(OsuColour.Gray(0.5f), TRANSITION_DURATION, Easing.OutQuint); + base.PopIn(); + } + + protected override void PopOut() + { + dimTarget?.FadeColour(Color4.White, TRANSITION_DURATION, Easing.OutQuint); + base.PopOut(); + } + + protected override void Update() + { + base.Update(); + MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 30, 100)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (State.Value == Visibility.Visible) + { + // ensure we don't leave the target in a bad state. + dimTarget?.FadeColour(Color4.White, TRANSITION_DURATION, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs new file mode 100644 index 0000000000..8174c4d5fe --- /dev/null +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A loading spinner. + /// + public class LoadingSpinner : VisibilityContainer + { + private readonly SpriteIcon spinner; + + protected override bool StartHidden => true; + + protected Container MainContents; + + public const float TRANSITION_DURATION = 500; + + private const float spin_duration = 900; + + /// + /// Constuct a new loading spinner. + /// + /// Whether the spinner should have a surrounding black box for visibility. + /// Whether colours should be inverted (black spinner instead of white). + public LoadingSpinner(bool withBox = false, bool inverted = false) + { + Size = new Vector2(60); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Child = MainContents = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = inverted ? Color4.White : Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = withBox ? 0.7f : 0 + }, + spinner = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = inverted ? Color4.Black : Color4.White, + Scale = new Vector2(withBox ? 0.6f : 1), + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.CircleNotch + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + rotate(); + } + + protected override void Update() + { + base.Update(); + + MainContents.CornerRadius = MainContents.DrawWidth / 4; + } + + protected override void PopIn() + { + if (Alpha < 0.5f) + // reset animation if the user can't see us. + rotate(); + + MainContents.ScaleTo(1, TRANSITION_DURATION, Easing.OutQuint); + this.FadeIn(TRANSITION_DURATION * 2, Easing.OutQuint); + } + + protected override void PopOut() + { + MainContents.ScaleTo(0.8f, TRANSITION_DURATION / 2, Easing.In); + this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + } + + private void rotate() + { + spinner.Spin(spin_duration * 3.5f, RotationDirection.Clockwise); + + MainContents.RotateTo(0).Then() + .RotateTo(90, spin_duration, Easing.InOutQuart).Then() + .RotateTo(180, spin_duration, Easing.InOutQuart).Then() + .RotateTo(270, spin_duration, Easing.InOutQuart).Then() + .RotateTo(360, spin_duration, Easing.InOutQuart).Then() + .Loop(); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 660bd7979f..cfcf034d1c 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -107,10 +107,10 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Content.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index c6a9aa1c97..9cf8f02024 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -129,10 +129,10 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Content.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected virtual SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 418ad038f7..ac6f5ceb1b 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,13 +18,20 @@ namespace osu.Game.Graphics.UserInterface { public class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging { - protected override Drawable GetDrawableCharacter(char c) => new PasswordMaskChar(CalculatedTextSize); + protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer + { + AutoSizeAxes = Axes.Both, + Child = new PasswordMaskChar(CalculatedTextSize), + }; + + protected override bool AllowUniqueCharacterSamples => false; protected override bool AllowClipboardExport => false; private readonly CapsWarning warning; - private GameHost host; + [Resolved] + private GameHost host { get; set; } public OsuPasswordTextBox() { @@ -38,12 +45,6 @@ namespace osu.Game.Graphics.UserInterface }); } - [BackgroundDependencyLoader] - private void load(GameHost host) - { - this.host = host; - } - protected override bool OnKeyDown(KeyDownEvent e) { if (e.Key == Key.CapsLock) @@ -104,7 +105,7 @@ namespace osu.Game.Graphics.UserInterface private class CapsWarning : SpriteIcon, IHasTooltip { - public string TooltipText => @"Caps lock is active"; + public string TooltipText => @"caps lock is active"; public CapsWarning() { diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 958390d5d2..d0356e77c7 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -36,6 +36,11 @@ namespace osu.Game.Graphics.UserInterface public virtual string TooltipText { get; private set; } + /// + /// Whether to format the tooltip as a percentage or the actual value. + /// + public bool DisplayAsPercentage { get; set; } + private Color4 accentColour; public Color4 AccentColour @@ -128,10 +133,10 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Nub.Current.Value = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override void OnUserChange(T value) @@ -169,11 +174,11 @@ namespace osu.Game.Graphics.UserInterface else { double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo); - double floatMinValue = CurrentNumber.MinValue.ToDouble(NumberFormatInfo.InvariantInfo); - double floatMaxValue = CurrentNumber.MaxValue.ToDouble(NumberFormatInfo.InvariantInfo); - if (floatMaxValue == 1 && floatMinValue >= -1) - TooltipText = floatValue.ToString("P0"); + if (DisplayAsPercentage) + { + TooltipText = floatValue.ToString("0%"); + } else { var decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index ed8904db7e..61501b0cd8 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -10,21 +10,38 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { public class OsuTabControl : TabControl { + private Color4 accentColour; + + public const float HORIZONTAL_SPACING = 10; + + public virtual Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + if (Dropdown is IHasAccentColour dropdown) + dropdown.AccentColour = value; + foreach (var i in TabContainer.Children.OfType()) + i.AccentColour = value; + } + } + private readonly Box strip; - protected override Dropdown CreateDropdown() => new OsuTabDropdown(); + protected override Dropdown CreateDropdown() => new OsuTabDropdown(); protected override TabItem CreateTabItem(T value) => new OsuTabItem(value); @@ -39,7 +56,7 @@ namespace osu.Game.Graphics.UserInterface public OsuTabControl() { - TabContainer.Spacing = new Vector2(10f, 0f); + TabContainer.Spacing = new Vector2(HORIZONTAL_SPACING, 0f); AddInternal(strip = new Box { @@ -63,35 +80,12 @@ namespace osu.Game.Graphics.UserInterface AccentColour = colours.Blue; } - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - if (Dropdown is IHasAccentColour dropdown) - dropdown.AccentColour = value; - foreach (var i in TabContainer.Children.OfType()) - i.AccentColour = value; - } - } - public Color4 StripColour { get => strip.Colour; set => strip.Colour = value; } - protected override TabFillFlowContainer CreateTabFlow() => new OsuTabFillFlowContainer - { - Direction = FillDirection.Full, - RelativeSizeAxes = Axes.Both, - Depth = -1, - Masking = true - }; - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -121,13 +115,13 @@ namespace osu.Game.Graphics.UserInterface private const float transition_length = 500; - private void fadeActive() + protected void FadeHovered() { Bar.FadeIn(transition_length, Easing.OutQuint); Text.FadeColour(Color4.White, transition_length, Easing.OutQuint); } - private void fadeInactive() + protected void FadeUnhovered() { Bar.FadeOut(transition_length, Easing.OutQuint); Text.FadeColour(AccentColour, transition_length, Easing.OutQuint); @@ -136,14 +130,14 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { if (!Active.Value) - fadeActive(); + FadeHovered(); return true; } protected override void OnHoverLost(HoverLostEvent e) { if (!Active.Value) - fadeInactive(); + FadeUnhovered(); } [BackgroundDependencyLoader] @@ -180,113 +174,19 @@ namespace osu.Game.Graphics.UserInterface }, new HoverClickSounds() }; - - Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Exo, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); } - protected override void OnActivated() => fadeActive(); - - protected override void OnDeactivated() => fadeInactive(); - } - - // todo: this needs to go - private class OsuTabDropdown : OsuDropdown - { - public OsuTabDropdown() + protected override void OnActivated() { - RelativeSizeAxes = Axes.X; + Text.Font = Text.Font.With(weight: FontWeight.Bold); + FadeHovered(); } - protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); - - protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader + protected override void OnDeactivated() { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - }; - - private class OsuTabDropdownMenu : OsuDropdownMenu - { - public OsuTabDropdownMenu() - { - Anchor = Anchor.TopRight; - Origin = Anchor.TopRight; - - BackgroundColour = Color4.Black.Opacity(0.7f); - MaxHeight = 400; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; - - private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem - { - public DrawableOsuTabDropdownMenuItem(MenuItem item) - : base(item) - { - ForegroundColourHover = Color4.Black; - } - } + Text.Font = Text.Font.With(weight: FontWeight.Medium); + FadeUnhovered(); } - - protected class OsuTabDropdownHeader : OsuDropdownHeader - { - public override Color4 AccentColour - { - get => base.AccentColour; - set - { - base.AccentColour = value; - Foreground.Colour = value; - } - } - - public OsuTabDropdownHeader() - { - RelativeSizeAxes = Axes.None; - AutoSizeAxes = Axes.X; - - BackgroundColour = Color4.Black.Opacity(0.5f); - - Background.Height = 0.5f; - Background.CornerRadius = 5; - Background.Masking = true; - - Foreground.RelativeSizeAxes = Axes.None; - Foreground.AutoSizeAxes = Axes.X; - Foreground.RelativeSizeAxes = Axes.Y; - Foreground.Margin = new MarginPadding(5); - - Foreground.Children = new Drawable[] - { - new SpriteIcon - { - Icon = FontAwesome.Solid.EllipsisH, - Size = new Vector2(14), - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - } - }; - - Padding = new MarginPadding { Left = 5, Right = 5 }; - } - - protected override bool OnHover(HoverEvent e) - { - Foreground.Colour = BackgroundColour; - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - Foreground.Colour = BackgroundColourHover; - base.OnHoverLost(e); - } - } - } - - private class OsuTabFillFlowContainer : TabFillFlowContainer - { - protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index 544acc7eb2..bdc95ee048 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -21,7 +21,6 @@ namespace osu.Game.Graphics.UserInterface { private readonly Box box; private readonly SpriteText text; - private readonly SpriteIcon icon; private Color4? accentColour; @@ -32,12 +31,6 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; - if (Current.Value) - { - text.Colour = AccentColour; - icon.Colour = AccentColour; - } - updateFade(); } } @@ -52,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface public OsuTabControlCheckbox() { + SpriteIcon icon; + AutoSizeAxes = Axes.Both; Children = new Drawable[] @@ -89,6 +84,8 @@ namespace osu.Game.Graphics.UserInterface { icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle; text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium); + + updateFade(); }; } @@ -115,8 +112,8 @@ namespace osu.Game.Graphics.UserInterface private void updateFade() { - box.FadeTo(IsHovered ? 1 : 0, transition_length, Easing.OutQuint); - text.FadeColour(IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); + box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint); + text.FadeColour(Current.Value || IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs new file mode 100644 index 0000000000..24b9ca8d90 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; +using osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; + +namespace osu.Game.Graphics.UserInterface +{ + public class OsuTabDropdown : OsuDropdown + { + public OsuTabDropdown() + { + RelativeSizeAxes = Axes.X; + } + + protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); + + protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }; + + private class OsuTabDropdownMenu : OsuDropdownMenu + { + public OsuTabDropdownMenu() + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + BackgroundColour = Color4.Black.Opacity(0.7f); + MaxHeight = 400; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; + + private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem + { + public DrawableOsuTabDropdownMenuItem(MenuItem item) + : base(item) + { + ForegroundColourHover = Color4.Black; + } + } + } + + protected class OsuTabDropdownHeader : OsuDropdownHeader + { + public override Color4 AccentColour + { + get => base.AccentColour; + set + { + base.AccentColour = value; + Foreground.Colour = value; + } + } + + public OsuTabDropdownHeader() + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.X; + + BackgroundColour = Color4.Black.Opacity(0.5f); + + Background.Height = 0.5f; + Background.CornerRadius = 5; + Background.Masking = true; + + Foreground.RelativeSizeAxes = Axes.None; + Foreground.AutoSizeAxes = Axes.X; + Foreground.RelativeSizeAxes = Axes.Y; + Foreground.Margin = new MarginPadding(5); + + Foreground.Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.EllipsisH, + Size = new Vector2(14), + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + } + }; + + Padding = new MarginPadding { Left = 5, Right = 5 }; + } + + protected override bool OnHover(HoverEvent e) + { + Foreground.Colour = BackgroundColour; + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Foreground.Colour = BackgroundColourHover; + base.OnHoverLost(e); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index f5b7bc3073..0d173e2d3e 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -1,19 +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.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Sprites; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osuTK; namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { + private readonly SampleChannel[] textAddedSamples = new SampleChannel[4]; + private SampleChannel capsTextAddedSample; + private SampleChannel textRemovedSample; + private SampleChannel textCommittedSample; + private SampleChannel caretMovedSample; + + /// + /// Whether to allow playing a different samples based on the type of character. + /// If set to false, the same sample will be used for all characters. + /// + protected virtual bool AllowUniqueCharacterSamples => true; + protected override float LeftRightPadding => 10; protected override float CaretWidth => 3; @@ -36,15 +57,54 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colour) + private void load(OsuColour colour, AudioManager audio) { BackgroundUnfocused = Color4.Black.Opacity(0.5f); BackgroundFocused = OsuColour.Gray(0.3f).Opacity(0.8f); BackgroundCommit = BorderColour = colour.Yellow; + + 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"); } protected override Color4 SelectionColour => new Color4(249, 90, 255, 255); + protected override void OnTextAdded(string added) + { + base.OnTextAdded(added); + + if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) + capsTextAddedSample?.Play(); + else + textAddedSamples[RNG.Next(0, 3)]?.Play(); + } + + protected override void OnTextRemoved(string removed) + { + base.OnTextRemoved(removed); + + textRemovedSample?.Play(); + } + + protected override void OnTextCommitted(bool textChanged) + { + base.OnTextCommitted(textChanged); + + textCommittedSample?.Play(); + } + + protected override void OnCaretMoved(bool selecting) + { + base.OnCaretMoved(selecting); + + caretMovedSample?.Play(); + } + protected override void OnFocus(FocusEvent e) { BorderThickness = 3; @@ -58,6 +118,96 @@ namespace osu.Game.Graphics.UserInterface base.OnFocusLost(e); } - protected override Drawable GetDrawableCharacter(char c) => new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }; + protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer + { + AutoSizeAxes = Axes.Both, + Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, + }; + + protected override Caret CreateCaret() => new OsuCaret + { + CaretWidth = CaretWidth, + SelectionColour = SelectionColour, + }; + + private class OsuCaret : Caret + { + private const float caret_move_time = 60; + + private readonly CaretBeatSyncedContainer beatSync; + + public OsuCaret() + { + RelativeSizeAxes = Axes.Y; + Size = new Vector2(1, 0.9f); + + Colour = Color4.Transparent; + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Masking = true; + CornerRadius = 1; + InternalChild = beatSync = new CaretBeatSyncedContainer + { + RelativeSizeAxes = Axes.Both, + }; + } + + public override void Hide() => this.FadeOut(200); + + public float CaretWidth { get; set; } + + public Color4 SelectionColour { get; set; } + + public override void DisplayAt(Vector2 position, float? selectionWidth) + { + beatSync.HasSelection = selectionWidth != null; + + if (selectionWidth != null) + { + this.MoveTo(new Vector2(position.X, position.Y), 60, Easing.Out); + this.ResizeWidthTo(selectionWidth.Value + CaretWidth / 2, caret_move_time, Easing.Out); + this.FadeColour(SelectionColour, 200, Easing.Out); + } + else + { + this.MoveTo(new Vector2(position.X - CaretWidth / 2, position.Y), 60, Easing.Out); + this.ResizeWidthTo(CaretWidth, caret_move_time, Easing.Out); + this.FadeColour(Color4.White, 200, Easing.Out); + } + } + + private class CaretBeatSyncedContainer : BeatSyncedContainer + { + private bool hasSelection; + + public bool HasSelection + { + set + { + hasSelection = value; + if (value) + + this.FadeTo(0.5f, 200, Easing.Out); + } + } + + public CaretBeatSyncedContainer() + { + MinimumBeatLength = 300; + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!hasSelection) + this.FadeTo(0.7f).FadeTo(0.4f, timingPoint.BeatLength, Easing.InOutSine); + } + } + } } } diff --git a/osu.Game/Graphics/UserInterface/PageTabControl.cs b/osu.Game/Graphics/UserInterface/PageTabControl.cs index ddcb626701..d05a08108a 100644 --- a/osu.Game/Graphics/UserInterface/PageTabControl.cs +++ b/osu.Game/Graphics/UserInterface/PageTabControl.cs @@ -78,7 +78,7 @@ namespace osu.Game.Graphics.UserInterface new HoverClickSounds() }; - Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Exo, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); + Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); } protected virtual string CreateText() => (Value as Enum)?.GetDescription() ?? Value.ToString(); diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 064c663d59..940c9808ce 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Game.Utils; namespace osu.Game.Graphics.UserInterface { @@ -29,10 +30,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(OsuColour colours) => AccentColour = colours.BlueLighter; - protected override string FormatCount(double count) - { - return $@"{count:P2}"; - } + protected override string FormatCount(double count) => count.FormatAccuracy(); protected override double GetProportionalDuration(double currentValue, double newValue) { diff --git a/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs b/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs deleted file mode 100644 index d75e78e2d9..0000000000 --- a/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// An overlay that will consume all available space and block input when required. - /// Useful for disabling all elements in a form and showing we are waiting on a response, for instance. - /// - public class ProcessingOverlay : VisibilityContainer - { - private const float transition_duration = 200; - - public ProcessingOverlay() - { - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChildren = new Drawable[] - { - new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - Alpha = 0.9f, - }, - new LoadingAnimation { State = { Value = Visibility.Visible } } - }; - } - - protected override bool Handle(UIEvent e) - { - return true; - } - - protected override void PopIn() - { - this.FadeIn(transition_duration * 2, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(transition_duration, Easing.OutQuint); - } - } -} diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 24d8009f40..01d8edaecf 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -16,11 +16,7 @@ namespace osu.Game.Graphics.UserInterface /// /// How many leading zeroes the counter has. /// - public uint LeadingZeroes - { - get; - protected set; - } + public uint LeadingZeroes { get; } /// /// Displays score. diff --git a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs index 3e0a6c3265..e85525b2f8 100644 --- a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs @@ -17,7 +17,8 @@ namespace osu.Game.Graphics.UserInterface stack.ScreenPushed += onPushed; stack.ScreenExited += onExited; - onPushed(null, stack.CurrentScreen); + if (stack.CurrentScreen != null) + onPushed(null, stack.CurrentScreen); Current.ValueChanged += current => current.NewValue.MakeCurrent(); } diff --git a/osu.Game/Graphics/UserInterface/ScreenTitle.cs b/osu.Game/Graphics/UserInterface/ScreenTitle.cs deleted file mode 100644 index ecd0508258..0000000000 --- a/osu.Game/Graphics/UserInterface/ScreenTitle.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - public abstract class ScreenTitle : CompositeDrawable, IHasAccentColour - { - public const float ICON_WIDTH = ICON_SIZE + spacing; - - public const float ICON_SIZE = 25; - private const float spacing = 6; - private const int text_offset = 2; - - private SpriteIcon iconSprite; - private readonly OsuSpriteText titleText, pageText; - - protected IconUsage Icon - { - set - { - if (iconSprite == null) - throw new InvalidOperationException($"Cannot use {nameof(Icon)} with a custom {nameof(CreateIcon)} function."); - - iconSprite.Icon = value; - } - } - - protected string Title - { - set => titleText.Text = value; - } - - protected string Section - { - set => pageText.Text = value; - } - - public Color4 AccentColour - { - get => pageText.Colour; - set => pageText.Colour = value; - } - - protected virtual Drawable CreateIcon() => iconSprite = new SpriteIcon - { - Size = new Vector2(ICON_SIZE), - }; - - protected ScreenTitle() - { - AutoSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(spacing, 0), - Direction = FillDirection.Horizontal, - Children = new[] - { - CreateIcon().With(t => - { - t.Anchor = Anchor.Centre; - t.Origin = Anchor.Centre; - }), - titleText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), - Margin = new MarginPadding { Bottom = text_offset } - }, - new Circle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(4), - Colour = Color4.Gray, - }, - pageText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20), - Margin = new MarginPadding { Bottom = text_offset } - } - } - }, - }; - } - } -} diff --git a/osu.Game/Graphics/UserInterface/ScreenTitleTextureIcon.cs b/osu.Game/Graphics/UserInterface/ScreenTitleTextureIcon.cs deleted file mode 100644 index c2a13970de..0000000000 --- a/osu.Game/Graphics/UserInterface/ScreenTitleTextureIcon.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osuTK; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// A custom icon class for use with based off a texture resource. - /// - public class ScreenTitleTextureIcon : CompositeDrawable - { - private readonly string textureName; - - public ScreenTitleTextureIcon(string textureName) - { - this.textureName = textureName; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Size = new Vector2(ScreenTitle.ICON_SIZE); - - InternalChild = new Sprite - { - RelativeSizeAxes = Axes.Both, - Texture = textures.Get(textureName), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fit - }; - } - } -} diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index fe8756a4d2..fe92054d25 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -17,18 +17,16 @@ namespace osu.Game.Graphics.UserInterface public SearchTextBox() { Height = 35; - AddRange(new Drawable[] + Add(new SpriteIcon { - new SpriteIcon - { - Icon = FontAwesome.Solid.Search, - Origin = Anchor.CentreRight, - Anchor = Anchor.CentreRight, - Margin = new MarginPadding { Right = 10 }, - Size = new Vector2(20), - } + Icon = FontAwesome.Solid.Search, + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, + Size = new Vector2(20), }); + TextFlow.Padding = new MarginPadding { Right = 35 }; PlaceholderText = "type to search"; } diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs index 4931a6aed6..c9cd9f1158 100644 --- a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -40,13 +40,14 @@ namespace osu.Game.Graphics.UserInterface public ShowMoreButton() { - AutoSizeAxes = Axes.Both; + Height = 30; + Width = 140; } protected override Drawable CreateContent() => new CircularContainer { Masking = true, - Size = new Vector2(140, 30), + RelativeSizeAxes = Axes.Both, Children = new Drawable[] { background = new Box diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index 3ee572602b..b13d6485ac 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -4,7 +4,7 @@ using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using System; using System.Linq; using osu.Framework.Graphics.Sprites; @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.UserInterface { public class StarCounter : Container { - private readonly Container stars; + private readonly FillFlowContainer stars; /// /// Maximum amount of stars displayed. @@ -23,34 +23,29 @@ namespace osu.Game.Graphics.UserInterface /// public int StarCount { get; } - private double animationDelay => 80; + /// + /// The added delay for each subsequent star to be animated. + /// + protected virtual double AnimationDelay => 80; - private double scalingDuration => 1000; - private Easing scalingEasing => Easing.OutElasticHalf; - private float minStarScale => 0.4f; - - private double fadingDuration => 100; - private float minStarAlpha => 0.5f; - - private const float star_size = 20; private const float star_spacing = 4; - private float countStars; + private float current; /// /// Amount of stars represented. /// - public float CountStars + public float Current { - get => countStars; + get => current; set { - if (countStars == value) return; + if (current == value) return; if (IsLoaded) - transformCount(value); - countStars = value; + animate(value); + current = value; } } @@ -71,11 +66,13 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(star_spacing), - ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(i => new Star { Alpha = minStarAlpha }) + ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(i => CreateStar()) } }; } + public virtual Star CreateStar() => new DefaultStar(); + protected override void LoadComplete() { base.LoadComplete(); @@ -86,63 +83,60 @@ namespace osu.Game.Graphics.UserInterface public void ResetCount() { - countStars = 0; + current = 0; StopAnimation(); } public void ReplayAnimation() { - var t = countStars; + var t = current; ResetCount(); - CountStars = t; + Current = t; } public void StopAnimation() { - int i = 0; - + animate(current); foreach (var star in stars.Children) + star.FinishTransforms(true); + } + + private float getStarScale(int i, float value) => i + 1 <= value ? 1.0f : Interpolation.ValueAt(value, 0, 1.0f, i, i + 1); + + private void animate(float newValue) + { + for (var i = 0; i < stars.Children.Count; i++) { + var star = stars.Children[i]; + star.ClearTransforms(true); - star.FadeTo(i < countStars ? 1.0f : minStarAlpha); - star.Icon.ScaleTo(getStarScale(i, countStars)); - i++; + + double delay = (current <= newValue ? Math.Max(i - current, 0) : Math.Max(current - 1 - i, 0)) * AnimationDelay; + + using (star.BeginDelayedSequence(delay, true)) + star.DisplayAt(getStarScale(i, newValue)); } } - private float getStarScale(int i, float value) + public class DefaultStar : Star { - if (value <= i) - return minStarScale; + private const double scaling_duration = 1000; - return i + 1 <= value ? 1.0f : Interpolation.ValueAt(value, minStarScale, 1.0f, i, i + 1); - } + private const double fading_duration = 100; - private void transformCount(float newValue) - { - int i = 0; + private const Easing scaling_easing = Easing.OutElasticHalf; - foreach (var star in stars.Children) - { - star.ClearTransforms(true); + private const float min_star_scale = 0.4f; - var delay = (countStars <= newValue ? Math.Max(i - countStars, 0) : Math.Max(countStars - 1 - i, 0)) * animationDelay; - star.Delay(delay).FadeTo(i < newValue ? 1.0f : minStarAlpha, fadingDuration); - star.Icon.Delay(delay).ScaleTo(getStarScale(i, newValue), scalingDuration, scalingEasing); + private const float star_size = 20; - i++; - } - } - - private class Star : Container - { public readonly SpriteIcon Icon; - public Star() + public DefaultStar() { Size = new Vector2(star_size); - Child = Icon = new SpriteIcon + InternalChild = Icon = new SpriteIcon { Size = new Vector2(star_size), Icon = FontAwesome.Solid.Star, @@ -150,6 +144,19 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.Centre, }; } + + public override void DisplayAt(float scale) + { + scale = Math.Clamp(scale, min_star_scale, 1); + + this.FadeTo(scale, fading_duration); + Icon.ScaleTo(scale, scaling_duration, scaling_easing); + } + } + + public abstract class Star : CompositeDrawable + { + public abstract void DisplayAt(float scale); } } } diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index 2d9e2106d4..acf4065f49 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -11,23 +11,13 @@ namespace osu.Game.Graphics.UserInterface /// public class TernaryStateMenuItem : StatefulMenuItem { - /// - /// Creates a new . - /// - /// The text to display. - /// The type of action which this performs. - public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard) - : this(text, type, null) - { - } - /// /// Creates a new . /// /// The text to display. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public TernaryStateMenuItem(string text, MenuItemType type, Action action) + public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) : this(text, getNextState, type, action) { } diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index aa96796cf1..120149d8c1 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -230,7 +230,7 @@ namespace osu.Game.Graphics.UserInterface }; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs new file mode 100644 index 0000000000..ae34281bfb --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.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 System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class DirectorySelector : CompositeDrawable + { + private FillFlowContainer directoryFlow; + + [Resolved] + private GameHost host { get; set; } + + [Cached] + public readonly Bindable CurrentDirectory = new Bindable(); + + public DirectorySelector(string initialPath = null) + { + CurrentDirectory.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + } + + [BackgroundDependencyLoader] + private void load() + { + Padding = new MarginPadding(10); + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new CurrentDirectoryDisplay + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Drawable[] + { + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = directoryFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + } + } + } + } + }; + + CurrentDirectory.BindValueChanged(updateDisplay, true); + } + + private void updateDisplay(ValueChangedEvent directory) + { + directoryFlow.Clear(); + + try + { + if (directory.NewValue == null) + { + var drives = DriveInfo.GetDrives(); + + foreach (var drive in drives) + directoryFlow.Add(new DirectoryPiece(drive.RootDirectory)); + } + else + { + directoryFlow.Add(new ParentDirectoryPiece(CurrentDirectory.Value.Parent)); + + foreach (var dir in CurrentDirectory.Value.GetDirectories().OrderBy(d => d.Name)) + { + if ((dir.Attributes & FileAttributes.Hidden) == 0) + directoryFlow.Add(new DirectoryPiece(dir)); + } + } + } + catch (Exception) + { + CurrentDirectory.Value = directory.OldValue; + this.FlashColour(Color4.Red, 300); + } + } + + private class CurrentDirectoryDisplay : CompositeDrawable + { + [Resolved] + private Bindable currentDirectory { get; set; } + + private FillFlowContainer flow; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + flow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(5), + Height = DirectoryPiece.HEIGHT, + Direction = FillDirection.Horizontal, + }, + }; + + currentDirectory.BindValueChanged(updateDisplay, true); + } + + private void updateDisplay(ValueChangedEvent dir) + { + flow.Clear(); + + List pathPieces = new List(); + + DirectoryInfo ptr = dir.NewValue; + + while (ptr != null) + { + pathPieces.Insert(0, new CurrentDisplayPiece(ptr)); + ptr = ptr.Parent; + } + + flow.ChildrenEnumerable = new Drawable[] + { + new OsuSpriteText { Text = "Current Directory: ", Font = OsuFont.Default.With(size: DirectoryPiece.HEIGHT), }, + new ComputerPiece(), + }.Concat(pathPieces); + } + + private class ComputerPiece : CurrentDisplayPiece + { + protected override IconUsage? Icon => null; + + public ComputerPiece() + : base(null, "Computer") + { + } + } + + private class CurrentDisplayPiece : DirectoryPiece + { + public CurrentDisplayPiece(DirectoryInfo directory, string displayName = null) + : base(directory, displayName) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Flow.Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(FONT_SIZE / 2) + }); + } + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) ? base.Icon : null; + } + } + + private class ParentDirectoryPiece : DirectoryPiece + { + protected override IconUsage? Icon => FontAwesome.Solid.Folder; + + public ParentDirectoryPiece(DirectoryInfo directory) + : base(directory, "..") + { + } + } + + private class DirectoryPiece : CompositeDrawable + { + public const float HEIGHT = 20; + + protected const float FONT_SIZE = 16; + + protected readonly DirectoryInfo Directory; + + private readonly string displayName; + + protected FillFlowContainer Flow; + + [Resolved] + private Bindable currentDirectory { get; set; } + + public DirectoryPiece(DirectoryInfo directory, string displayName = null) + { + Directory = directory; + this.displayName = displayName; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDarker, + RelativeSizeAxes = Axes.Both, + }, + Flow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = 20, + Margin = new MarginPadding { Vertical = 2, Horizontal = 5 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + } + }; + + if (Icon.HasValue) + { + Flow.Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = Icon.Value, + Size = new Vector2(FONT_SIZE) + }); + } + + Flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = displayName ?? Directory.Name, + Font = OsuFont.Default.With(size: FONT_SIZE) + }); + } + + protected override bool OnClick(ClickEvent e) + { + currentDirectory.Value = Directory; + return true; + } + + protected virtual IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) + ? FontAwesome.Solid.Database + : FontAwesome.Regular.Folder; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index f44bd72aee..0e995ca73d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.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.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -42,7 +43,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("1c2125"), + Colour = Color4Extensions.FromHex("1c2125"), }, new FillFlowContainer { diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index 4ee7a19ebc..a30f961daf 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -45,7 +45,5 @@ namespace osu.Game.IO.Archives return buffer; } } - - public abstract Stream GetUnderlyingStream(); } } diff --git a/osu.Game/IO/Archives/LegacyByteArrayReader.cs b/osu.Game/IO/Archives/LegacyByteArrayReader.cs new file mode 100644 index 0000000000..0c3620403f --- /dev/null +++ b/osu.Game/IO/Archives/LegacyByteArrayReader.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.Collections.Generic; +using System.IO; + +namespace osu.Game.IO.Archives +{ + /// + /// Allows reading a single file from the provided stream. + /// + public class LegacyByteArrayReader : ArchiveReader + { + private readonly byte[] content; + + public LegacyByteArrayReader(byte[] content, string filename) + : base(filename) + { + this.content = content; + } + + public override Stream GetStream(string name) => new MemoryStream(content); + + public override void Dispose() + { + } + + public override IEnumerable Filenames => new[] { Name }; + } +} diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs index eff02ae7a5..dfae58aed7 100644 --- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs @@ -28,7 +28,5 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => Directory.GetFiles(path, "*", SearchOption.AllDirectories).Select(f => f.Replace(path, string.Empty).Trim(Path.DirectorySeparatorChar)).ToArray(); - - public override Stream GetUnderlyingStream() => null; } } diff --git a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs index bd5f9cbd07..72e5a21079 100644 --- a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs @@ -28,7 +28,5 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => new[] { Name }; - - public override Stream GetUnderlyingStream() => null; } } diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index 35f38ea7e8..80dfa104f3 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -45,7 +45,5 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames(); - - public override Stream GetUnderlyingStream() => archiveStream; } } diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index c75de93bc8..bb8014fe54 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -130,91 +130,91 @@ namespace osu.Game.IO.Legacy } else { - switch (obj.GetType().Name) + switch (obj) { - case "Boolean": + case bool boolObj: Write((byte)ObjType.boolType); - Write((bool)obj); + Write(boolObj); break; - case "Byte": + case byte byteObj: Write((byte)ObjType.byteType); - Write((byte)obj); + Write(byteObj); break; - case "UInt16": + case ushort ushortObj: Write((byte)ObjType.uint16Type); - Write((ushort)obj); + Write(ushortObj); break; - case "UInt32": + case uint uintObj: Write((byte)ObjType.uint32Type); - Write((uint)obj); + Write(uintObj); break; - case "UInt64": + case ulong ulongObj: Write((byte)ObjType.uint64Type); - Write((ulong)obj); + Write(ulongObj); break; - case "SByte": + case sbyte sbyteObj: Write((byte)ObjType.sbyteType); - Write((sbyte)obj); + Write(sbyteObj); break; - case "Int16": + case short shortObj: Write((byte)ObjType.int16Type); - Write((short)obj); + Write(shortObj); break; - case "Int32": + case int intObj: Write((byte)ObjType.int32Type); - Write((int)obj); + Write(intObj); break; - case "Int64": + case long longObj: Write((byte)ObjType.int64Type); - Write((long)obj); + Write(longObj); break; - case "Char": + case char charObj: Write((byte)ObjType.charType); - base.Write((char)obj); + base.Write(charObj); break; - case "String": + case string stringObj: Write((byte)ObjType.stringType); - base.Write((string)obj); + base.Write(stringObj); break; - case "Single": + case float floatObj: Write((byte)ObjType.singleType); - Write((float)obj); + Write(floatObj); break; - case "Double": + case double doubleObj: Write((byte)ObjType.doubleType); - Write((double)obj); + Write(doubleObj); break; - case "Decimal": + case decimal decimalObj: Write((byte)ObjType.decimalType); - Write((decimal)obj); + Write(decimalObj); break; - case "DateTime": + case DateTime dateTimeObj: Write((byte)ObjType.dateTimeType); - Write((DateTime)obj); + Write(dateTimeObj); break; - case "Byte[]": + case byte[] byteArray: Write((byte)ObjType.byteArrayType); - base.Write((byte[])obj); + base.Write(byteArray); break; - case "Char[]": + case char[] charArray: Write((byte)ObjType.charArrayType); - base.Write((char[])obj); + base.Write(charArray); break; default: diff --git a/osu.Game/IO/LineBufferedReader.cs b/osu.Game/IO/LineBufferedReader.cs index aab761afd8..018321dc9a 100644 --- a/osu.Game/IO/LineBufferedReader.cs +++ b/osu.Game/IO/LineBufferedReader.cs @@ -17,9 +17,9 @@ namespace osu.Game.IO private readonly StreamReader streamReader; private readonly Queue lineBuffer; - public LineBufferedReader(Stream stream) + public LineBufferedReader(Stream stream, bool leaveOpen = false) { - streamReader = new StreamReader(stream); + streamReader = new StreamReader(stream, Encoding.UTF8, true, 1024, leaveOpen); lineBuffer = new Queue(); } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs new file mode 100644 index 0000000000..1d15294666 --- /dev/null +++ b/osu.Game/IO/OsuStorage.cs @@ -0,0 +1,227 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using JetBrains.Annotations; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Configuration; + +namespace osu.Game.IO +{ + public class OsuStorage : WrappedStorage + { + /// + /// Indicates the error (if any) that occurred when initialising the custom storage during initial startup. + /// + public readonly OsuStorageError Error; + + /// + /// The custom storage path as selected by the user. + /// + [CanBeNull] + public string CustomStoragePath => storageConfig.Get(StorageConfig.FullPath); + + /// + /// The default storage path to be used if a custom storage path hasn't been selected or is not accessible. + /// + [NotNull] + public string DefaultStoragePath => defaultStorage.GetFullPath("."); + + private readonly GameHost host; + private readonly StorageConfigManager storageConfig; + private readonly Storage defaultStorage; + + public static readonly string[] IGNORE_DIRECTORIES = { "cache" }; + + public static readonly string[] IGNORE_FILES = + { + "framework.ini", + "storage.ini" + }; + + public OsuStorage(GameHost host, Storage defaultStorage) + : base(defaultStorage, string.Empty) + { + this.host = host; + this.defaultStorage = defaultStorage; + + storageConfig = new StorageConfigManager(defaultStorage); + + if (!string.IsNullOrEmpty(CustomStoragePath)) + TryChangeToCustomStorage(out Error); + } + + /// + /// Resets the custom storage path, changing the target storage to the default location. + /// + public void ResetCustomStoragePath() + { + storageConfig.Set(StorageConfig.FullPath, string.Empty); + storageConfig.Save(); + + ChangeTargetStorage(defaultStorage); + } + + /// + /// Attempts to change to the user's custom storage path. + /// + /// The error that occurred. + /// Whether the custom storage path was used successfully. If not, will be populated with the reason. + public bool TryChangeToCustomStorage(out OsuStorageError error) + { + Debug.Assert(!string.IsNullOrEmpty(CustomStoragePath)); + + error = OsuStorageError.None; + Storage lastStorage = UnderlyingStorage; + + try + { + Storage userStorage = host.GetStorage(CustomStoragePath); + + if (!userStorage.ExistsDirectory(".") || !userStorage.GetFiles(".").Any()) + error = OsuStorageError.AccessibleButEmpty; + + ChangeTargetStorage(userStorage); + } + catch + { + error = OsuStorageError.NotAccessible; + ChangeTargetStorage(lastStorage); + } + + return error == OsuStorageError.None; + } + + protected override void ChangeTargetStorage(Storage newStorage) + { + base.ChangeTargetStorage(newStorage); + Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); + } + + public void Migrate(string newLocation) + { + var source = new DirectoryInfo(GetFullPath(".")); + var destination = new DirectoryInfo(newLocation); + + // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) + var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); + var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); + + if (sourceUri == destinationUri) + throw new ArgumentException("Destination provided is already the current location", nameof(newLocation)); + + if (sourceUri.IsBaseOf(destinationUri)) + throw new ArgumentException("Destination provided is inside the source", nameof(newLocation)); + + // ensure the new location has no files present, else hard abort + if (destination.Exists) + { + if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) + throw new ArgumentException("Destination provided already has files or directories present", nameof(newLocation)); + + deleteRecursive(destination); + } + + copyRecursive(source, destination); + + ChangeTargetStorage(host.GetStorage(newLocation)); + + storageConfig.Set(StorageConfig.FullPath, newLocation); + storageConfig.Save(); + + deleteRecursive(source); + } + + private static void deleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) + { + foreach (System.IO.FileInfo fi in target.GetFiles()) + { + if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) + continue; + + attemptOperation(() => fi.Delete()); + } + + foreach (DirectoryInfo dir in target.GetDirectories()) + { + if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) + continue; + + attemptOperation(() => dir.Delete(true)); + } + + if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) + attemptOperation(target.Delete); + } + + private static void copyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) + { + // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo + Directory.CreateDirectory(destination.FullName); + + foreach (System.IO.FileInfo fi in source.GetFiles()) + { + if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) + continue; + + attemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + } + + foreach (DirectoryInfo dir in source.GetDirectories()) + { + if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) + continue; + + copyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); + } + } + + /// + /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. + /// + /// The action to perform. + /// The number of attempts (250ms wait between each). + private static void attemptOperation(Action action, int attempts = 10) + { + while (true) + { + try + { + action(); + return; + } + catch (Exception) + { + if (attempts-- == 0) + throw; + } + + Thread.Sleep(250); + } + } + } + + public enum OsuStorageError + { + /// + /// No error. + /// + None, + + /// + /// Occurs when the target storage directory is accessible but does not already contain game files. + /// Only happens when the user changes the storage directory and then moves the files manually or mounts a different device to the same path. + /// + AccessibleButEmpty, + + /// + /// Occurs when the target storage directory cannot be accessed at all. + /// + NotAccessible, + } +} diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index 6d244bff60..50b28ea74b 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -9,12 +9,12 @@ using Newtonsoft.Json.Linq; namespace osu.Game.IO.Serialization.Converters { /// - /// A type of that serializes a alongside + /// A type of that serializes an alongside /// a lookup table for the types contained. The lookup table is used in deserialization to /// reconstruct the objects with their original types. /// - /// The type of objects contained in the this attribute is attached to. - public class TypedListConverter : JsonConverter + /// The type of objects contained in the this attribute is attached to. + public class TypedListConverter : JsonConverter> { private readonly bool requiresTypeVersion; @@ -36,20 +36,30 @@ namespace osu.Game.IO.Serialization.Converters this.requiresTypeVersion = requiresTypeVersion; } - public override bool CanConvert(Type objectType) => objectType == typeof(List); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override IReadOnlyList ReadJson(JsonReader reader, Type objectType, IReadOnlyList existingValue, bool hasExistingValue, JsonSerializer serializer) { var list = new List(); var obj = JObject.Load(reader); - var lookupTable = serializer.Deserialize>(obj["lookup_table"].CreateReader()); - foreach (var tok in obj["items"]) + if (obj["$lookup_table"] == null) + return list; + + var lookupTable = serializer.Deserialize>(obj["$lookup_table"].CreateReader()); + if (lookupTable == null) + return list; + + if (obj["$items"] == null) + return list; + + foreach (var tok in obj["$items"]) { var itemReader = tok.CreateReader(); - var typeName = lookupTable[(int)tok["type"]]; + if (tok["$type"] == null) + throw new JsonException("Expected $type token."); + + var typeName = lookupTable[(int)tok["$type"]]; var instance = (T)Activator.CreateInstance(Type.GetType(typeName)); serializer.Populate(itemReader, instance); @@ -59,14 +69,12 @@ namespace osu.Game.IO.Serialization.Converters return list; } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, IReadOnlyList value, JsonSerializer serializer) { - var list = (List)value; - var lookupTable = new List(); var objects = new List(); - foreach (var item in list) + foreach (var item in value) { var type = item.GetType(); var assemblyName = type.Assembly.GetName(); @@ -84,16 +92,16 @@ namespace osu.Game.IO.Serialization.Converters } var itemObject = JObject.FromObject(item, serializer); - itemObject.AddFirst(new JProperty("type", typeId)); + itemObject.AddFirst(new JProperty("$type", typeId)); objects.Add(itemObject); } writer.WriteStartObject(); - writer.WritePropertyName("lookup_table"); + writer.WritePropertyName("$lookup_table"); serializer.Serialize(writer, lookupTable); - writer.WritePropertyName("items"); + writer.WritePropertyName("$items"); serializer.Serialize(writer, objects); writer.WriteEndObject(); diff --git a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs index bf5edeef94..46447b607b 100644 --- a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs +++ b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs @@ -11,26 +11,22 @@ namespace osu.Game.IO.Serialization.Converters /// /// A type of that serializes only the X and Y coordinates of a . /// - public class Vector2Converter : JsonConverter + public class Vector2Converter : JsonConverter { - public override bool CanConvert(Type objectType) => objectType == typeof(Vector2); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) { var obj = JObject.Load(reader); return new Vector2((float)obj["x"], (float)obj["y"]); } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) { - var vector = (Vector2)value; - writer.WriteStartObject(); writer.WritePropertyName("x"); - writer.WriteValue(vector.X); + writer.WriteValue(value.X); writer.WritePropertyName("y"); - writer.WriteValue(vector.Y); + writer.WriteValue(value.Y); writer.WriteEndObject(); } diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs new file mode 100644 index 0000000000..1dd3afbfae --- /dev/null +++ b/osu.Game/IO/WrappedStorage.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A storage which wraps another storage and delegates implementation, potentially mutating the lookup path. + /// + public class WrappedStorage : Storage + { + protected Storage UnderlyingStorage { get; private set; } + + private readonly string subPath; + + public WrappedStorage(Storage underlyingStorage, string subPath = null) + : base(string.Empty) + { + ChangeTargetStorage(underlyingStorage); + + this.subPath = subPath; + } + + protected virtual string MutatePath(string path) => !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path; + + protected virtual void ChangeTargetStorage(Storage newStorage) + { + UnderlyingStorage = newStorage; + } + + public override string GetFullPath(string path, bool createIfNotExisting = false) => + UnderlyingStorage.GetFullPath(MutatePath(path), createIfNotExisting); + + public override bool Exists(string path) => + UnderlyingStorage.Exists(MutatePath(path)); + + public override bool ExistsDirectory(string path) => + UnderlyingStorage.ExistsDirectory(MutatePath(path)); + + public override void DeleteDirectory(string path) => + UnderlyingStorage.DeleteDirectory(MutatePath(path)); + + public override void Delete(string path) => + UnderlyingStorage.Delete(MutatePath(path)); + + public override IEnumerable GetDirectories(string path) => + ToLocalRelative(UnderlyingStorage.GetDirectories(MutatePath(path))); + + public IEnumerable ToLocalRelative(IEnumerable paths) + { + string localRoot = GetFullPath(string.Empty); + + foreach (var path in paths) + yield return Path.GetRelativePath(localRoot, UnderlyingStorage.GetFullPath(path)); + } + + public override IEnumerable GetFiles(string path, string pattern = "*") => + ToLocalRelative(UnderlyingStorage.GetFiles(MutatePath(path), pattern)); + + public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => + UnderlyingStorage.GetStream(MutatePath(path), access, mode); + + public override string GetDatabaseConnectionString(string name) => + UnderlyingStorage.GetDatabaseConnectionString(MutatePath(name)); + + public override void DeleteDatabase(string name) => UnderlyingStorage.DeleteDatabase(MutatePath(name)); + + public override void OpenPathInNativeExplorer(string path) => UnderlyingStorage.OpenPathInNativeExplorer(MutatePath(path)); + + public override Storage GetStorageForDirectory(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Must be non-null and not empty string", nameof(path)); + + if (!path.EndsWith(Path.DirectorySeparatorChar)) + path += Path.DirectorySeparatorChar; + + // create non-existing path. + GetFullPath(path, true); + + return new WrappedStorage(this, path); + } + } +} diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index 484db932f8..029908ec9d 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -32,7 +32,7 @@ namespace osu.Game.IPC { if (importer == null) { - //we want to contact a remote osu! to handle the import. + // we want to contact a remote osu! to handle the import. await SendMessageAsync(new ArchiveImportMessage { Path = path }); return; } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index ea274284ac..94edc33099 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -31,8 +31,9 @@ namespace osu.Game.Input.Bindings /// A reference to identify the current . Used to lookup mappings. Null for global mappings. /// An optional variant for the specified . Used when a ruleset has more than one possible keyboard layouts. /// Specify how to deal with multiple matches of s and s. - public DatabasedKeyBindingContainer(RulesetInfo ruleset = null, int? variant = null, SimultaneousBindingMode simultaneousMode = SimultaneousBindingMode.None) - : base(simultaneousMode) + /// Specify how to deal with exact matches. + public DatabasedKeyBindingContainer(RulesetInfo ruleset = null, int? variant = null, SimultaneousBindingMode simultaneousMode = SimultaneousBindingMode.None, KeyCombinationMatchingMode matchingMode = KeyCombinationMatchingMode.Any) + : base(simultaneousMode, matchingMode) { this.ruleset = ruleset; this.variant = variant; @@ -61,6 +62,14 @@ namespace osu.Game.Input.Bindings store.KeyBindingChanged -= ReloadMappings; } - protected override void ReloadMappings() => KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + protected override void ReloadMappings() + { + if (ruleset != null && !ruleset.ID.HasValue) + // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings. + // fallback to defaults instead. + KeyBindings = DefaultKeyBindings; + else + KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index bf758e21d9..6ae420b162 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -15,6 +15,7 @@ namespace osu.Game.Input.Bindings private readonly Drawable handler; public GlobalActionContainer(OsuGameBase game) + : base(matchingMode: KeyCombinationMatchingMode.Modifiers) { if (game is IKeyBindingHandler) handler = game; @@ -24,6 +25,7 @@ namespace osu.Game.Input.Bindings 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), @@ -32,10 +34,17 @@ namespace osu.Game.Input.Bindings 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.ToggleDirect), + new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), 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), + new KeyBinding(InputKey.Space, GlobalAction.Select), new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), @@ -48,14 +57,16 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Tilde }, GlobalAction.QuickExit), new KeyBinding(new[] { InputKey.Control, InputKey.Plus }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Minus }, GlobalAction.DecreaseScrollSpeed), + new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), }; public IEnumerable AudioControlKeyBindings => new[] { - new KeyBinding(InputKey.Up, GlobalAction.IncreaseVolume), - new KeyBinding(InputKey.MouseWheelUp, GlobalAction.IncreaseVolume), - new KeyBinding(InputKey.Down, GlobalAction.DecreaseVolume), - new KeyBinding(InputKey.MouseWheelDown, GlobalAction.DecreaseVolume), + new KeyBinding(new[] { InputKey.Alt, InputKey.Up }, GlobalAction.IncreaseVolume), + new KeyBinding(new[] { InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.IncreaseVolume), + new KeyBinding(new[] { InputKey.Alt, InputKey.Down }, GlobalAction.DecreaseVolume), + new KeyBinding(new[] { InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.DecreaseVolume), + new KeyBinding(InputKey.F4, GlobalAction.ToggleMute), new KeyBinding(InputKey.TrackPrevious, GlobalAction.MusicPrev), @@ -136,5 +147,23 @@ namespace osu.Game.Input.Bindings [Description("Play / pause")] MusicPlay, + + [Description("Toggle now playing overlay")] + ToggleNowPlaying, + + [Description("Previous Selection")] + SelectPrevious, + + [Description("Next Selection")] + SelectNext, + + [Description("Home")] + Home, + + [Description("Toggle notifications")] + ToggleNotifications, + + [Description("Pause")] + PauseGameplay, } } diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index 39ccf9fe1c..63a6348b57 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -50,7 +50,7 @@ namespace osu.Game.Input public bool OnPressed(PlatformAction action) => updateLastInteractionTime(); - public bool OnReleased(PlatformAction action) => updateLastInteractionTime(); + public void OnReleased(PlatformAction action) => updateLastInteractionTime(); protected override bool Handle(UIEvent e) { diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs index 74b3134964..198ab6883d 100644 --- a/osu.Game/Input/KeyBindingStore.cs +++ b/osu.Game/Input/KeyBindingStore.cs @@ -55,6 +55,9 @@ namespace osu.Game.Input RulesetID = rulesetId, Variant = variant }); + + // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686) + usage.Context.SaveChanges(); } } } diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs new file mode 100644 index 0000000000..22316b0380 --- /dev/null +++ b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs @@ -0,0 +1,506 @@ +// +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 new file mode 100644 index 0000000000..ec4475971c --- /dev/null +++ b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs @@ -0,0 +1,16 @@ +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/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs index a6d9d1f3cb..bc4fc3342d 100644 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs @@ -43,7 +43,7 @@ namespace osu.Game.Migrations b.Property("ID") .ValueGeneratedOnAdd(); - b.Property("AudioLeadIn"); + b.Property("AudioLeadIn"); b.Property("BPM"); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 23c931d161..4ea5c192fe 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.ExceptionExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; @@ -22,6 +23,7 @@ namespace osu.Game.Online.API public class APIAccess : Component, IAPIProvider { private readonly OsuConfigManager config; + private readonly OAuth authentication; public string Endpoint => @"https://osu.ppy.sh"; @@ -126,7 +128,7 @@ namespace osu.Game.Online.API case APIState.Offline: case APIState.Connecting: - //work to restore a connection... + // work to restore a connection... if (!HasLogin) { State = APIState.Offline; @@ -179,7 +181,7 @@ namespace osu.Game.Online.API break; } - //hard bail if we can't get a valid access token. + // hard bail if we can't get a valid access token. if (authentication.RequestAccessToken() == null) { Logout(); @@ -249,7 +251,7 @@ namespace osu.Game.Online.API { try { - return JObject.Parse(req.GetResponseString()).SelectToken("form_error", true).ToObject(); + return JObject.Parse(req.GetResponseString()).SelectToken("form_error", true).AsNonNull().ToObject(); } catch { @@ -273,7 +275,7 @@ namespace osu.Game.Online.API { req.Perform(this); - //we could still be in initialisation, at which point we don't want to say we're Online yet. + // we could still be in initialisation, at which point we don't want to say we're Online yet. if (IsLoggedIn) State = APIState.Online; failureCount = 0; @@ -338,7 +340,7 @@ namespace osu.Game.Online.API log.Add($@"API failure count is now {failureCount}"); if (failureCount < 3) - //we might try again at an api level. + // we might try again at an api level. return false; if (State == APIState.Online) diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs new file mode 100644 index 0000000000..46a8db31b7 --- /dev/null +++ b/osu.Game/Online/API/APIMod.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.Collections.Generic; +using System.Linq; +using Humanizer; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Online.API +{ + public class APIMod : IMod + { + [JsonProperty("acronym")] + public string Acronym { get; set; } + + [JsonProperty("settings")] + public Dictionary Settings { get; set; } = new Dictionary(); + + [JsonConstructor] + private APIMod() + { + } + + public APIMod(Mod mod) + { + Acronym = mod.Acronym; + + foreach (var (_, property) in mod.GetSettingsSourceProperties()) + Settings.Add(property.Name.Underscore(), property.GetValue(mod)); + } + + public Mod ToMod(Ruleset ruleset) + { + Mod resultMod = ruleset.GetAllMods().FirstOrDefault(m => m.Acronym == Acronym); + + if (resultMod == null) + throw new InvalidOperationException($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}."); + + foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) + { + if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + continue; + + ((IBindable)property.GetValue(resultMod)).Parse(settingValue); + } + + return resultMod; + } + + public bool Equals(IMod other) => Acronym == other?.Acronym; + } +} diff --git a/osu.Game/Online/API/APIPlaylistBeatmap.cs b/osu.Game/Online/API/APIPlaylistBeatmap.cs new file mode 100644 index 0000000000..4f7786e880 --- /dev/null +++ b/osu.Game/Online/API/APIPlaylistBeatmap.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API +{ + public class APIPlaylistBeatmap : APIBeatmap + { + [JsonProperty("checksum")] + public string Checksum { get; set; } + + public override BeatmapInfo ToBeatmap(RulesetStore rulesets) + { + var b = base.ToBeatmap(rulesets); + b.MD5Hash = Checksum; + return b; + } + } +} diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index fcbd4d314a..2115326cc2 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -5,6 +5,7 @@ using System; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Framework.Logging; +using osu.Game.Users; namespace osu.Game.Online.API { @@ -12,24 +13,39 @@ namespace osu.Game.Online.API /// An API request with a well-defined response type. /// /// Type of the response (used for deserialisation). - public abstract class APIRequest : APIRequest + public abstract class APIRequest : APIRequest where T : class { - protected override WebRequest CreateWebRequest() => new JsonWebRequest(Uri); + protected override WebRequest CreateWebRequest() => new OsuJsonWebRequest(Uri); - public T Result => ((JsonWebRequest)WebRequest).ResponseObject; - - protected APIRequest() - { - base.Success += onSuccess; - } - - private void onSuccess() => Success?.Invoke(Result); + public T Result { get; private set; } /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// public new event APISuccessHandler Success; + + protected override void PostProcess() + { + base.PostProcess(); + Result = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; + } + + internal void TriggerSuccess(T result) + { + if (Result != null) + throw new InvalidOperationException("Attempted to trigger success more than once"); + + Result = result; + + TriggerSuccess(); + } + + internal override void TriggerSuccess() + { + base.TriggerSuccess(); + Success?.Invoke(Result); + } } /// @@ -39,13 +55,18 @@ namespace osu.Game.Online.API { protected abstract string Target { get; } - protected virtual WebRequest CreateWebRequest() => new WebRequest(Uri); + protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri); protected virtual string Uri => $@"{API.Endpoint}/api/v2/{Target}"; protected APIAccess API; protected WebRequest WebRequest; + /// + /// The currently logged in user. Note that this will only be populated during . + /// + protected User User { get; private set; } + /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). @@ -71,6 +92,7 @@ namespace osu.Game.Online.API } API = apiAccess; + User = apiAccess.LocalUser.Value; if (checkAndScheduleFailure()) return; @@ -83,7 +105,7 @@ namespace osu.Game.Online.API if (checkAndScheduleFailure()) return; - if (!WebRequest.Aborted) //could have been aborted by a Cancel() call + if (!WebRequest.Aborted) // could have been aborted by a Cancel() call { Logger.Log($@"Performing request {this}", LoggingTarget.Network); WebRequest.Perform(); @@ -92,14 +114,28 @@ namespace osu.Game.Online.API if (checkAndScheduleFailure()) return; + PostProcess(); + API.Schedule(delegate { if (cancelled) return; - Success?.Invoke(); + TriggerSuccess(); }); } + /// + /// Perform any post-processing actions after a successful request. + /// + protected virtual void PostProcess() + { + } + + internal virtual void TriggerSuccess() + { + Success?.Invoke(); + } + public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 7f23f9b5d5..7800241904 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.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.Threading; using System.Threading.Tasks; @@ -30,10 +31,15 @@ namespace osu.Game.Online.API private readonly List components = new List(); + /// + /// Provide handling logic for an arbitrary API request. + /// + public Action HandleRequest; + public APIState State { get => state; - private set + set { if (state == value) return; @@ -55,11 +61,16 @@ namespace osu.Game.Online.API public virtual void Queue(APIRequest request) { + HandleRequest?.Invoke(request); } - public void Perform(APIRequest request) { } + public void Perform(APIRequest request) => HandleRequest?.Invoke(request); - public Task PerformAsync(APIRequest request) => Task.CompletedTask; + public Task PerformAsync(APIRequest request) + { + HandleRequest?.Invoke(request); + return Task.CompletedTask; + } public void Register(IOnlineComponent component) { diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index baf494ebf9..bdc47aab8d 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Net.Http; using osu.Framework.Bindables; -using osu.Framework.IO.Network; namespace osu.Game.Online.API { @@ -166,7 +165,7 @@ namespace osu.Game.Online.API } } - private class AccessTokenRequest : JsonWebRequest + private class AccessTokenRequest : OsuJsonWebRequest { protected string GrantType; diff --git a/osu.Game/Online/API/OsuJsonWebRequest.cs b/osu.Game/Online/API/OsuJsonWebRequest.cs new file mode 100644 index 0000000000..4a45a8b261 --- /dev/null +++ b/osu.Game/Online/API/OsuJsonWebRequest.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + public class OsuJsonWebRequest : JsonWebRequest + { + public OsuJsonWebRequest(string uri) + : base(uri) + { + } + + public OsuJsonWebRequest() + { + } + + protected override string UserAgent => "osu!"; + } +} diff --git a/osu.Game/Online/API/OsuWebRequest.cs b/osu.Game/Online/API/OsuWebRequest.cs new file mode 100644 index 0000000000..1d27899473 --- /dev/null +++ b/osu.Game/Online/API/OsuWebRequest.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + public class OsuWebRequest : WebRequest + { + public OsuWebRequest(string uri) + : base(uri) + { + } + + public OsuWebRequest() + { + } + + protected override string UserAgent => "osu!"; + } +} diff --git a/osu.Game/Online/API/RegistrationRequest.cs b/osu.Game/Online/API/RegistrationRequest.cs index 349cd4de0c..f650e5c93b 100644 --- a/osu.Game/Online/API/RegistrationRequest.cs +++ b/osu.Game/Online/API/RegistrationRequest.cs @@ -2,11 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using Newtonsoft.Json; -using osu.Framework.IO.Network; namespace osu.Game.Online.API { - public class RegistrationRequest : WebRequest + public class RegistrationRequest : OsuWebRequest { internal string Username; internal string Email; diff --git a/osu.Game/Online/API/Requests/CreateChannelRequest.cs b/osu.Game/Online/API/Requests/CreateChannelRequest.cs new file mode 100644 index 0000000000..42cb201969 --- /dev/null +++ b/osu.Game/Online/API/Requests/CreateChannelRequest.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class CreateChannelRequest : APIRequest + { + private readonly Channel channel; + + public CreateChannelRequest(Channel channel) + { + this.channel = channel; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter("type", $"{ChannelType.PM}"); + req.AddParameter("target_id", $"{channel.Users.First().Id}"); + + return req; + } + + protected override string Target => @"chat/channels"; + } +} diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs new file mode 100644 index 0000000000..f21445ca32 --- /dev/null +++ b/osu.Game/Online/API/Requests/Cursor.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.API.Requests +{ + /// + /// A collection of parameters which should be passed to the search endpoint to fetch the next page. + /// + public class Cursor + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary Properties; + } +} diff --git a/osu.Game/Online/API/Requests/GetCommentsRequest.cs b/osu.Game/Online/API/Requests/GetCommentsRequest.cs index 7763501860..24dae4adf1 100644 --- a/osu.Game/Online/API/Requests/GetCommentsRequest.cs +++ b/osu.Game/Online/API/Requests/GetCommentsRequest.cs @@ -10,27 +10,32 @@ namespace osu.Game.Online.API.Requests { public class GetCommentsRequest : APIRequest { - private readonly long id; - private readonly int page; + private readonly long commentableId; private readonly CommentableType type; private readonly CommentsSortCriteria sort; + private readonly int page; + private readonly long? parentId; - public GetCommentsRequest(CommentableType type, long id, CommentsSortCriteria sort = CommentsSortCriteria.New, int page = 1) + public GetCommentsRequest(long commentableId, CommentableType type, CommentsSortCriteria sort = CommentsSortCriteria.New, int page = 1, long? parentId = null) { + this.commentableId = commentableId; this.type = type; this.sort = sort; - this.id = id; this.page = page; + this.parentId = parentId; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); + req.AddParameter("commentable_id", commentableId.ToString()); req.AddParameter("commentable_type", type.ToString().Underscore().ToLowerInvariant()); - req.AddParameter("commentable_id", id.ToString()); - req.AddParameter("sort", sort.ToString().ToLowerInvariant()); req.AddParameter("page", page.ToString()); + req.AddParameter("sort", sort.ToString().ToLowerInvariant()); + + if (parentId != null) + req.AddParameter("parent_id", parentId.ToString()); return req; } diff --git a/osu.Game/Online/API/Requests/GetRankingsRequest.cs b/osu.Game/Online/API/Requests/GetRankingsRequest.cs index 941691c4c1..ddc3298ca7 100644 --- a/osu.Game/Online/API/Requests/GetRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetRankingsRequest.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { - public abstract class GetRankingsRequest : APIRequest + public abstract class GetRankingsRequest : APIRequest where TModel : class { private readonly RulesetInfo ruleset; private readonly int page; diff --git a/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs b/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs new file mode 100644 index 0000000000..38f852870b --- /dev/null +++ b/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests +{ + public class GetRoomPlaylistScoresRequest : APIRequest + { + private readonly int roomId; + private readonly int playlistItemId; + + public GetRoomPlaylistScoresRequest(int roomId, int playlistItemId) + { + this.roomId = roomId; + this.playlistItemId = playlistItemId; + } + + protected override string Target => $@"rooms/{roomId}/playlist/{playlistItemId}/scores"; + } + + public class RoomPlaylistScores + { + [JsonProperty("scores")] + public List Scores { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/GetRoomRequest.cs b/osu.Game/Online/API/Requests/GetRoomRequest.cs new file mode 100644 index 0000000000..531e1857de --- /dev/null +++ b/osu.Game/Online/API/Requests/GetRoomRequest.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.Multiplayer; + +namespace osu.Game.Online.API.Requests +{ + public class GetRoomRequest : APIRequest + { + private readonly int roomId; + + public GetRoomRequest(int roomId) + { + this.roomId = roomId; + } + + protected override string Target => $"rooms/{roomId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetRoomsRequest.cs b/osu.Game/Online/API/Requests/GetRoomsRequest.cs index 8f1497ef33..c47ed20909 100644 --- a/osu.Game/Online/API/Requests/GetRoomsRequest.cs +++ b/osu.Game/Online/API/Requests/GetRoomsRequest.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using Humanizer; +using osu.Framework.IO.Network; using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Lounge.Components; @@ -9,39 +11,28 @@ namespace osu.Game.Online.API.Requests { public class GetRoomsRequest : APIRequest> { - private readonly PrimaryFilter primaryFilter; + private readonly RoomStatusFilter statusFilter; + private readonly RoomCategoryFilter categoryFilter; - public GetRoomsRequest(PrimaryFilter primaryFilter) + public GetRoomsRequest(RoomStatusFilter statusFilter, RoomCategoryFilter categoryFilter) { - this.primaryFilter = primaryFilter; + this.statusFilter = statusFilter; + this.categoryFilter = categoryFilter; } - protected override string Target + protected override WebRequest CreateWebRequest() { - get - { - string target = "rooms"; + var req = base.CreateWebRequest(); - switch (primaryFilter) - { - case PrimaryFilter.Open: - break; + if (statusFilter != RoomStatusFilter.Open) + req.AddParameter("mode", statusFilter.ToString().Underscore().ToLowerInvariant()); - case PrimaryFilter.Owned: - target += "/owned"; - break; + if (categoryFilter != RoomCategoryFilter.Any) + req.AddParameter("category", categoryFilter.ToString().Underscore().ToLowerInvariant()); - case PrimaryFilter.Participated: - target += "/participated"; - break; - - case PrimaryFilter.RecentlyEnded: - target += "/ended"; - break; - } - - return target; - } + return req; } + + protected override string Target => "rooms"; } } diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs new file mode 100644 index 0000000000..25e6b3f1af --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.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.IO.Network; +using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightRankingsRequest : GetRankingsRequest + { + private readonly int spotlight; + private readonly RankingsSortCriteria sort; + + public GetSpotlightRankingsRequest(RulesetInfo ruleset, int spotlight, RankingsSortCriteria sort) + : base(ruleset, 1) + { + this.spotlight = spotlight; + this.sort = sort; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("spotlight", spotlight.ToString()); + req.AddParameter("filter", sort.ToString().ToLower()); + + return req; + } + + protected override string TargetPostfix() => "charts"; + } +} diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs new file mode 100644 index 0000000000..2259314a9f --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.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 Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightRankingsResponse + { + [JsonProperty("ranking")] + public List Users; + + [JsonProperty("spotlight")] + public APISpotlight Spotlight; + + [JsonProperty("beatmapsets")] + public List BeatmapSets; + } +} diff --git a/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs new file mode 100644 index 0000000000..6fafb3933c --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightsRequest : APIRequest + { + protected override string Target => "spotlights"; + } + + public class SpotlightsCollection + { + [JsonProperty("spotlights")] + public List Spotlights; + } +} diff --git a/osu.Game/Online/API/Requests/JoinChannelRequest.cs b/osu.Game/Online/API/Requests/JoinChannelRequest.cs index f6ed5f22c9..33eab7e355 100644 --- a/osu.Game/Online/API/Requests/JoinChannelRequest.cs +++ b/osu.Game/Online/API/Requests/JoinChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class JoinChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public JoinChannelRequest(Channel channel, User user) + public JoinChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/JoinRoomRequest.cs b/osu.Game/Online/API/Requests/JoinRoomRequest.cs index 36b275236c..b0808afa45 100644 --- a/osu.Game/Online/API/Requests/JoinRoomRequest.cs +++ b/osu.Game/Online/API/Requests/JoinRoomRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Multiplayer; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class JoinRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public JoinRoomRequest(Room room, User user) + public JoinRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs index f2ae3926bd..7dfc9a0aed 100644 --- a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs +++ b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class LeaveChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public LeaveChannelRequest(Channel channel, User user) + public LeaveChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs new file mode 100644 index 0000000000..95a5d0acbd --- /dev/null +++ b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class MarkChannelAsReadRequest : APIRequest + { + private readonly Channel channel; + private readonly Message message; + + public MarkChannelAsReadRequest(Channel channel, Message message) + { + this.channel = channel; + this.message = message; + } + + protected override string Target => $"chat/channels/{channel.Id}/mark-as-read/{message.Id}"; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + return req; + } + } +} diff --git a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs index 52e12f04ee..bddc34a0dc 100644 --- a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs +++ b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs @@ -6,7 +6,7 @@ using osu.Framework.IO.Network; namespace osu.Game.Online.API.Requests { - public abstract class PaginatedAPIRequest : APIRequest + public abstract class PaginatedAPIRequest : APIRequest where T : class { private readonly int page; private readonly int itemsPerPage; diff --git a/osu.Game/Online/API/Requests/PartRoomRequest.cs b/osu.Game/Online/API/Requests/PartRoomRequest.cs index e1550cb2e0..c988cd5c9e 100644 --- a/osu.Game/Online/API/Requests/PartRoomRequest.cs +++ b/osu.Game/Online/API/Requests/PartRoomRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Multiplayer; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class PartRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public PartRoomRequest(Room room, User user) + public PartRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs index e38e73dd01..d52e999722 100644 --- a/osu.Game/Online/API/Requests/ResponseWithCursor.cs +++ b/osu.Game/Online/API/Requests/ResponseWithCursor.cs @@ -7,10 +7,7 @@ namespace osu.Game.Online.API.Requests { public abstract class ResponseWithCursor { - /// - /// A collection of parameters which should be passed to the search endpoint to fetch the next page. - /// [JsonProperty("cursor")] - public dynamic CursorJson; + public Cursor Cursor; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index f4d67a56aa..ae65ac09b2 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -61,7 +61,10 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"failtimes")] private BeatmapMetrics metrics { get; set; } - public BeatmapInfo ToBeatmap(RulesetStore rulesets) + [JsonProperty(@"max_combo")] + private int? maxCombo { get; set; } + + public virtual BeatmapInfo ToBeatmap(RulesetStore rulesets) { var set = BeatmapSet?.ToBeatmapSet(rulesets); @@ -76,6 +79,7 @@ namespace osu.Game.Online.API.Requests.Responses Status = Status, BeatmapSet = set, Metrics = metrics, + MaxCombo = maxCombo, BaseDifficulty = new BeatmapDifficulty { DrainRate = drainRate, diff --git a/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs b/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs new file mode 100644 index 0000000000..fc3b2a8e31 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIChatChannel.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.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIChatChannel + { + [JsonProperty(@"channel_id")] + public int? ChannelID { get; set; } + + [JsonProperty(@"recent_messages")] + public List RecentMessages { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs b/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs index 318fcb00de..75be9171b0 100644 --- a/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs +++ b/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.API.Requests.Responses public class APILegacyUserTopScoreInfo { [JsonProperty(@"position")] - public int Position; + public int? Position; [JsonProperty(@"score")] public APILegacyScoreInfo Score; diff --git a/osu.Game/Online/API/Requests/Responses/APIMod.cs b/osu.Game/Online/API/Requests/Responses/APIMod.cs deleted file mode 100644 index b9da4f49ee..0000000000 --- a/osu.Game/Online/API/Requests/Responses/APIMod.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; - -namespace osu.Game.Online.API.Requests.Responses -{ - public class APIMod : IMod - { - public string Acronym { get; set; } - - public bool Equals(IMod other) => Acronym == other?.Acronym; - } -} diff --git a/osu.Game/Online/API/Requests/Responses/APINewsPost.cs b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs new file mode 100644 index 0000000000..ced08f0bf2 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINewsPost.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 Newtonsoft.Json; +using System; +using System.Net; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APINewsPost + { + [JsonProperty("id")] + public long Id { get; set; } + + private string author; + + [JsonProperty("author")] + public string Author + { + get => author; + set => author = WebUtility.HtmlDecode(value); + } + + [JsonProperty("edit_url")] + public string EditUrl { get; set; } + + [JsonProperty("first_image")] + public string FirstImage { get; set; } + + [JsonProperty("published_at")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } + + private string title; + + [JsonProperty("title")] + public string Title + { + get => title; + set => title = WebUtility.HtmlDecode(value); + } + + private string preview; + + [JsonProperty("preview")] + public string Preview + { + get => preview; + set => preview = WebUtility.HtmlDecode(value); + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APISpotlight.cs b/osu.Game/Online/API/Requests/Responses/APISpotlight.cs new file mode 100644 index 0000000000..4f63ebe3df --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APISpotlight.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APISpotlight + { + [JsonProperty("id")] + public int Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("type")] + public string Type; + + [JsonProperty("mode_specific")] + public bool ModeSpecific; + + [JsonProperty(@"start_date")] + public DateTimeOffset StartDate; + + [JsonProperty(@"end_date")] + public DateTimeOffset EndDate; + + [JsonProperty(@"participant_count")] + public int? Participants; + + public override string ToString() => Name; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs index 5510e9afff..05a24cec0e 100644 --- a/osu.Game/Online/API/Requests/Responses/Comment.cs +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -4,10 +4,6 @@ using Newtonsoft.Json; using osu.Game.Users; using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text.RegularExpressions; namespace osu.Game.Online.API.Requests.Responses { @@ -19,8 +15,6 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"parent_id")] public long? ParentId { get; set; } - public readonly List ChildComments = new List(); - public Comment ParentComment { get; set; } [JsonProperty(@"user_id")] @@ -70,12 +64,8 @@ namespace osu.Game.Online.API.Requests.Responses public bool IsDeleted => DeletedAt.HasValue; - public bool HasMessage => !string.IsNullOrEmpty(MessageHtml); + public bool HasMessage => !string.IsNullOrEmpty(Message); public bool IsVoted { get; set; } - - public string GetMessage => HasMessage ? WebUtility.HtmlDecode(Regex.Replace(MessageHtml, @"<(.|\n)*?>", string.Empty)) : string.Empty; - - public int DeletedChildrenCount => ChildComments.Count(c => c.IsDeleted); } } diff --git a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs index 8db5d8d6ad..d76ede67cd 100644 --- a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs +++ b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs @@ -9,31 +9,8 @@ namespace osu.Game.Online.API.Requests.Responses { public class CommentBundle { - private List comments; - [JsonProperty(@"comments")] - public List Comments - { - get => comments; - set - { - comments = value; - comments.ForEach(child => - { - if (child.ParentId != null) - { - comments.ForEach(parent => - { - if (parent.Id == child.ParentId) - { - parent.ChildComments.Add(child); - child.ParentComment = parent; - } - }); - } - }); - } - } + public List Comments { get; set; } [JsonProperty(@"has_more")] public bool HasMore { get; set; } @@ -47,17 +24,19 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"included_comments")] public List IncludedComments { get; set; } + private List userVotes; + [JsonProperty(@"user_votes")] - private List userVotes + public List UserVotes { - set => value.ForEach(v => + get => userVotes; + set { - Comments.ForEach(c => - { - if (v == c.Id) - c.IsVoted = true; - }); - }); + userVotes = value; + + Comments.ForEach(c => c.IsVoted = value.Contains(c.Id)); + IncludedComments.ForEach(c => c.IsVoted = value.Contains(c.Id)); + } } private List users; @@ -80,6 +59,15 @@ namespace osu.Game.Online.API.Requests.Responses if (c.EditedById == u.Id) c.EditedUser = u; }); + + IncludedComments.ForEach(c => + { + if (c.UserId == u.Id) + c.User = u; + + if (c.EditedById == u.Id) + c.EditedUser = u; + }); }); } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 5652b8d2bd..dde45b5aeb 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -1,51 +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.ComponentModel; +using osu.Framework.IO.Network; +using osu.Game.Extensions; using osu.Game.Overlays; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsRequest : APIRequest { + public SearchCategory SearchCategory { get; } + + public SortCriteria SortCriteria { get; } + + public SortDirection SortDirection { get; } + + public SearchGenre Genre { get; } + + public SearchLanguage Language { get; } + private readonly string query; private readonly RulesetInfo ruleset; - private readonly BeatmapSearchCategory searchCategory; - private readonly DirectSortCriteria sortCriteria; - private readonly SortDirection direction; - private string directionString => direction == SortDirection.Descending ? @"desc" : @"asc"; + private readonly Cursor cursor; - public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, BeatmapSearchCategory searchCategory = BeatmapSearchCategory.Any, DirectSortCriteria sortCriteria = DirectSortCriteria.Ranked, SortDirection direction = SortDirection.Descending) + private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc"; + + public SearchBeatmapSetsRequest( + string query, + RulesetInfo ruleset, + Cursor cursor = null, + SearchCategory searchCategory = SearchCategory.Any, + SortCriteria sortCriteria = SortCriteria.Ranked, + SortDirection sortDirection = SortDirection.Descending, + SearchGenre genre = SearchGenre.Any, + SearchLanguage language = SearchLanguage.Any) { this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); this.ruleset = ruleset; - this.searchCategory = searchCategory; - this.sortCriteria = sortCriteria; - this.direction = direction; + this.cursor = cursor; + + SearchCategory = searchCategory; + SortCriteria = sortCriteria; + SortDirection = sortDirection; + Genre = genre; + Language = language; } - // ReSharper disable once ImpureMethodCallOnReadonlyValueField - protected override string Target => $@"beatmapsets/search?q={query}&m={ruleset.ID ?? 0}&s={searchCategory.ToString().ToLowerInvariant()}&sort={sortCriteria.ToString().ToLowerInvariant()}_{directionString}"; - } + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.AddParameter("q", query); - public enum BeatmapSearchCategory - { - Any, + if (ruleset.ID.HasValue) + req.AddParameter("m", ruleset.ID.Value.ToString()); - [Description("Has Leaderboard")] - Leaderboard, - Ranked, - Qualified, - Loved, - Favourites, + req.AddParameter("s", SearchCategory.ToString().ToLowerInvariant()); - [Description("Pending & WIP")] - Pending, - Graveyard, + if (Genre != SearchGenre.Any) + req.AddParameter("g", ((int)Genre).ToString()); - [Description("My Maps")] - Mine, + if (Language != SearchLanguage.Any) + req.AddParameter("l", ((int)Language).ToString()); + + req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}"); + + req.AddCursor(cursor); + + return req; + } + + protected override string Target => @"beatmapsets/search"; } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs index 28863cb0e0..3c4fb11ed1 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs @@ -2,12 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsResponse : ResponseWithCursor { + [JsonProperty("beatmapsets")] public IEnumerable BeatmapSets; + + [JsonProperty("total")] + public int Total; } } diff --git a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs b/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs index 50b62cd6ed..8eb2952159 100644 --- a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs @@ -8,7 +8,7 @@ using osu.Game.Scoring; namespace osu.Game.Online.API.Requests { - public class SubmitRoomScoreRequest : APIRequest + public class SubmitRoomScoreRequest : APIRequest { private readonly int scoreId; private readonly int roomId; diff --git a/osu.Game/Online/API/RoomScore.cs b/osu.Game/Online/API/RoomScore.cs new file mode 100644 index 0000000000..3c7f8c9833 --- /dev/null +++ b/osu.Game/Online/API/RoomScore.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 Newtonsoft.Json.Converters; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.API +{ + public class RoomScore + { + [JsonProperty("id")] + public int ID { get; set; } + + [JsonProperty("user")] + public User User { get; set; } + + [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("max_combo")] + public int MaxCombo { get; set; } + + [JsonProperty("mods")] + public APIMod[] Mods { get; set; } + + [JsonProperty("statistics")] + public Dictionary Statistics = new Dictionary(); + + [JsonProperty("passed")] + public bool Passed { get; set; } + + [JsonProperty("ended_at")] + public DateTimeOffset EndedAt { get; set; } + + public ScoreInfo CreateScoreInfo(PlaylistItem playlistItem) + { + var rulesetInstance = playlistItem.Ruleset.Value.CreateInstance(); + + var scoreInfo = new ScoreInfo + { + OnlineScoreID = ID, + TotalScore = TotalScore, + MaxCombo = MaxCombo, + Beatmap = playlistItem.Beatmap.Value, + BeatmapInfoID = playlistItem.BeatmapID, + Ruleset = playlistItem.Ruleset.Value, + RulesetID = playlistItem.RulesetID, + Statistics = Statistics, + User = User, + Accuracy = Accuracy, + Date = EndedAt, + Hash = string.Empty, // todo: temporary? + Rank = Rank, + Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty() + }; + + return scoreInfo; + } + } +} diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 451174a73c..8c1e1ad128 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -36,6 +36,11 @@ namespace osu.Game.Online.Chat /// public readonly SortedList Messages = new SortedList(Comparer.Default); + /// + /// Contains all the messages that weren't read by the user. + /// + public IEnumerable UnreadMessages => Messages.Where(m => LastReadId < m.Id); + /// /// Contains all the messages that are still pending for submission to the server. /// @@ -56,7 +61,7 @@ namespace osu.Game.Online.Chat /// public event Action MessageRemoved; - public bool ReadOnly => false; //todo not yet used. + public bool ReadOnly => false; // todo: not yet used. public override string ToString() => Name; @@ -75,8 +80,12 @@ namespace osu.Game.Online.Chat [JsonProperty(@"last_message_id")] public long? LastMessageId; + [JsonProperty(@"last_read_id")] + public long? LastReadId; + /// - /// Signalles if the current user joined this channel or not. Defaults to false. + /// Signals if the current user joined this channel or not. Defaults to false. + /// Note that this does not guarantee a join has completed. Check Id > 0 for confirmation. /// public Bindable Joined = new Bindable(); diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 1d8c5609d9..f7ed57f207 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.Chat /// /// Manages everything channel related /// - public class ChannelManager : PollingComponent + public class ChannelManager : PollingComponent, IChannelPostTarget { /// /// The channels the player joins on startup @@ -48,7 +48,8 @@ namespace osu.Game.Online.Chat /// public IBindableList AvailableChannels => availableChannels; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } public readonly BindableBool HighPollRate = new BindableBool(); @@ -85,7 +86,7 @@ namespace osu.Game.Online.Chat return; CurrentChannel.Value = JoinedChannels.FirstOrDefault(c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Users.Any(u => u.Id == user.Id)) - ?? new Channel(user); + ?? JoinChannel(new Channel(user)); } private void currentChannelChanged(ValueChangedEvent e) @@ -107,8 +108,7 @@ namespace osu.Game.Online.Chat /// An optional target channel. If null, will be used. public void PostMessage(string text, bool isAction = false, Channel target = null) { - if (target == null) - target = CurrentChannel.Value; + target ??= CurrentChannel.Value; if (target == null) return; @@ -139,7 +139,7 @@ namespace osu.Game.Online.Chat target.AddLocalEcho(message); // if this is a PM and the first message, we need to do a special request to create the PM channel - if (target.Type == ChannelType.PM && !target.Joined.Value) + if (target.Type == ChannelType.PM && target.Id == 0) { var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(target.Users.First(), message); @@ -191,8 +191,7 @@ namespace osu.Game.Online.Chat /// An optional target channel. If null, will be used. public void PostCommand(string text, Channel target = null) { - if (target == null) - target = CurrentChannel.Value; + target ??= CurrentChannel.Value; if (target == null) return; @@ -203,6 +202,10 @@ namespace osu.Game.Online.Chat switch (command) { + case "np": + AddInternal(new NowPlayingCommand()); + break; + case "me": if (string.IsNullOrWhiteSpace(content)) { @@ -229,11 +232,10 @@ namespace osu.Game.Online.Chat } JoinChannel(channel); - CurrentChannel.Value = channel; break; case "help": - target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel]")); + target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /np")); break; default: @@ -264,7 +266,7 @@ namespace osu.Game.Online.Chat // join any channels classified as "defaults" if (joinDefaults && defaultChannels.Any(c => c.Equals(channel.Name, StringComparison.OrdinalIgnoreCase))) - JoinChannel(ch); + joinChannel(ch); } }; req.Failure += error => @@ -285,7 +287,7 @@ namespace osu.Game.Online.Chat /// The channel private void fetchInitalMessages(Channel channel) { - if (channel.Id <= 0) return; + if (channel.Id <= 0 || channel.MessagesLoaded) return; var fetchInitialMsgReq = new GetMessagesRequest(channel); fetchInitialMsgReq.Success += messages => @@ -340,9 +342,10 @@ namespace osu.Game.Online.Chat /// Joins a channel if it has not already been joined. /// /// The channel to join. - /// Whether the channel has already been joined server-side. Will skip a join request. /// The joined channel. Note that this may not match the parameter channel as it is a backed object. - public Channel JoinChannel(Channel channel, bool alreadyJoined = false) + public Channel JoinChannel(Channel channel) => joinChannel(channel, true); + + private Channel joinChannel(Channel channel, bool fetchInitialMessages = false) { if (channel == null) return null; @@ -351,31 +354,48 @@ namespace osu.Game.Online.Chat // ensure we are joined to the channel if (!channel.Joined.Value) { - if (alreadyJoined) - channel.Joined.Value = true; - else + channel.Joined.Value = true; + + switch (channel.Type) { - switch (channel.Type) - { - case ChannelType.Public: - var req = new JoinChannelRequest(channel, api.LocalUser.Value); - req.Success += () => JoinChannel(channel, true); - req.Failure += ex => LeaveChannel(channel); - api.Queue(req); - return channel; - } + case ChannelType.Multiplayer: + // join is implicit. happens when you join a multiplayer game. + // this will probably change in the future. + joinChannel(channel, fetchInitialMessages); + return channel; + + case ChannelType.PM: + var createRequest = new CreateChannelRequest(channel); + createRequest.Success += resChannel => + { + if (resChannel.ChannelID.HasValue) + { + channel.Id = resChannel.ChannelID.Value; + + handleChannelMessages(resChannel.RecentMessages); + channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. + } + }; + + api.Queue(createRequest); + break; + + default: + var req = new JoinChannelRequest(channel); + req.Success += () => joinChannel(channel, fetchInitialMessages); + req.Failure += ex => LeaveChannel(channel); + api.Queue(req); + return channel; } } - - if (CurrentChannel.Value == null) - CurrentChannel.Value = channel; - - if (!channel.MessagesLoaded) + else { - // let's fetch a small number of messages to bring us up-to-date with the backlog. - fetchInitalMessages(channel); + if (fetchInitialMessages) + fetchInitalMessages(channel); } + CurrentChannel.Value ??= channel; + return channel; } @@ -390,7 +410,7 @@ namespace osu.Game.Online.Chat if (channel.Joined.Value) { - api.Queue(new LeaveChannelRequest(channel, api.LocalUser.Value)); + api.Queue(new LeaveChannelRequest(channel)); channel.Joined.Value = false; } } @@ -415,7 +435,8 @@ namespace osu.Game.Online.Chat foreach (var channel in updates.Presence) { // we received this from the server so should mark the channel already joined. - JoinChannel(channel, true); + channel.Joined.Value = true; + joinChannel(channel); } //todo: handle left channels @@ -445,10 +466,26 @@ namespace osu.Game.Online.Chat return tcs.Task; } - [BackgroundDependencyLoader] - private void load(IAPIProvider api) + /// + /// Marks the as read + /// + /// The channel that will be marked as read + public void MarkChannelAsRead(Channel channel) { - this.api = api; + if (channel.LastMessageId == channel.LastReadId) + return; + + var message = channel.Messages.LastOrDefault(); + + if (message == null) + return; + + var req = new MarkChannelAsReadRequest(channel, message); + + req.Success += () => channel.LastReadId = message.Id; + req.Failure += e => Logger.Error(e, $"Failed to mark channel {channel} up to '{message}' as read"); + + api.Queue(req); } } diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index 7d2b661164..151efc4645 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -12,5 +12,6 @@ namespace osu.Game.Online.Chat Temporary, PM, Group, + System, } } diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 495f1ac0b0..8407e2ca6a 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -13,15 +13,17 @@ namespace osu.Game.Online.Chat { public class ExternalLinkOpener : Component { - private GameHost host; - private DialogOverlay dialogOverlay; + [Resolved] + private GameHost host { get; set; } + + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + private Bindable externalLinkWarning; [BackgroundDependencyLoader(true)] - private void load(GameHost host, DialogOverlay dialogOverlay, OsuConfigManager config) + private void load(OsuConfigManager config) { - this.host = host; - this.dialogOverlay = dialogOverlay; externalLinkWarning = config.GetBindable(OsuSetting.ExternalLinkWarning); } diff --git a/osu.Game/Online/Chat/IChannelPostTarget.cs b/osu.Game/Online/Chat/IChannelPostTarget.cs new file mode 100644 index 0000000000..5697e918f0 --- /dev/null +++ b/osu.Game/Online/Chat/IChannelPostTarget.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; + +namespace osu.Game.Online.Chat +{ + [Cached(typeof(IChannelPostTarget))] + public interface IChannelPostTarget + { + /// + /// Posts a message to the currently opened channel. + /// + /// The message text that is going to be posted + /// Is true if the message is an action, e.g.: user is currently eating + /// An optional target channel. If null, will be used. + void PostMessage(string text, bool isAction = false, Channel target = null); + } +} diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 717de18c14..6af2561c89 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -78,13 +78,13 @@ namespace osu.Game.Online.Chat { result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText); - //since we just changed the line display text, offset any already processed links. + // since we just changed the line display text, offset any already processed links. result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0); var details = GetLinkDetails(linkText); result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument)); - //adjust the offset for processing the current matches group. + // adjust the offset for processing the current matches group. captureOffset += m.Length - displayText.Length; } } diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs new file mode 100644 index 0000000000..c0b54812b6 --- /dev/null +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Users; + +namespace osu.Game.Online.Chat +{ + public class NowPlayingCommand : Component + { + [Resolved] + private IChannelPostTarget channelManager { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable currentBeatmap { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + string verb; + BeatmapInfo beatmap; + + switch (api.Activity.Value) + { + case UserActivity.SoloGame solo: + verb = "playing"; + beatmap = solo.Beatmap; + break; + + case UserActivity.Editing edit: + verb = "editing"; + beatmap = edit.Beatmap; + break; + + default: + verb = "listening to"; + beatmap = currentBeatmap.Value.BeatmapInfo; + break; + } + + var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[https://osu.ppy.sh/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString(); + + channelManager.PostMessage($"is {verb} {beatmapString}", true); + Expire(); + } + } +} diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 21d0bcc4bf..f8810c778f 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -26,7 +26,7 @@ namespace osu.Game.Online.Chat protected ChannelManager ChannelManager; - private DrawableChannel drawableChannel; + private StandAloneDrawableChannel drawableChannel; private readonly bool postingTextbox; @@ -73,10 +73,12 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader(true)] private void load(ChannelManager manager) { - if (ChannelManager == null) - ChannelManager = manager; + ChannelManager ??= manager; } + protected virtual StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => + new StandAloneDrawableChannel(channel); + private void postMessage(TextBox sender, bool newtext) { var text = textbox.Text.Trim(); @@ -92,18 +94,6 @@ namespace osu.Game.Online.Chat textbox.Text = string.Empty; } - public void Contract() - { - this.FadeIn(300); - this.MoveToY(0, 500, Easing.OutQuint); - } - - public void Expand() - { - this.FadeOut(200); - this.MoveToY(100, 500, Easing.In); - } - protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); private void channelChanged(ValueChangedEvent e) @@ -112,14 +102,14 @@ namespace osu.Game.Online.Chat if (e.NewValue == null) return; - AddInternal(drawableChannel = new StandAloneDrawableChannel(e.NewValue) - { - CreateChatLineAction = CreateMessage, - Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 } - }); + drawableChannel = CreateDrawableChannel(e.NewValue); + drawableChannel.CreateChatLineAction = CreateMessage; + drawableChannel.Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 }; + + AddInternal(drawableChannel); } - protected class StandAloneDrawableChannel : DrawableChannel + public class StandAloneDrawableChannel : DrawableChannel { public Func CreateChatLineAction; diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 9a0e112727..5d9cf612bb 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -19,7 +19,8 @@ namespace osu.Game.Online { protected readonly Bindable Model = new Bindable(); - private TModelManager manager; + [Resolved(CanBeNull = true)] + private TModelManager manager { get; set; } /// /// Holds the current download state of the , whether is has already been downloaded, is in progress, or is not downloaded. @@ -33,11 +34,14 @@ namespace osu.Game.Online Model.Value = model; } - [BackgroundDependencyLoader(true)] - private void load(TModelManager manager) - { - this.manager = manager; + private IBindable> managedUpdated; + private IBindable> managerRemoved; + private IBindable>> managerDownloadBegan; + private IBindable>> managerDownloadFailed; + [BackgroundDependencyLoader(true)] + private void load() + { Model.BindValueChanged(modelInfo => { if (modelInfo.NewValue == null) @@ -48,22 +52,38 @@ namespace osu.Game.Online attachDownload(manager.GetExistingDownload(modelInfo.NewValue)); }, true); - manager.DownloadBegan += downloadBegan; - manager.DownloadFailed += downloadFailed; - manager.ItemAdded += itemAdded; - manager.ItemRemoved += itemRemoved; + managerDownloadBegan = manager.DownloadBegan.GetBoundCopy(); + managerDownloadBegan.BindValueChanged(downloadBegan); + managerDownloadFailed = manager.DownloadFailed.GetBoundCopy(); + managerDownloadFailed.BindValueChanged(downloadFailed); + managedUpdated = manager.ItemUpdated.GetBoundCopy(); + managedUpdated.BindValueChanged(itemUpdated); + managerRemoved = manager.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(itemRemoved); } - private void downloadBegan(ArchiveDownloadRequest request) + private void downloadBegan(ValueChangedEvent>> weakRequest) { - if (request.Model.Equals(Model.Value)) - attachDownload(request); + if (weakRequest.NewValue.TryGetTarget(out var request)) + { + Schedule(() => + { + if (request.Model.Equals(Model.Value)) + attachDownload(request); + }); + } } - private void downloadFailed(ArchiveDownloadRequest request) + private void downloadFailed(ValueChangedEvent>> weakRequest) { - if (request.Model.Equals(Model.Value)) - attachDownload(null); + if (weakRequest.NewValue.TryGetTarget(out var request)) + { + Schedule(() => + { + if (request.Model.Equals(Model.Value)) + attachDownload(null); + }); + } } private ArchiveDownloadRequest attachedRequest; @@ -108,9 +128,17 @@ namespace osu.Game.Online private void onRequestFailure(Exception e) => Schedule(() => attachDownload(null)); - private void itemAdded(TModel s) => setDownloadStateFromManager(s, DownloadState.LocallyAvailable); + private void itemUpdated(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + setDownloadStateFromManager(item, DownloadState.LocallyAvailable); + } - private void itemRemoved(TModel s) => setDownloadStateFromManager(s, DownloadState.NotDownloaded); + private void itemRemoved(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + setDownloadStateFromManager(item, DownloadState.NotDownloaded); + } private void setDownloadStateFromManager(TModel s, DownloadState state) => Schedule(() => { @@ -126,14 +154,6 @@ namespace osu.Game.Online { base.Dispose(isDisposing); - if (manager != null) - { - manager.DownloadBegan -= downloadBegan; - manager.DownloadFailed -= downloadFailed; - manager.ItemAdded -= itemAdded; - manager.ItemRemoved -= itemRemoved; - } - State.UnbindAll(); attachDownload(null); diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index 50cb58c6ab..4d41230799 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -28,7 +28,7 @@ namespace osu.Game.Online.Leaderboards FillMode = FillMode.Fit; FillAspectRatio = 2; - var rankColour = getRankColour(); + var rankColour = OsuColour.ForRank(rank); InternalChild = new DrawSizePreservingFillContainer { TargetDrawSize = new Vector2(64, 32), @@ -58,8 +58,8 @@ namespace osu.Game.Online.Leaderboards Spacing = new Vector2(-3, 0), Padding = new MarginPadding { Top = 5 }, Colour = getRankNameColour(), - Font = OsuFont.GetFont(Typeface.Venera, 25), - Text = getRankName(), + Font = OsuFont.Numeric.With(size: 25), + Text = GetRankName(rank), ShadowColour = Color4.Black.Opacity(0.3f), ShadowOffset = new Vector2(0, 0.08f), Shadow = true, @@ -69,36 +69,7 @@ namespace osu.Game.Online.Leaderboards }; } - private string getRankName() => rank.GetDescription().TrimEnd('+'); - - /// - /// Retrieves the grade background colour. - /// - private Color4 getRankColour() - { - switch (rank) - { - case ScoreRank.XH: - case ScoreRank.X: - return OsuColour.FromHex(@"ce1c9d"); - - case ScoreRank.SH: - case ScoreRank.S: - return OsuColour.FromHex(@"00a8b5"); - - case ScoreRank.A: - return OsuColour.FromHex(@"7cce14"); - - case ScoreRank.B: - return OsuColour.FromHex(@"e3b130"); - - case ScoreRank.C: - return OsuColour.FromHex(@"f18252"); - - default: - return OsuColour.FromHex(@"e95353"); - } - } + public static string GetRankName(ScoreRank rank) => rank.GetDescription().TrimEnd('+'); /// /// Retrieves the grade text colour. @@ -109,23 +80,23 @@ namespace osu.Game.Online.Leaderboards { case ScoreRank.XH: case ScoreRank.SH: - return ColourInfo.GradientVertical(Color4.White, OsuColour.FromHex("afdff0")); + return ColourInfo.GradientVertical(Color4.White, Color4Extensions.FromHex("afdff0")); case ScoreRank.X: case ScoreRank.S: - return ColourInfo.GradientVertical(OsuColour.FromHex(@"ffe7a8"), OsuColour.FromHex(@"ffb800")); + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ffe7a8"), Color4Extensions.FromHex(@"ffb800")); case ScoreRank.A: - return OsuColour.FromHex(@"275227"); + return Color4Extensions.FromHex(@"275227"); case ScoreRank.B: - return OsuColour.FromHex(@"553a2b"); + return Color4Extensions.FromHex(@"553a2b"); case ScoreRank.C: - return OsuColour.FromHex(@"473625"); + return Color4Extensions.FromHex(@"473625"); default: - return OsuColour.FromHex(@"512525"); + return Color4Extensions.FromHex(@"512525"); } } } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 9c48ebd09b..800029ceb9 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -12,8 +12,10 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.Placeholders; using osuTK; using osuTK.Graphics; @@ -28,7 +30,7 @@ namespace osu.Game.Online.Leaderboards private FillFlowContainer scrollFlow; - private readonly LoadingAnimation loading; + private readonly LoadingSpinner loading; private ScheduledDelegate showScoresDelegate; private CancellationTokenSource showScoresCancellationSource; @@ -150,7 +152,7 @@ namespace osu.Game.Online.Leaderboards break; case PlaceholderState.NotLoggedIn: - replacePlaceholder(new MessagePlaceholder(@"Please sign in to view online leaderboards!")); + replacePlaceholder(new LoginPlaceholder(@"Please sign in to view online leaderboards!")); break; case PlaceholderState.NotSupporter: @@ -168,35 +170,40 @@ namespace osu.Game.Online.Leaderboards { InternalChildren = new Drawable[] { - new GridContainer + new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Masking = true, + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - scrollContainer = new OsuScrollContainer + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, + scrollContainer = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + } + }, + new Drawable[] + { + content = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, } }, - new Drawable[] - { - content = new Container - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - }, - } }, }, - loading = new LoadingAnimation(), + loading = new LoadingSpinner(), placeholderContainer = new Container { RelativeSizeAxes = Axes.Both @@ -211,14 +218,14 @@ namespace osu.Game.Online.Leaderboards Scores = null; } - private IAPIProvider api; + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } private ScheduledDelegate pendingUpdateScores; - [BackgroundDependencyLoader(true)] - private void load(IAPIProvider api) + [BackgroundDependencyLoader] + private void load() { - this.api = api; api?.Register(this); } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 6ac5219282..b60d71cfe7 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -11,11 +11,15 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; 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.Rulesets.UI; +using osu.Game.Screens.Select; using osu.Game.Scoring; using osu.Game.Users.Drawables; using osuTK; @@ -25,7 +29,7 @@ using osu.Game.Online.API; namespace osu.Game.Online.Leaderboards { - public class LeaderboardScore : OsuClickableContainer + public class LeaderboardScore : OsuClickableContainer, IHasContextMenu { public const float HEIGHT = 60; @@ -37,7 +41,7 @@ namespace osu.Game.Online.Leaderboards protected Container RankContainer { get; private set; } private readonly ScoreInfo score; - private readonly int rank; + private readonly int? rank; private readonly bool allowHighlight; private Box background; @@ -51,7 +55,13 @@ namespace osu.Game.Online.Leaderboards private List statisticsLabels; - public LeaderboardScore(ScoreInfo score, int rank, bool allowHighlight = true) + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private SongSelect songSelect { get; set; } + + public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { this.score = score; this.rank = rank; @@ -83,7 +93,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.Centre, Origin = Anchor.Centre, Font = OsuFont.GetFont(size: 20, italics: true), - Text = rank.ToMetric(decimals: rank < 100000 ? 1 : 0), + Text = rank == null ? "-" : rank.Value.ToMetric(decimals: rank < 100000 ? 1 : 0), }, }, }, @@ -193,7 +203,7 @@ namespace osu.Game.Online.Leaderboards scoreLabel = new GlowingSpriteText { TextColour = Color4.White, - GlowColour = OsuColour.FromHex(@"83ccfa"), + GlowColour = Color4Extensions.FromHex(@"83ccfa"), Text = score.TotalScore.ToString(@"N0"), Font = OsuFont.Numeric.With(size: 23), }, @@ -270,7 +280,7 @@ namespace osu.Game.Online.Leaderboards protected virtual IEnumerable GetStatistics(ScoreInfo model) => new[] { new LeaderboardScoreStatistic(FontAwesome.Solid.Link, "Max Combo", model.MaxCombo.ToString()), - new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", string.Format(model.Accuracy % 1 == 0 ? @"{0:P0}" : @"{0:P2}", model.Accuracy)) + new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", model.DisplayAccuracy) }; protected override bool OnHover(HoverEvent e) @@ -318,7 +328,7 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.Centre, Size = new Vector2(icon_size), Rotation = 45, - Colour = OsuColour.FromHex(@"3087ac"), + Colour = Color4Extensions.FromHex(@"3087ac"), Icon = FontAwesome.Solid.Square, Shadow = true, }, @@ -327,7 +337,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(icon_size - 6), - Colour = OsuColour.FromHex(@"a4edff"), + Colour = Color4Extensions.FromHex(@"a4edff"), Icon = statistic.Icon, }, }, @@ -337,7 +347,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, TextColour = Color4.White, - GlowColour = OsuColour.FromHex(@"83ccfa"), + GlowColour = Color4Extensions.FromHex(@"83ccfa"), Text = statistic.Value, Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold), }, @@ -359,5 +369,21 @@ namespace osu.Game.Online.Leaderboards Value = value; } } + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); + + if (score.ID != 0) + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs b/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs index 801f3f8ff0..d109f28e72 100644 --- a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs +++ b/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; +using osu.Game.Online.Placeholders; using osuTK; namespace osu.Game.Online.Leaderboards @@ -54,10 +55,10 @@ namespace osu.Game.Online.Leaderboards return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { icon.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Online/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index d9e8957281..8f74fd84fe 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -7,23 +7,31 @@ using osu.Game.Scoring; namespace osu.Game.Online.Leaderboards { - public class UpdateableRank : ModelBackedDrawable + public class UpdateableRank : ModelBackedDrawable { - public ScoreRank Rank + public ScoreRank? Rank { get => Model; set => Model = value; } - public UpdateableRank(ScoreRank rank) + public UpdateableRank(ScoreRank? rank) { Rank = rank; } - protected override Drawable CreateDrawable(ScoreRank rank) => new DrawableRank(rank) + protected override Drawable CreateDrawable(ScoreRank? rank) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; + if (rank.HasValue) + { + return new DrawableRank(rank.Value) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + return null; + } } } diff --git a/osu.Game/Online/Multiplayer/PlaylistItem.cs b/osu.Game/Online/Multiplayer/PlaylistItem.cs index d13e8b31e6..416091a1aa 100644 --- a/osu.Game/Online/Multiplayer/PlaylistItem.cs +++ b/osu.Game/Online/Multiplayer/PlaylistItem.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.Collections.Generic; +using System; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; namespace osu.Game.Online.Multiplayer { - public class PlaylistItem + public class PlaylistItem : IEquatable { [JsonProperty("id")] public int ID { get; set; } @@ -23,34 +24,26 @@ namespace osu.Game.Online.Multiplayer public int RulesetID { get; set; } [JsonIgnore] - public BeatmapInfo Beatmap - { - get => beatmap; - set - { - beatmap = value; - BeatmapID = value?.OnlineBeatmapID ?? 0; - } - } + public readonly Bindable Beatmap = new Bindable(); [JsonIgnore] - public RulesetInfo Ruleset { get; set; } + public readonly Bindable Ruleset = new Bindable(); [JsonIgnore] - public readonly List AllowedMods = new List(); + public readonly BindableList AllowedMods = new BindableList(); [JsonIgnore] - public readonly List RequiredMods = new List(); + public readonly BindableList RequiredMods = new BindableList(); [JsonProperty("beatmap")] - private APIBeatmap apiBeatmap { get; set; } + private APIPlaylistBeatmap apiBeatmap { get; set; } private APIMod[] allowedModsBacking; [JsonProperty("allowed_mods")] private APIMod[] allowedMods { - get => AllowedMods.Select(m => new APIMod { Acronym = m.Acronym }).ToArray(); + get => AllowedMods.Select(m => new APIMod(m)).ToArray(); set => allowedModsBacking = value; } @@ -59,23 +52,27 @@ namespace osu.Game.Online.Multiplayer [JsonProperty("required_mods")] private APIMod[] requiredMods { - get => RequiredMods.Select(m => new APIMod { Acronym = m.Acronym }).ToArray(); + get => RequiredMods.Select(m => new APIMod(m)).ToArray(); set => requiredModsBacking = value; } - private BeatmapInfo beatmap; + public PlaylistItem() + { + Beatmap.BindValueChanged(beatmap => BeatmapID = beatmap.NewValue?.OnlineBeatmapID ?? 0); + Ruleset.BindValueChanged(ruleset => RulesetID = ruleset.NewValue?.ID ?? 0); + } public void MapObjects(BeatmapManager beatmaps, RulesetStore rulesets) { - // If we don't have an api beatmap, the request occurred as a result of room creation, so we can query the local beatmap instead - // Todo: Is this a bug? Room creation only returns the beatmap ID - Beatmap = apiBeatmap == null ? beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == BeatmapID) : apiBeatmap.ToBeatmap(rulesets); - Ruleset = rulesets.GetRuleset(RulesetID); + Beatmap.Value = apiBeatmap.ToBeatmap(rulesets); + Ruleset.Value = rulesets.GetRuleset(RulesetID); + + Ruleset rulesetInstance = Ruleset.Value.CreateInstance(); if (allowedModsBacking != null) { AllowedMods.Clear(); - AllowedMods.AddRange(Ruleset.CreateInstance().GetAllMods().Where(mod => allowedModsBacking.Any(m => m.Acronym == mod.Acronym))); + AllowedMods.AddRange(allowedModsBacking.Select(m => m.ToMod(rulesetInstance))); allowedModsBacking = null; } @@ -83,7 +80,7 @@ namespace osu.Game.Online.Multiplayer if (requiredModsBacking != null) { RequiredMods.Clear(); - RequiredMods.AddRange(Ruleset.CreateInstance().GetAllMods().Where(mod => requiredModsBacking.Any(m => m.Acronym == mod.Acronym))); + RequiredMods.AddRange(requiredModsBacking.Select(m => m.ToMod(rulesetInstance))); requiredModsBacking = null; } @@ -91,5 +88,7 @@ namespace osu.Game.Online.Multiplayer public bool ShouldSerializeID() => false; public bool ShouldSerializeapiBeatmap() => false; + + public bool Equals(PlaylistItem other) => ID == other?.ID && BeatmapID == other.BeatmapID && RulesetID == other.RulesetID; } } diff --git a/osu.Game/Online/Multiplayer/Room.cs b/osu.Game/Online/Multiplayer/Room.cs index 53089897f7..34cf158442 100644 --- a/osu.Game/Online/Multiplayer/Room.cs +++ b/osu.Game/Online/Multiplayer/Room.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using osu.Framework.Allocation; @@ -17,70 +16,58 @@ namespace osu.Game.Online.Multiplayer { [Cached] [JsonProperty("id")] - public Bindable RoomID { get; private set; } = new Bindable(); + public readonly Bindable RoomID = new Bindable(); [Cached] [JsonProperty("name")] - public Bindable Name { get; private set; } = new Bindable(); + public readonly Bindable Name = new Bindable(); [Cached] [JsonProperty("host")] - public Bindable Host { get; private set; } = new Bindable(); + public readonly Bindable Host = new Bindable(); [Cached] [JsonProperty("playlist")] - public BindableList Playlist { get; private set; } = new BindableList(); - - [Cached] - [JsonIgnore] - public Bindable CurrentItem { get; private set; } = new Bindable(); + public readonly BindableList Playlist = new BindableList(); [Cached] [JsonProperty("channel_id")] - public Bindable ChannelId { get; private set; } = new Bindable(); + public readonly Bindable ChannelId = new Bindable(); + + [Cached] + [JsonProperty("category")] + public readonly Bindable Category = new Bindable(); [Cached] [JsonIgnore] - public Bindable Duration { get; private set; } = new Bindable(TimeSpan.FromMinutes(30)); + public readonly Bindable Duration = new Bindable(TimeSpan.FromMinutes(30)); [Cached] [JsonIgnore] - public Bindable MaxAttempts { get; private set; } = new Bindable(); + public readonly Bindable MaxAttempts = new Bindable(); [Cached] [JsonIgnore] - public Bindable Status { get; private set; } = new Bindable(new RoomStatusOpen()); + public readonly Bindable Status = new Bindable(new RoomStatusOpen()); [Cached] [JsonIgnore] - public Bindable Availability { get; private set; } = new Bindable(); + public readonly Bindable Availability = new Bindable(); [Cached] [JsonIgnore] - public Bindable Type { get; private set; } = new Bindable(new GameTypeTimeshift()); + public readonly Bindable Type = new Bindable(new GameTypeTimeshift()); [Cached] [JsonIgnore] - public Bindable MaxParticipants { get; private set; } = new Bindable(); + public readonly Bindable MaxParticipants = new Bindable(); [Cached] - [JsonIgnore] - public Bindable> Participants { get; private set; } = new Bindable>(Enumerable.Empty()); + [JsonProperty("recent_participants")] + public readonly BindableList RecentParticipants = new BindableList(); [Cached] - public Bindable ParticipantCount { get; private set; } = new Bindable(); - - public Room() - { - Playlist.ItemsAdded += updateCurrent; - Playlist.ItemsRemoved += updateCurrent; - updateCurrent(Playlist); - } - - private void updateCurrent(IEnumerable playlist) - { - CurrentItem.Value = playlist.FirstOrDefault(); - } + public readonly Bindable ParticipantCount = new Bindable(); // todo: TEMPORARY [JsonProperty("participant_count")] @@ -100,7 +87,7 @@ namespace osu.Game.Online.Multiplayer // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] - public Bindable EndDate { get; private set; } = new Bindable(); + public readonly Bindable EndDate = new Bindable(); // Todo: Find a better way to do this (https://github.com/ppy/osu-framework/issues/1930) [JsonProperty("max_attempts", DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -114,7 +101,7 @@ namespace osu.Game.Online.Multiplayer /// The position of this in the list. This is not read from or written to the API. /// [JsonIgnore] - public Bindable Position { get; private set; } = new Bindable(-1); + public readonly Bindable Position = new Bindable(-1); public void CopyFrom(Room other) { @@ -130,19 +117,24 @@ namespace osu.Game.Online.Multiplayer Type.Value = other.Type.Value; MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; - Participants.Value = other.Participants.Value.ToArray(); EndDate.Value = other.EndDate.Value; if (DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); - // Todo: Temporary, should only remove/add new items (requires framework changes) - if (Playlist.Count == 0) + if (!Playlist.SequenceEqual(other.Playlist)) + { + Playlist.Clear(); Playlist.AddRange(other.Playlist); - else if (other.Playlist.Count > 0) - Playlist.First().ID = other.Playlist.First().ID; + } - Position = other.Position; + if (!RecentParticipants.SequenceEqual(other.RecentParticipants)) + { + RecentParticipants.Clear(); + RecentParticipants.AddRange(other.RecentParticipants); + } + + Position.Value = other.Position.Value; } public bool ShouldSerializeRoomID() => false; diff --git a/osu.Game/Online/Multiplayer/RoomCategory.cs b/osu.Game/Online/Multiplayer/RoomCategory.cs new file mode 100644 index 0000000000..636a73a3e9 --- /dev/null +++ b/osu.Game/Online/Multiplayer/RoomCategory.cs @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.Multiplayer +{ + public enum RoomCategory + { + Normal, + Spotlight + } +} diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs new file mode 100644 index 0000000000..b52e3d9e3c --- /dev/null +++ b/osu.Game/Online/OnlineViewContainer.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.Placeholders; + +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 abstract class OnlineViewContainer : Container, IOnlineComponent + { + protected LoadingSpinner LoadingSpinner { get; private set; } + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private readonly string placeholderMessage; + + private Placeholder placeholder; + + private const double transform_duration = 300; + + [Resolved] + protected IAPIProvider API { get; private set; } + + protected OnlineViewContainer(string placeholderMessage) + { + this.placeholderMessage = placeholderMessage; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + Content, + placeholder = new LoginPlaceholder(placeholderMessage), + LoadingSpinner = new LoadingSpinner + { + Alpha = 0, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + API.Register(this); + } + + public virtual void APIStateChanged(IAPIProvider api, APIState state) + { + switch (state) + { + case APIState.Offline: + PopContentOut(Content); + placeholder.ScaleTo(0.8f).Then().ScaleTo(1, 3 * transform_duration, Easing.OutQuint); + placeholder.FadeInFromZero(2 * transform_duration, Easing.OutQuint); + LoadingSpinner.Hide(); + break; + + case APIState.Online: + PopContentIn(Content); + placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); + LoadingSpinner.Hide(); + break; + + case APIState.Failing: + case APIState.Connecting: + PopContentOut(Content); + LoadingSpinner.Show(); + placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); + break; + } + } + + /// + /// Applies a transform to the online content to make it hidden. + /// + protected virtual void PopContentOut(Drawable content) => content.FadeOut(transform_duration / 2, Easing.OutQuint); + + /// + /// Applies a transform to the online content to make it visible. + /// + protected virtual void PopContentIn(Drawable content) => content.FadeIn(transform_duration, Easing.OutQuint); + + protected override void Dispose(bool isDisposing) + { + API?.Unregister(this); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs new file mode 100644 index 0000000000..73b0fa27c3 --- /dev/null +++ b/osu.Game/Online/Placeholders/LoginPlaceholder.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.Input.Events; +using osu.Game.Overlays; + +namespace osu.Game.Online.Placeholders +{ + public sealed class LoginPlaceholder : Placeholder + { + [Resolved(CanBeNull = true)] + private LoginOverlay login { get; set; } + + public LoginPlaceholder(string actionMessage) + { + AddIcon(FontAwesome.Solid.UserLock, cp => + { + cp.Font = cp.Font.With(size: TEXT_SIZE); + cp.Padding = new MarginPadding { Right = 10 }; + }); + + AddText(actionMessage); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + this.ScaleTo(0.8f, 4000, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + this.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + + protected override bool OnClick(ClickEvent e) + { + login?.Show(); + return base.OnClick(e); + } + } +} diff --git a/osu.Game/Online/Leaderboards/MessagePlaceholder.cs b/osu.Game/Online/Placeholders/MessagePlaceholder.cs similarity index 95% rename from osu.Game/Online/Leaderboards/MessagePlaceholder.cs rename to osu.Game/Online/Placeholders/MessagePlaceholder.cs index ef425dacd8..7342765ca4 100644 --- a/osu.Game/Online/Leaderboards/MessagePlaceholder.cs +++ b/osu.Game/Online/Placeholders/MessagePlaceholder.cs @@ -4,7 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -namespace osu.Game.Online.Leaderboards +namespace osu.Game.Online.Placeholders { public class MessagePlaceholder : Placeholder { diff --git a/osu.Game/Online/Leaderboards/Placeholder.cs b/osu.Game/Online/Placeholders/Placeholder.cs similarity index 95% rename from osu.Game/Online/Leaderboards/Placeholder.cs rename to osu.Game/Online/Placeholders/Placeholder.cs index d38110a9d0..f58282bbd9 100644 --- a/osu.Game/Online/Leaderboards/Placeholder.cs +++ b/osu.Game/Online/Placeholders/Placeholder.cs @@ -5,7 +5,7 @@ using System; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; -namespace osu.Game.Online.Leaderboards +namespace osu.Game.Online.Placeholders { public abstract class Placeholder : OsuTextFlowContainer, IEquatable { diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index acbb2c39f4..228f147835 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -3,7 +3,7 @@ using System; using System.Threading.Tasks; -using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Threading; namespace osu.Game.Online @@ -11,7 +11,7 @@ namespace osu.Game.Online /// /// A component which requires a constant polling process. /// - public abstract class PollingComponent : Component + public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages. { private double? lastTimePolled; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 84aba4af52..f4bb10340e 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -18,6 +18,7 @@ using osu.Game.Screens.Menu; using System.Linq; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Development; @@ -25,6 +26,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; @@ -33,7 +35,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Overlays.Notifications; -using osu.Game.Screens.Play; using osu.Game.Input.Bindings; using osu.Game.Online.Chat; using osu.Game.Skinning; @@ -41,7 +42,10 @@ using osuTK.Graphics; using osu.Game.Overlays.Volume; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Updater; using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; @@ -59,11 +63,14 @@ namespace osu.Game private ChannelManager channelManager; - private NotificationOverlay notifications; + [NotNull] + private readonly NotificationOverlay notifications = new NotificationOverlay(); - private DirectOverlay direct; + private NowPlayingOverlay nowPlaying; - private SocialOverlay social; + private BeatmapListingOverlay beatmapListing; + + private DashboardOverlay dashboard; private UserProfileOverlay userProfile; @@ -76,7 +83,7 @@ namespace osu.Game public virtual Storage GetStorageForStableInstall() => null; - public float ToolbarOffset => Toolbar.Position.Y + Toolbar.DrawHeight; + public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); private IdleTracker idleTracker; @@ -86,13 +93,14 @@ namespace osu.Game protected BackButton BackButton; - protected SettingsPanel Settings; + protected SettingsOverlay Settings; private VolumeOverlay volume; private OsuLogo osuLogo; private MainMenu menuScreen; + [CanBeNull] private IntroScreen introScreen; private Bindable configRuleset; @@ -148,10 +156,8 @@ namespace osu.Game dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig) + private void load() { - this.frameworkConfig = frameworkConfig; - if (!Host.IsPrimaryInstance && !DebugUtils.IsDebugBuild) { Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); @@ -245,7 +251,7 @@ namespace osu.Game case LinkAction.OpenEditorTimestamp: case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: - waitForReady(() => notifications, _ => notifications?.Post(new SimpleNotification + waitForReady(() => notifications, _ => notifications.Post(new SimpleNotification { Text = @"This link type is not yet supported!", Icon = FontAwesome.Solid.LifeRing, @@ -313,8 +319,15 @@ namespace osu.Game /// The user should have already requested this interactively. /// /// The beatmap to select. - public void PresentBeatmap(BeatmapSetInfo beatmap) + /// + /// Optional predicate used to try and find a difficulty to select. + /// If omitted, this will try to present the first beatmap from the current ruleset. + /// In case of failure the first difficulty of the set will be presented, ignoring the predicate. + /// + public void PresentBeatmap(BeatmapSetInfo beatmap, Predicate difficultyCriteria = null) { + difficultyCriteria ??= b => b.Ruleset.Equals(Ruleset.Value); + var databasedSet = beatmap.OnlineBeatmapSetID != null ? BeatmapManager.QueryBeatmapSet(s => s.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID) : BeatmapManager.QueryBeatmapSet(s => s.Hash == beatmap.Hash); @@ -325,31 +338,31 @@ namespace osu.Game return; } - performFromMainMenu(() => + PerformFromScreen(screen => { // we might already be at song select, so a check is required before performing the load to solo. - if (menuScreen.IsCurrentScreen()) + if (screen is MainMenu) menuScreen.LoadToSolo(); // we might even already be at the song - if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash) + if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && difficultyCriteria(Beatmap.Value.BeatmapInfo)) { return; } - // Use first beatmap available for current ruleset, else switch ruleset. - var first = databasedSet.Beatmaps.Find(b => b.Ruleset.Equals(Ruleset.Value)) ?? databasedSet.Beatmaps.First(); + // Find first beatmap that matches our predicate. + var first = databasedSet.Beatmaps.Find(difficultyCriteria) ?? databasedSet.Beatmaps.First(); Ruleset.Value = first.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(first); - }, $"load {beatmap}", bypassScreenAllowChecks: true, targetScreen: typeof(PlaySongSelect)); + }, validScreens: new[] { typeof(PlaySongSelect) }); } /// /// Present a score's replay immediately. /// The user should have already requested this interactively. /// - public void PresentScore(ScoreInfo score) + public void PresentScore(ScoreInfo score, ScorePresentType presentType = ScorePresentType.Results) { // 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. @@ -379,35 +392,55 @@ namespace osu.Game return; } - performFromMainMenu(() => + PerformFromScreen(screen => { + Ruleset.Value = databasedScore.ScoreInfo.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); - menuScreen.Push(new ReplayPlayerLoader(databasedScore)); - }, $"watch {databasedScoreInfo}", bypassScreenAllowChecks: true); + switch (presentType) + { + case ScorePresentType.Gameplay: + screen.Push(new ReplayPlayerLoader(databasedScore)); + break; + + case ScorePresentType.Results: + screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo)); + break; + } + }, validScreens: new[] { typeof(PlaySongSelect) }); } protected virtual Loader CreateLoader() => new Loader(); + protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); #region Beatmap progression private void beatmapChanged(ValueChangedEvent beatmap) { - var nextBeatmap = beatmap.NewValue; - if (nextBeatmap?.Track != null) - nextBeatmap.Track.Completed += currentTrackCompleted; - - using (var oldBeatmap = beatmap.OldValue) - { - if (oldBeatmap?.Track != null) - oldBeatmap.Track.Completed -= currentTrackCompleted; - } + beatmap.OldValue?.CancelAsyncLoad(); updateModDefaults(); - nextBeatmap?.LoadBeatmapAsync(); + var newBeatmap = beatmap.NewValue; + + if (newBeatmap != null) + { + newBeatmap.Track.Completed += () => Scheduler.AddOnce(() => trackCompleted(newBeatmap)); + newBeatmap.BeginAsyncLoad(); + } + + void trackCompleted(WorkingBeatmap b) + { + // the source of track completion is the audio thread, so the beatmap may have changed before firing. + if (Beatmap.Value != b) + return; + + if (!Beatmap.Value.Track.Looping && !Beatmap.Disabled) + MusicController.NextTrack(); + } } private void modsChanged(ValueChangedEvent> mods) @@ -428,64 +461,47 @@ namespace osu.Game } } - private void currentTrackCompleted() => Schedule(() => - { - if (!Beatmap.Value.Track.Looping && !Beatmap.Disabled) - musicController.NextTrack(); - }); - #endregion private ScheduledDelegate performFromMainMenuTask; /// - /// Perform an action only after returning to the main menu. + /// Perform an action only after returning to a specific screen as indicated by . /// Eagerly tries to exit the current screen until it succeeds. /// /// The action to perform once we are in the correct state. - /// The task name to display in a notification (if we can't immediately reach the main menu state). - /// An optional target screen type. If this screen is already current we can immediately perform the action without returning to the menu. - /// Whether checking should be bypassed. - private void performFromMainMenu(Action action, string taskName, Type targetScreen = null, bool bypassScreenAllowChecks = false) + /// An optional collection of valid screen types. If any of these screens are already current we can perform the action immediately, else the first valid parent will be made current before performing the action. is used if not specified. + public void PerformFromScreen(Action action, IEnumerable validScreens = null) { performFromMainMenuTask?.Cancel(); - // if the current screen does not allow screen changing, give the user an option to try again later. - if (!bypassScreenAllowChecks && (ScreenStack.CurrentScreen as IOsuScreen)?.AllowExternalScreenChange == false) - { - notifications.Post(new SimpleNotification - { - Text = $"Click here to {taskName}", - Activated = () => - { - performFromMainMenu(action, taskName, targetScreen, true); - return true; - } - }); - - return; - } + validScreens ??= Enumerable.Empty(); + validScreens = validScreens.Append(typeof(MainMenu)); CloseAllOverlays(false); // we may already be at the target screen type. - if (targetScreen != null && ScreenStack.CurrentScreen?.GetType() == targetScreen) + if (validScreens.Contains(ScreenStack.CurrentScreen?.GetType()) && !Beatmap.Disabled) { - action(); + action(ScreenStack.CurrentScreen); return; } - // all conditions have been met to continue with the action. - if (menuScreen?.IsCurrentScreen() == true && !Beatmap.Disabled) + // find closest valid target + IScreen screen = ScreenStack.CurrentScreen; + + while (screen != null) { - action(); - return; + if (validScreens.Contains(screen.GetType())) + { + screen.MakeCurrent(); + break; + } + + screen = screen.GetParentScreen(); } - // menuScreen may not be initialised yet (null check required). - menuScreen?.MakeCurrent(); - - performFromMainMenuTask = Schedule(() => performFromMainMenu(action, taskName)); + performFromMainMenuTask = Schedule(() => PerformFromScreen(action, validScreens)); } /// @@ -521,14 +537,14 @@ namespace osu.Game MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. - SkinManager.PostNotification = n => notifications?.Post(n); + SkinManager.PostNotification = n => notifications.Post(n); SkinManager.GetStableStorage = GetStorageForStableInstall; - BeatmapManager.PostNotification = n => notifications?.Post(n); + BeatmapManager.PostNotification = n => notifications.Post(n); BeatmapManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); - ScoreManager.PostNotification = n => notifications?.Post(n); + ScoreManager.PostNotification = n => notifications.Post(n); ScoreManager.GetStableStorage = GetStorageForStableInstall; ScoreManager.PresentImport = items => PresentScore(items.First()); @@ -558,7 +574,9 @@ namespace osu.Game Origin = Anchor.BottomLeft, Action = () => { - if ((ScreenStack.CurrentScreen as IOsuScreen)?.AllowBackButton == true) + var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; + + if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) ScreenStack.Exit(); } }, @@ -596,20 +614,24 @@ namespace osu.Game loadComponentSingleFile(new OnScreenDisplay(), Add, true); - loadComponentSingleFile(musicController = new MusicController(), Add, true); + loadComponentSingleFile(MusicController = new MusicController(), Add, true); - loadComponentSingleFile(notifications = new NotificationOverlay + loadComponentSingleFile(notifications.With(d => { - GetToolbarHeight = () => ToolbarOffset, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, rightFloatingOverlayContent.Add, true); + d.GetToolbarHeight = () => ToolbarOffset; + d.Anchor = Anchor.TopRight; + d.Origin = Anchor.TopRight; + }), rightFloatingOverlayContent.Add, true); loadComponentSingleFile(screenshotManager, Add); - //overlay elements - loadComponentSingleFile(direct = new DirectOverlay(), overlayContent.Add, true); - loadComponentSingleFile(social = new SocialOverlay(), overlayContent.Add, true); + // dependency on notification overlay, dependent by settings overlay + loadComponentSingleFile(CreateUpdateManager(), Add, true); + + // overlay elements + loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); + loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); + var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); loadComponentSingleFile(Settings = new SettingsOverlay { GetToolbarHeight = () => ToolbarOffset }, leftFloatingOverlayContent.Add, true); @@ -624,7 +646,7 @@ namespace osu.Game Origin = Anchor.TopRight, }, rightFloatingOverlayContent.Add, true); - loadComponentSingleFile(new NowPlayingOverlay + loadComponentSingleFile(nowPlaying = new NowPlayingOverlay { GetToolbarHeight = () => ToolbarOffset, Anchor = Anchor.TopRight, @@ -666,7 +688,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, social, direct, changelogOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, dashboard, beatmapListing, changelogOverlay, rankingsOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -748,7 +770,7 @@ namespace osu.Game Text = "Subsequent messages have been logged. Click to view log files.", Activated = () => { - Host.Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); + Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); return true; } })); @@ -761,13 +783,20 @@ namespace osu.Game private Task asyncLoadStream; - private T loadComponentSingleFile(T d, Action add, bool cache = false) + /// + /// Queues loading the provided component in sequential fashion. + /// This operation is limited to a single thread to avoid saturating all cores. + /// + /// The component to load. + /// An action to invoke on load completion (generally to add the component to the hierarchy). + /// Whether to cache the component as type into the game dependencies before any scheduling. + private T loadComponentSingleFile(T component, Action loadCompleteAction, bool cache = false) where T : Drawable { if (cache) - dependencies.Cache(d); + dependencies.CacheAs(component); - if (d is OverlayContainer overlay) + if (component is OverlayContainer overlay) overlays.Add(overlay); // schedule is here to ensure that all component loads are done after LoadComplete is run (and thus all dependencies are cached). @@ -777,7 +806,7 @@ namespace osu.Game { var previousLoadStream = asyncLoadStream; - //chain with existing load stream + // chain with existing load stream asyncLoadStream = Task.Run(async () => { if (previousLoadStream != null) @@ -785,12 +814,12 @@ namespace osu.Game try { - Logger.Log($"Loading {d}...", level: LogLevel.Debug); + Logger.Log($"Loading {component}...", level: LogLevel.Debug); // Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called // throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true Task task = null; - var del = new ScheduledDelegate(() => task = LoadComponentAsync(d, add)); + var del = new ScheduledDelegate(() => task = LoadComponentAsync(component, loadCompleteAction)); Scheduler.Add(del); // The delegate won't complete if OsuGame has been disposed in the meantime @@ -805,7 +834,7 @@ namespace osu.Game await task; - Logger.Log($"Loaded {d}!", level: LogLevel.Debug); + Logger.Log($"Loaded {component}!", level: LogLevel.Debug); } catch (OperationCanceledException) { @@ -813,7 +842,14 @@ namespace osu.Game }); }); - return d; + return component; + } + + protected override bool OnScroll(ScrollEvent e) + { + // forward any unhandled mouse scroll events to the volume control. + volume.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); + return true; } public bool OnPressed(GlobalAction action) @@ -822,12 +858,16 @@ namespace osu.Game switch (action) { + case GlobalAction.ToggleNowPlaying: + nowPlaying.ToggleVisibility(); + return true; + case GlobalAction.ToggleChat: chatOverlay.ToggleVisibility(); return true; case GlobalAction.ToggleSocial: - social.ToggleVisibility(); + dashboard.ToggleVisibility(); return true; case GlobalAction.ResetInputSettings: @@ -850,7 +890,11 @@ namespace osu.Game return true; case GlobalAction.ToggleDirect: - direct.ToggleVisibility(); + beatmapListing.ToggleVisibility(); + return true; + + case GlobalAction.ToggleNotifications: + notifications.ToggleVisibility(); return true; case GlobalAction.ToggleGameplayMouseButtons: @@ -875,7 +919,9 @@ namespace osu.Game #endregion - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private Container overlayContent; @@ -885,21 +931,19 @@ namespace osu.Game private Container topMostOverlayContent; - private FrameworkConfigManager frameworkConfig; + [Resolved] + private FrameworkConfigManager frameworkConfig { get; set; } private ScalingContainer screenContainer; - private MusicController musicController; + protected MusicController MusicController { get; private set; } protected override bool OnExiting() { if (ScreenStack.CurrentScreen is Loader) return false; - if (introScreen == null) - return true; - - if (!introScreen.DidLoadMenu || !(ScreenStack.CurrentScreen is IntroScreen)) + if (introScreen?.DidLoadMenu == true && !(ScreenStack.CurrentScreen is IntroScreen)) { Scheduler.Add(introScreen.MakeCurrent); return true; @@ -947,7 +991,7 @@ namespace osu.Game { OverlayActivationMode.Value = newOsuScreen.InitialOverlayActivationMode; - musicController.AllowRateAdjustments = newOsuScreen.AllowRateAdjustments; + MusicController.AllowRateAdjustments = newOsuScreen.AllowRateAdjustments; if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); @@ -976,4 +1020,10 @@ namespace osu.Game Exit(); } } + + public enum ScorePresentType + { + Results, + Gameplay + } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 22b8d9d012..dd120937af 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -29,6 +29,7 @@ using osu.Game.Database; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; +using osu.Game.Resources; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -46,6 +47,8 @@ namespace osu.Game { public const string CLIENT_STREAM_NAME = "lazer"; + public const int SAMPLE_CONCURRENCY = 6; + protected OsuConfigManager LocalConfig; protected BeatmapManager BeatmapManager; @@ -96,7 +99,7 @@ namespace osu.Game public bool IsDeployedBuild => AssemblyVersion.Major > 0; - public string Version + public virtual string Version { get { @@ -125,10 +128,12 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - Resources.AddStore(new DllResourceStore(@"osu.Game.Resources.dll")); + Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); + dependencies.CacheAs(Storage); + var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); @@ -137,41 +142,36 @@ namespace osu.Game dependencies.Cache(LocalConfig); AddFont(Resources, @"Fonts/osuFont"); - AddFont(Resources, @"Fonts/Exo2.0-Medium"); - AddFont(Resources, @"Fonts/Exo2.0-MediumItalic"); + + AddFont(Resources, @"Fonts/Torus-Regular"); + AddFont(Resources, @"Fonts/Torus-Light"); + AddFont(Resources, @"Fonts/Torus-SemiBold"); + AddFont(Resources, @"Fonts/Torus-Bold"); AddFont(Resources, @"Fonts/Noto-Basic"); AddFont(Resources, @"Fonts/Noto-Hangul"); AddFont(Resources, @"Fonts/Noto-CJK-Basic"); AddFont(Resources, @"Fonts/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto-Thai"); - AddFont(Resources, @"Fonts/Exo2.0-Regular"); - AddFont(Resources, @"Fonts/Exo2.0-RegularItalic"); - AddFont(Resources, @"Fonts/Exo2.0-SemiBold"); - AddFont(Resources, @"Fonts/Exo2.0-SemiBoldItalic"); - AddFont(Resources, @"Fonts/Exo2.0-Bold"); - AddFont(Resources, @"Fonts/Exo2.0-BoldItalic"); - AddFont(Resources, @"Fonts/Exo2.0-Light"); - AddFont(Resources, @"Fonts/Exo2.0-LightItalic"); - AddFont(Resources, @"Fonts/Exo2.0-Black"); - AddFont(Resources, @"Fonts/Exo2.0-BlackItalic"); - - AddFont(Resources, @"Fonts/Venera"); AddFont(Resources, @"Fonts/Venera-Light"); - AddFont(Resources, @"Fonts/Venera-Medium"); + AddFont(Resources, @"Fonts/Venera-Bold"); + AddFont(Resources, @"Fonts/Venera-Black"); + + Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; runMigrations(); dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Audio, new NamespacedResourceStore(Resources, "Skins/Legacy"))); dependencies.CacheAs(SkinManager); - if (API == null) API = new APIAccess(LocalConfig); + API ??= new APIAccess(LocalConfig); dependencies.CacheAs(API); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); - dependencies.Cache(RulesetStore = new RulesetStore(contextFactory)); + dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); dependencies.Cache(FileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() @@ -187,8 +187,17 @@ namespace osu.Game return ScoreManager.QueryScores(s => beatmapIds.Contains(s.Beatmap.ID)).ToList(); } - BeatmapManager.ItemRemoved += i => ScoreManager.Delete(getBeatmapScores(i), true); - BeatmapManager.ItemAdded += i => ScoreManager.Undelete(getBeatmapScores(i), true); + BeatmapManager.ItemRemoved.BindValueChanged(i => + { + if (i.NewValue.TryGetTarget(out var item)) + ScoreManager.Delete(getBeatmapScores(item), true); + }); + + BeatmapManager.ItemUpdated.BindValueChanged(i => + { + if (i.NewValue.TryGetTarget(out var item)) + ScoreManager.Undelete(getBeatmapScores(item), true); + }); dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); @@ -205,6 +214,10 @@ namespace osu.Game Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, new BindableDouble(0.8)); Beatmap = new NonNullableBindable(defaultBeatmap); + + // ScheduleAfterChildren is safety against something in the current frame accessing the previous beatmap's track + // and potentially causing a reload of it after just unloading. + // Note that the reason for this being added *has* been resolved, so it may be feasible to removed this if required. Beatmap.BindValueChanged(b => ScheduleAfterChildren(() => { // compare to last beatmap as sometimes the two may share a track representation (optimisation, see WorkingBeatmap.TransferTo) @@ -217,8 +230,8 @@ namespace osu.Game FileStore.Cleanup(); - if (API is APIAccess apiAcces) - AddInternal(apiAcces); + if (API is APIAccess apiAccess) + AddInternal(apiAccess); AddInternal(RulesetConfigCache); GlobalActionContainer globalBinding; @@ -299,13 +312,14 @@ namespace osu.Game { base.SetHost(host); - if (Storage == null) - Storage = host.Storage; + // may be non-null for certain tests + Storage ??= host.Storage; - if (LocalConfig == null) - LocalConfig = new OsuConfigManager(Storage); + LocalConfig ??= new OsuConfigManager(Storage); } + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + private readonly List fileImporters = new List(); public async Task Import(params string[] paths) @@ -325,11 +339,14 @@ namespace osu.Game { base.Dispose(isDisposing); RulesetStore?.Dispose(); + BeatmapManager?.Dispose(); + + contextFactory.FlushConnections(); } private class OsuUserInputManager : UserInputManager { - protected override MouseButtonEventManager CreateButtonManagerFor(MouseButton button) + protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { switch (button) { @@ -337,7 +354,7 @@ namespace osu.Game return new RightMouseManager(button); } - return base.CreateButtonManagerFor(button); + return base.CreateButtonEventManagerFor(button); } private class RightMouseManager : MouseButtonEventManager @@ -352,5 +369,11 @@ namespace osu.Game public override bool ChangeFocusOnClick => false; } } + + public void Migrate(string path) + { + contextFactory.FlushConnections(); + (Storage as OsuStorage)?.Migrate(path); + } } } diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index 6de14c51ee..a0b1b27ebf 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -9,7 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Graphics; @@ -33,23 +33,26 @@ namespace osu.Game.Overlays.AccountCreation private OsuTextBox emailTextBox; private OsuPasswordTextBox passwordTextBox; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } + private ShakeContainer registerShake; private IEnumerable characterCheckText; private OsuTextBox[] textboxes; - private ProcessingOverlay processingOverlay; - private GameHost host; + private LoadingLayer loadingLayer; + + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours, IAPIProvider api, GameHost host) + private void load(OsuColour colours) { - this.api = api; - this.host = host; + FillFlowContainer mainContent; InternalChildren = new Drawable[] { - new FillFlowContainer + mainContent = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Direction = FillDirection.Vertical, @@ -121,7 +124,7 @@ namespace osu.Game.Overlays.AccountCreation }, }, }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(mainContent) }; textboxes = new[] { usernameTextBox, emailTextBox, passwordTextBox }; @@ -129,7 +132,7 @@ namespace osu.Game.Overlays.AccountCreation usernameDescription.AddText("This will be your public presence. No profanity, no impersonation. Avoid exposing your own personal details, too!"); emailAddressDescription.AddText("Will be used for notifications, account verification and in the case you forget your password. No spam, ever."); - emailAddressDescription.AddText(" Make sure to get it right!", cp => cp.Font = cp.Font.With(Typeface.Exo, weight: FontWeight.Bold)); + emailAddressDescription.AddText(" Make sure to get it right!", cp => cp.Font = cp.Font.With(Typeface.Torus, weight: FontWeight.Bold)); passwordDescription.AddText("At least "); characterCheckText = passwordDescription.AddText("8 characters long"); @@ -141,7 +144,7 @@ namespace osu.Game.Overlays.AccountCreation public override void OnEntering(IScreen last) { base.OnEntering(last); - processingOverlay.Hide(); + loadingLayer.Hide(); if (host?.OnScreenKeyboardOverlapsGameWindow != true) focusNextTextbox(); @@ -159,7 +162,7 @@ namespace osu.Game.Overlays.AccountCreation emailAddressDescription.ClearErrors(); passwordDescription.ClearErrors(); - processingOverlay.Show(); + loadingLayer.Show(); Task.Run(() => { @@ -192,11 +195,11 @@ namespace osu.Game.Overlays.AccountCreation } registerShake.Shake(); - processingOverlay.Hide(); + loadingLayer.Hide(); return; } - api.Login(emailTextBox.Text, passwordTextBox.Text); + api.Login(usernameTextBox.Text, passwordTextBox.Text); }); }); } diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index f91d2e3323..5375476c9e 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -22,7 +22,9 @@ namespace osu.Game.Overlays.AccountCreation { private OsuTextFlowContainer multiAccountExplanationText; private LinkFlowContainer furtherAssistance; - private IAPIProvider api; + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } private const string help_centre_url = "/help/wiki/Help_Centre#login"; @@ -39,10 +41,8 @@ namespace osu.Game.Overlays.AccountCreation } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, IAPIProvider api, OsuGame game, TextureStore textures) + private void load(OsuColour colours, OsuGame game, TextureStore textures) { - this.api = api; - if (string.IsNullOrEmpty(api.ProvidedUsername)) return; diff --git a/osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs b/osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs similarity index 94% rename from osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs rename to osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs index fd04a1541e..f6b5b181c3 100644 --- a/osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs +++ b/osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs @@ -5,7 +5,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays { public abstract class BeatmapDownloadTrackingComposite : DownloadTrackingComposite { diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs new file mode 100644 index 0000000000..494a0df8f8 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.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.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingFilterControl : CompositeDrawable + { + /// + /// Fired when a search finishes. Contains only new items in the case of pagination. + /// + public Action> SearchFinished; + + /// + /// Fired when search criteria change. + /// + public Action SearchStarted; + + /// + /// True when pagination has reached the end of available results. + /// + private bool noMoreResults; + + /// + /// The current page fetched of results (zero index). + /// + public int CurrentPage { get; private set; } + + private readonly BeatmapListingSearchControl searchControl; + private readonly BeatmapListingSortTabControl sortControl; + private readonly Box sortControlBackground; + + private ScheduledDelegate queryChangedDebounce; + + private SearchBeatmapSetsRequest getSetsRequest; + private SearchBeatmapSetsResponse lastResponse; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + public BeatmapListingFilterControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 3, + Offset = new Vector2(0f, 1f), + }, + Child = searchControl = new BeatmapListingSearchControl(), + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Children = new Drawable[] + { + sortControlBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + sortControl = new BeatmapListingSortTabControl + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 20 } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + sortControlBackground.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var sortCriteria = sortControl.Current; + var sortDirection = sortControl.SortDirection; + + searchControl.Query.BindValueChanged(query => + { + sortCriteria.Value = string.IsNullOrEmpty(query.NewValue) ? SortCriteria.Ranked : SortCriteria.Relevance; + sortDirection.Value = SortDirection.Descending; + queueUpdateSearch(true); + }); + + searchControl.Ruleset.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Category.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Genre.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Language.BindValueChanged(_ => queueUpdateSearch()); + + sortCriteria.BindValueChanged(_ => queueUpdateSearch()); + sortDirection.BindValueChanged(_ => queueUpdateSearch()); + } + + public void TakeFocus() => searchControl.TakeFocus(); + + /// + /// Fetch the next page of results. May result in a no-op if a fetch is already in progress, or if there are no results left. + /// + public void FetchNextPage() + { + // there may be no results left. + if (noMoreResults) + return; + + // there may already be an active request. + if (getSetsRequest != null) + return; + + if (lastResponse != null) + CurrentPage++; + + performRequest(); + } + + private void queueUpdateSearch(bool queryTextChanged = false) + { + SearchStarted?.Invoke(); + + resetSearch(); + + queryChangedDebounce = Scheduler.AddDelayed(() => + { + resetSearch(); + FetchNextPage(); + }, queryTextChanged ? 500 : 100); + } + + private void performRequest() + { + getSetsRequest = new SearchBeatmapSetsRequest( + searchControl.Query.Value, + searchControl.Ruleset.Value, + lastResponse?.Cursor, + searchControl.Category.Value, + sortControl.Current.Value, + sortControl.SortDirection.Value, + searchControl.Genre.Value, + searchControl.Language.Value); + + getSetsRequest.Success += response => + { + var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); + + if (sets.Count == 0) + noMoreResults = true; + + if (CurrentPage == 0) + searchControl.BeatmapSet = sets.FirstOrDefault(); + + lastResponse = response; + getSetsRequest = null; + + SearchFinished?.Invoke(sets); + }; + + api.Queue(getSetsRequest); + } + + private void resetSearch() + { + noMoreResults = false; + CurrentPage = 0; + + lastResponse = null; + + getSetsRequest?.Cancel(); + getSetsRequest = null; + + queryChangedDebounce?.Cancel(); + } + + protected override void Dispose(bool isDisposing) + { + resetSearch(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs new file mode 100644 index 0000000000..1bab200fec --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.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.Overlays.BeatmapListing +{ + public class BeatmapListingHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new BeatmapListingTitle(); + + private class BeatmapListingTitle : OverlayTitle + { + public BeatmapListingTitle() + { + Title = "beatmap listing"; + IconTexture = "Icons/changelog"; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs new file mode 100644 index 0000000000..29c4fe0d2e --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osu.Framework.Bindables; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingSearchControl : CompositeDrawable + { + public Bindable Query => textBox.Current; + + public Bindable Ruleset => modeFilter.Current; + + public Bindable Category => categoryFilter.Current; + + public Bindable Genre => genreFilter.Current; + + public Bindable Language => languageFilter.Current; + + public BeatmapSetInfo BeatmapSet + { + set + { + if (value == null || string.IsNullOrEmpty(value.OnlineInfo.Covers.Cover)) + { + beatmapCover.FadeOut(600, Easing.OutQuint); + return; + } + + beatmapCover.BeatmapSet = value; + beatmapCover.FadeTo(0.1f, 200, Easing.OutQuint); + } + } + + private readonly BeatmapSearchTextBox textBox; + private readonly BeatmapSearchRulesetFilterRow modeFilter; + private readonly BeatmapSearchFilterRow categoryFilter; + private readonly BeatmapSearchFilterRow genreFilter; + private readonly BeatmapSearchFilterRow languageFilter; + + private readonly Box background; + private readonly UpdateableBeatmapSetCover beatmapCover; + + public BeatmapListingSearchControl() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = beatmapCover = new UpdateableBeatmapSetCover + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + } + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding + { + Vertical = 20, + Horizontal = 40, + }, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + textBox = new BeatmapSearchTextBox + { + RelativeSizeAxes = Axes.X, + }, + new ReverseChildIDFillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = 10 }, + Children = new Drawable[] + { + modeFilter = new BeatmapSearchRulesetFilterRow(), + categoryFilter = new BeatmapSearchFilterRow(@"Categories"), + genreFilter = new BeatmapSearchFilterRow(@"Genre"), + languageFilter = new BeatmapSearchFilterRow(@"Language"), + } + } + } + } + } + }); + + categoryFilter.Current.Value = SearchCategory.Leaderboard; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark6; + } + + public void TakeFocus() => textBox.TakeFocus(); + + private class BeatmapSearchTextBox : SearchTextBox + { + protected override Color4 SelectionColour => Color4.Gray; + + public BeatmapSearchTextBox() + { + PlaceholderText = @"type in keywords..."; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs new file mode 100644 index 0000000000..4c77a736ac --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics; +using osuTK.Graphics; +using osuTK; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingSortTabControl : OverlaySortTabControl + { + public readonly Bindable SortDirection = new Bindable(Overlays.SortDirection.Descending); + + public BeatmapListingSortTabControl() + { + Current.Value = SortCriteria.Ranked; + } + + protected override SortTabControl CreateControl() => new BeatmapSortTabControl + { + SortDirection = { BindTarget = SortDirection } + }; + + private class BeatmapSortTabControl : SortTabControl + { + public readonly Bindable SortDirection = new Bindable(); + + protected override TabItem CreateTabItem(SortCriteria value) => new BeatmapSortTabItem(value) + { + SortDirection = { BindTarget = SortDirection } + }; + } + + private class BeatmapSortTabItem : SortTabItem + { + public readonly Bindable SortDirection = new Bindable(); + + public BeatmapSortTabItem(SortCriteria value) + : base(value) + { + } + + protected override TabButton CreateTabButton(SortCriteria value) => new BeatmapTabButton(value) + { + Active = { BindTarget = Active }, + SortDirection = { BindTarget = SortDirection } + }; + } + + private class BeatmapTabButton : TabButton + { + public readonly Bindable SortDirection = new Bindable(); + + protected override Color4 ContentColour + { + set + { + base.ContentColour = value; + icon.Colour = value; + } + } + + private readonly SpriteIcon icon; + + public BeatmapTabButton(SortCriteria value) + : base(value) + { + Add(icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysPresent = true, + Alpha = 0, + Size = new Vector2(6) + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SortDirection.BindValueChanged(direction => + { + icon.Icon = direction.NewValue == Overlays.SortDirection.Ascending ? FontAwesome.Solid.CaretUp : FontAwesome.Solid.CaretDown; + }, true); + } + + protected override void UpdateState() + { + base.UpdateState(); + icon.FadeTo(Active.Value || IsHovered ? 1 : 0, 200, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + if (Active.Value) + SortDirection.Value = SortDirection.Value == Overlays.SortDirection.Ascending ? Overlays.SortDirection.Descending : Overlays.SortDirection.Ascending; + + return base.OnClick(e); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs new file mode 100644 index 0000000000..45ef793deb --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; +using Humanizer; +using osu.Game.Utils; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchFilterRow : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public BeatmapSearchFilterRow(string headerName) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + AddInternal(new GridContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 100), + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 13), + Text = headerName.Titleize() + }, + CreateFilter().With(f => + { + f.Current = current; + }) + } + } + }); + } + + [NotNull] + protected virtual BeatmapSearchFilter CreateFilter() => new BeatmapSearchFilter(); + + protected class BeatmapSearchFilter : TabControl + { + public BeatmapSearchFilter() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.X; + Height = 15; + + TabContainer.Spacing = new Vector2(10, 0); + + if (typeof(T).IsEnum) + { + foreach (var val in OrderAttributeUtils.GetValuesInOrder()) + AddItem(val); + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + if (Dropdown is FilterDropdown fd) + fd.AccentColour = colourProvider.Light2; + } + + protected override Dropdown CreateDropdown() => new FilterDropdown(); + + protected override TabItem CreateTabItem(T value) => new FilterTabItem(value); + + protected class FilterTabItem : TabItem + { + protected virtual float TextSize => 13; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly OsuSpriteText text; + + public FilterTabItem(T value) + : base(value) + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + AddRangeInternal(new Drawable[] + { + text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Regular), + Text = (value as Enum)?.GetDescription() ?? value.ToString() + }, + new HoverClickSounds() + }); + + Enabled.Value = true; + } + + [BackgroundDependencyLoader] + private void load() + { + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + private void updateState() => text.FadeColour(Active.Value ? Color4.White : getStateColour(), 200, Easing.OutQuint); + + private Color4 getStateColour() => IsHovered ? colourProvider.Light1 : colourProvider.Light3; + } + + private class FilterDropdown : OsuTabDropdown + { + protected override DropdownHeader CreateHeader() => new FilterHeader + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }; + + private class FilterHeader : OsuTabDropdownHeader + { + public FilterHeader() + { + Background.Height = 1; + } + } + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs new file mode 100644 index 0000000000..eebd896cf9 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.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.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchRulesetFilterRow : BeatmapSearchFilterRow + { + public BeatmapSearchRulesetFilterRow() + : base(@"Mode") + { + } + + protected override BeatmapSearchFilter CreateFilter() => new RulesetFilter(); + + private class RulesetFilter : BeatmapSearchFilter + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + AddItem(new RulesetInfo + { + Name = @"Any" + }); + + foreach (var r in rulesets.AvailableRulesets) + AddItem(r); + } + } + } +} diff --git a/osu.Game/Overlays/Direct/DirectPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs similarity index 96% rename from osu.Game/Overlays/Direct/DirectPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index 4ad8e95512..88c15776cd 100644 --- a/osu.Game/Overlays/Direct/DirectPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -26,9 +26,9 @@ using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public abstract class DirectPanel : OsuClickableContainer, IHasContextMenu + public abstract class BeatmapPanel : OsuClickableContainer, IHasContextMenu { public readonly BeatmapSetInfo SetInfo; @@ -49,7 +49,7 @@ namespace osu.Game.Overlays.Direct protected Action ViewBeatmap; - protected DirectPanel(BeatmapSetInfo setInfo) + protected BeatmapPanel(BeatmapSetInfo setInfo) { Debug.Assert(setInfo.OnlineBeatmapSetID != null); @@ -148,7 +148,7 @@ namespace osu.Game.Overlays.Direct if (SetInfo.Beatmaps.Count > maximum_difficulty_icons) { foreach (var ruleset in SetInfo.Beatmaps.Select(b => b.Ruleset).Distinct()) - icons.Add(new GroupedDifficultyIcon(SetInfo.Beatmaps.FindAll(b => b.Ruleset.Equals(ruleset)), ruleset, this is DirectListPanel ? Color4.White : colours.Gray5)); + icons.Add(new GroupedDifficultyIcon(SetInfo.Beatmaps.FindAll(b => b.Ruleset.Equals(ruleset)), ruleset, this is ListBeatmapPanel ? Color4.White : colours.Gray5)); } else { diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs new file mode 100644 index 0000000000..001ca801d9 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -0,0 +1,99 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; + +namespace osu.Game.Overlays.BeatmapListing.Panels +{ + public class BeatmapPanelDownloadButton : BeatmapDownloadTrackingComposite + { + protected bool DownloadEnabled => button.Enabled.Value; + + /// + /// Currently selected beatmap. Used to present the correct difficulty after completing a download. + /// + public readonly IBindable SelectedBeatmap = new Bindable(); + + private readonly ShakeContainer shakeContainer; + private readonly DownloadButton button; + private Bindable noVideoSetting; + + public BeatmapPanelDownloadButton(BeatmapSetInfo beatmapSet) + : base(beatmapSet) + { + InternalChild = shakeContainer = new ShakeContainer + { + RelativeSizeAxes = Axes.Both, + Child = button = new DownloadButton + { + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + button.State.BindTo(State); + FinishTransforms(true); + } + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, BeatmapManager beatmaps, OsuConfigManager osuConfig) + { + noVideoSetting = osuConfig.GetBindable(OsuSetting.PreferNoVideo); + + button.Action = () => + { + switch (State.Value) + { + case DownloadState.Downloading: + case DownloadState.Downloaded: + shakeContainer.Shake(); + break; + + case DownloadState.LocallyAvailable: + Predicate findPredicate = null; + if (SelectedBeatmap.Value != null) + findPredicate = b => b.OnlineBeatmapID == SelectedBeatmap.Value.OnlineBeatmapID; + + game?.PresentBeatmap(BeatmapSet.Value, findPredicate); + break; + + default: + beatmaps.Download(BeatmapSet.Value, noVideoSetting.Value); + break; + } + }; + + State.BindValueChanged(state => + { + switch (state.NewValue) + { + case DownloadState.LocallyAvailable: + button.Enabled.Value = true; + button.TooltipText = "Go to beatmap"; + break; + + default: + if (BeatmapSet.Value?.OnlineInfo?.Availability?.DownloadDisabled ?? false) + { + button.Enabled.Value = false; + button.TooltipText = "this beatmap is currently not available for download."; + } + + break; + } + }, true); + } + } +} diff --git a/osu.Game/Overlays/Direct/DownloadProgressBar.cs b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs similarity index 86% rename from osu.Game/Overlays/Direct/DownloadProgressBar.cs rename to osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs index a6cefaae84..93cf8799b5 100644 --- a/osu.Game/Overlays/Direct/DownloadProgressBar.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class DownloadProgressBar : BeatmapDownloadTrackingComposite { @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Direct public DownloadProgressBar(BeatmapSetInfo beatmapSet) : base(beatmapSet) { - AddInternal(progressBar = new ProgressBar + AddInternal(progressBar = new InteractionDisabledProgressBar { Height = 0, Alpha = 0, @@ -64,5 +64,11 @@ namespace osu.Game.Overlays.Direct } }, true); } + + private class InteractionDisabledProgressBar : ProgressBar + { + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + } } } diff --git a/osu.Game/Overlays/Direct/DirectGridPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs similarity index 96% rename from osu.Game/Overlays/Direct/DirectGridPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index 2528ccec41..28c36e6c56 100644 --- a/osu.Game/Overlays/Direct/DirectGridPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -1,25 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public class DirectGridPanel : DirectPanel + public class GridBeatmapPanel : BeatmapPanel { private const float horizontal_padding = 10; private const float vertical_padding = 5; @@ -31,11 +31,11 @@ namespace osu.Game.Overlays.Direct protected override PlayButton PlayButton => playButton; protected override Box PreviewBar => progressBar; - public DirectGridPanel(BeatmapSetInfo beatmap) + public GridBeatmapPanel(BeatmapSetInfo beatmap) : base(beatmap) { Width = 380; - Height = 140 + vertical_padding; //full height of all the elements plus vertical padding (autosize uses the image) + Height = 140 + vertical_padding; // full height of all the elements plus vertical padding (autosize uses the image) } protected override void LoadComplete() @@ -156,7 +156,7 @@ namespace osu.Game.Overlays.Direct }, }, }, - new PanelDownloadButton(SetInfo) + new BeatmapPanelDownloadButton(SetInfo) { Size = new Vector2(50, 30), Margin = new MarginPadding(horizontal_padding), diff --git a/osu.Game/Overlays/Direct/IconPill.cs b/osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs similarity index 96% rename from osu.Game/Overlays/Direct/IconPill.cs rename to osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs index d63bb2a292..1cb6c84f13 100644 --- a/osu.Game/Overlays/Direct/IconPill.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Sprites; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class IconPill : CircularContainer { diff --git a/osu.Game/Overlays/Direct/DirectListPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs similarity index 97% rename from osu.Game/Overlays/Direct/DirectListPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index b64142dfe7..433ea37f06 100644 --- a/osu.Game/Overlays/Direct/DirectListPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -1,25 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; -using osuTK.Graphics; +using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Colour; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Allocation; +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.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public class DirectListPanel : DirectPanel + public class ListBeatmapPanel : BeatmapPanel { private const float transition_duration = 120; private const float horizontal_padding = 10; @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Direct private const float height = 70; private FillFlowContainer statusContainer; - protected PanelDownloadButton DownloadButton; + protected BeatmapPanelDownloadButton DownloadButton; private PlayButton playButton; private Box progressBar; @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Direct protected override PlayButton PlayButton => playButton; protected override Box PreviewBar => progressBar; - public DirectListPanel(BeatmapSetInfo beatmap) + public ListBeatmapPanel(BeatmapSetInfo beatmap) : base(beatmap) { RelativeSizeAxes = Axes.X; @@ -151,7 +151,7 @@ namespace osu.Game.Overlays.Direct Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, - Child = DownloadButton = new PanelDownloadButton(SetInfo) + Child = DownloadButton = new BeatmapPanelDownloadButton(SetInfo) { Size = new Vector2(height - vertical_padding * 3), Margin = new MarginPadding { Left = vertical_padding * 2, Right = vertical_padding }, diff --git a/osu.Game/Overlays/Direct/PlayButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs similarity index 90% rename from osu.Game/Overlays/Direct/PlayButton.cs rename to osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs index 2a77e7ca26..e95fdeecf4 100644 --- a/osu.Game/Overlays/Direct/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class PlayButton : Container { @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Direct private Color4 hoverColour; private readonly SpriteIcon icon; - private readonly LoadingAnimation loadingAnimation; + private readonly LoadingSpinner loadingSpinner; private const float transition_duration = 500; @@ -53,12 +53,12 @@ namespace osu.Game.Overlays.Direct if (value) { icon.FadeTo(0.5f, transition_duration, Easing.OutQuint); - loadingAnimation.Show(); + loadingSpinner.Show(); } else { icon.FadeTo(1, transition_duration, Easing.OutQuint); - loadingAnimation.Hide(); + loadingSpinner.Hide(); } } } @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Direct RelativeSizeAxes = Axes.Both, Icon = FontAwesome.Solid.Play, }, - loadingAnimation = new LoadingAnimation + loadingSpinner = new LoadingSpinner { Size = new Vector2(15), }, @@ -85,13 +85,12 @@ namespace osu.Game.Overlays.Direct Playing.ValueChanged += playingStateChanged; } - private PreviewTrackManager previewTrackManager; + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colour, PreviewTrackManager previewTrackManager) + private void load(OsuColour colour) { - this.previewTrackManager = previewTrackManager; - hoverColour = colour.Yellow; } diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs new file mode 100644 index 0000000000..84859bf5b5 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchCategory + { + Any, + + [Description("Has Leaderboard")] + Leaderboard, + Ranked, + Qualified, + Loved, + Favourites, + + [Description("Pending & WIP")] + Pending, + Graveyard, + + [Description("My Maps")] + Mine, + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs new file mode 100644 index 0000000000..de437fac3e --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchGenre + { + Any = 0, + Unspecified = 1, + + [Description("Video Game")] + VideoGame = 2, + Anime = 3, + Rock = 4, + Pop = 5, + Other = 6, + Novelty = 7, + + [Description("Hip Hop")] + HipHop = 9, + Electronic = 10, + Metal = 11, + Classical = 12, + Folk = 13, + Jazz = 14 + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs new file mode 100644 index 0000000000..eee5d8f7e1 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.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 osu.Game.Utils; + +namespace osu.Game.Overlays.BeatmapListing +{ + [HasOrderedElements] + public enum SearchLanguage + { + [Order(0)] + Any, + + [Order(14)] + Unspecified, + + [Order(1)] + English, + + [Order(6)] + Japanese, + + [Order(2)] + Chinese, + + [Order(12)] + Instrumental, + + [Order(7)] + Korean, + + [Order(3)] + French, + + [Order(4)] + German, + + [Order(9)] + Swedish, + + [Order(8)] + Spanish, + + [Order(5)] + Italian, + + [Order(10)] + Russian, + + [Order(11)] + Polish, + + [Order(13)] + Other + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs new file mode 100644 index 0000000000..e409cbdda7 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.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.Overlays.BeatmapListing +{ + public enum SortCriteria + { + Title, + Artist, + Difficulty, + Ranked, + Rating, + Plays, + Favourites, + Relevance + } +} diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs new file mode 100644 index 0000000000..225a8a0578 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListingOverlay.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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.BeatmapListing; +using osu.Game.Overlays.BeatmapListing.Panels; +using osuTK; + +namespace osu.Game.Overlays +{ + public class BeatmapListingOverlay : FullscreenOverlay + { + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } + + private Drawable currentContent; + private LoadingLayer loadingLayer; + private Container panelTarget; + private FillFlowContainer foundContent; + private NotFoundDrawable notFoundContent; + + private OverlayScrollContainer resultScrollContainer; + + public BeatmapListingOverlay() + : base(OverlayColourScheme.Blue) + { + } + + private BeatmapListingFilterControl filterControl; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6 + }, + resultScrollContainer = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new ReverseChildIDFillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new BeatmapListingHeader(), + filterControl = new BeatmapListingFilterControl + { + SearchStarted = onSearchStarted, + SearchFinished = onSearchFinished, + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background4, + }, + panelTarget = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 20 }, + Children = new Drawable[] + { + foundContent = new FillFlowContainer(), + notFoundContent = new NotFoundDrawable(), + loadingLayer = new LoadingLayer(panelTarget) + } + } + } + }, + } + } + } + }; + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + filterControl.TakeFocus(); + } + + private CancellationTokenSource cancellationToken; + + private void onSearchStarted() + { + cancellationToken?.Cancel(); + + previewTrackManager.StopAnyPlaying(this); + + if (panelTarget.Any()) + loadingLayer.Show(); + } + + private Task panelLoadDelegate; + + private void onSearchFinished(List beatmaps) + { + var newPanels = beatmaps.Select(b => new GridBeatmapPanel(b) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); + + if (filterControl.CurrentPage == 0) + { + //No matches case + if (!newPanels.Any()) + { + LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + return; + } + + // spawn new children with the contained so we only clear old content at the last moment. + var content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Alpha = 0, + Margin = new MarginPadding { Vertical = 15 }, + ChildrenEnumerable = newPanels + }; + + panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + else + { + panelLoadDelegate = LoadComponentsAsync(newPanels, loaded => + { + lastFetchDisplayedTime = Time.Current; + foundContent.AddRange(loaded); + loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint)); + }); + } + } + + private void addContentToPlaceholder(Drawable content) + { + loadingLayer.Hide(); + lastFetchDisplayedTime = Time.Current; + + var lastContent = currentContent; + + if (lastContent != null) + { + lastContent.FadeOut(100, Easing.OutQuint).Expire(); + + // Consider the case when the new content is smaller than the last content. + // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. + // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. + // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent)); + } + + if (!content.IsAlive) + panelTarget.Add(content); + content.FadeIn(200, Easing.OutQuint); + + currentContent = content; + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + + private class NotFoundDrawable : CompositeDrawable + { + public NotFoundDrawable() + { + RelativeSizeAxes = Axes.X; + Height = 250; + Alpha = 0; + Margin = new MarginPadding { Top = 15 }; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddInternal(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get(@"Online/not-found") + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = @"... nope, nothing found.", + } + } + }); + } + } + + private const double time_between_fetches = 500; + + private double lastFetchDisplayedTime; + + protected override void Update() + { + base.Update(); + + const int pagination_scroll_distance = 500; + + bool shouldShowMore = panelLoadDelegate?.IsCompleted != false + && Time.Current - lastFetchDisplayedTime > time_between_fetches + && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance)); + + if (shouldShowMore) + filterControl.FetchNextPage(); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 096e91b65b..31c1439c8f 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.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.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -50,7 +51,7 @@ namespace osu.Game.Overlays.BeatmapSet fields.Children = new Drawable[] { new Field("mapped by", BeatmapSet.Metadata.Author.Username, OsuFont.GetFont(weight: FontWeight.Regular, italics: true)), - new Field("submitted on", online.Submitted.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold)) + new Field("submitted", online.Submitted, OsuFont.GetFont(weight: FontWeight.Bold)) { Margin = new MarginPadding { Top = 5 }, }, @@ -58,11 +59,11 @@ namespace osu.Game.Overlays.BeatmapSet if (online.Ranked.HasValue) { - fields.Add(new Field("ranked on", online.Ranked.Value.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold))); + fields.Add(new Field(online.Status.ToString().ToLowerInvariant(), online.Ranked.Value, OsuFont.GetFont(weight: FontWeight.Bold))); } else if (online.LastUpdated.HasValue) { - fields.Add(new Field("last updated on", online.LastUpdated.Value.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold))); + fields.Add(new Field("last updated", online.LastUpdated.Value, OsuFont.GetFont(weight: FontWeight.Bold))); } } @@ -76,7 +77,7 @@ namespace osu.Game.Overlays.BeatmapSet new Container { AutoSizeAxes = Axes.Both, - CornerRadius = 3, + CornerRadius = 4, Masking = true, Child = avatar = new UpdateableAvatar { @@ -87,7 +88,7 @@ namespace osu.Game.Overlays.BeatmapSet { Colour = Color4.Black.Opacity(0.25f), Type = EdgeEffectType.Shadow, - Radius = 3, + Radius = 4, Offset = new Vector2(0f, 1f), }, }, @@ -117,15 +118,34 @@ namespace osu.Game.Overlays.BeatmapSet new OsuSpriteText { Text = $"{first} ", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 11) }, new OsuSpriteText { Text = second, - Font = secondFont.With(size: 13) + Font = secondFont.With(size: 11) }, }; } + + public Field(string first, DateTimeOffset second, FontUsage secondFont) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + + Children = new[] + { + new OsuSpriteText + { + Text = $"{first} ", + Font = OsuFont.GetFont(size: 13) + }, + new DrawableDate(second) + { + Font = secondFont.With(size: 13) + } + }; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index 7092b860a0..a2464bef09 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -105,7 +106,7 @@ namespace osu.Game.Overlays.BeatmapSet { TooltipText = name; RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; + Height = 24f; Children = new Drawable[] { @@ -113,7 +114,8 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, Children = new Drawable[] { new SpriteIcon @@ -121,17 +123,17 @@ namespace osu.Game.Overlays.BeatmapSet Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, Icon = FontAwesome.Solid.Square, - Size = new Vector2(13), + Size = new Vector2(12), Rotation = 45, - Colour = OsuColour.FromHex(@"441288"), + Colour = Color4Extensions.FromHex(@"441288"), }, new SpriteIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, Icon = icon, - Size = new Vector2(13), - Colour = OsuColour.FromHex(@"f7dd55"), + Size = new Vector2(12), + Colour = Color4Extensions.FromHex(@"f7dd55"), Scale = new Vector2(0.8f), }, value = new OsuSpriteText @@ -139,7 +141,7 @@ namespace osu.Game.Overlays.BeatmapSet Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding { Left = 10 }, - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), }, }, }, diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index bf2a92cd4f..66886b0882 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework; 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; @@ -19,7 +18,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { @@ -34,7 +32,6 @@ namespace osu.Game.Overlays.BeatmapSet public readonly DifficultiesContainer Difficulties; public readonly Bindable Beatmap = new Bindable(); - private BeatmapSetInfo beatmapSet; public BeatmapSetInfo BeatmapSet @@ -67,7 +64,7 @@ namespace osu.Game.Overlays.BeatmapSet { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2) }, + Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2), Bottom = 10 }, OnLostHover = () => { showBeatmap(Beatmap.Value); @@ -77,7 +74,6 @@ namespace osu.Game.Overlays.BeatmapSet new FillFlowContainer { AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 10 }, Spacing = new Vector2(5f), Children = new[] { @@ -85,13 +81,13 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold) }, starRating = new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), Text = "Star Difficulty", Alpha = 0, Margin = new MarginPadding { Bottom = 1 }, @@ -192,9 +188,11 @@ namespace osu.Game.Overlays.BeatmapSet public class DifficultySelectorButton : OsuClickableContainer, IStateful { private const float transition_duration = 100; - private const float size = 52; + private const float size = 54; + private const float background_size = size - 2; - private readonly Container bg; + private readonly Container background; + private readonly Box backgroundBox; private readonly DifficultyIcon icon; public readonly BeatmapInfo Beatmap; @@ -230,16 +228,16 @@ namespace osu.Game.Overlays.BeatmapSet Children = new Drawable[] { - bg = new Container + background = new Container { - RelativeSizeAxes = Axes.Both, + Size = new Vector2(background_size), Masking = true, CornerRadius = 4, - Child = new Box + Child = backgroundBox = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, + Alpha = 0.5f + } }, icon = new DifficultyIcon(beatmap, shouldShowTooltip: false) { @@ -273,15 +271,21 @@ namespace osu.Game.Overlays.BeatmapSet private void fadeIn() { - bg.FadeIn(transition_duration); + background.FadeIn(transition_duration); icon.FadeIn(transition_duration); } private void fadeOut() { - bg.FadeOut(); + background.FadeOut(); icon.FadeTo(0.7f, transition_duration); } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + backgroundBox.Colour = colourProvider.Background6; + } } private class Statistic : FillFlowContainer @@ -314,13 +318,13 @@ namespace osu.Game.Overlays.BeatmapSet Origin = Anchor.CentreLeft, Icon = icon, Shadow = true, - Size = new Vector2(13), + Size = new Vector2(12), }, text = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold, italics: true), }, }; } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs index a0bedc848e..005d21726b 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs @@ -2,17 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osuTK; using System.Linq; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapRulesetSelector : RulesetSelector + public class BeatmapRulesetSelector : OverlayRulesetSelector { private readonly Bindable beatmapSet = new Bindable(); @@ -28,21 +25,9 @@ namespace osu.Game.Overlays.BeatmapSet } } - public BeatmapRulesetSelector() - { - AutoSizeAxes = Axes.Both; - } - protected override TabItem CreateTabItem(RulesetInfo value) => new BeatmapRulesetTabItem(value) { BeatmapSet = { BindTarget = beatmapSet } }; - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - }; } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs index cdea49afe7..cb258edced 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs @@ -3,143 +3,74 @@ 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.UserInterface; -using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; using System.Linq; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapRulesetTabItem : TabItem + public class BeatmapRulesetTabItem : OverlayRulesetTabItem { - private readonly OsuSpriteText name, count; - private readonly Box bar; - public readonly Bindable BeatmapSet = new Bindable(); - public override bool PropagatePositionalInputSubTree => Enabled.Value && !Active.Value && base.PropagatePositionalInputSubTree; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private OsuSpriteText count; + private Container countContainer; public BeatmapRulesetTabItem(RulesetInfo value) : base(value) { - AutoSizeAxes = Axes.Both; + } - FillFlowContainer nameContainer; - - Children = new Drawable[] + [BackgroundDependencyLoader] + private void load() + { + Add(countContainer = new Container { - nameContainer = new FillFlowContainer + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 4f, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Bottom = 7.5f }, - Spacing = new Vector2(2.5f), - Children = new Drawable[] + new Box { - name = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = value.Name, - Font = OsuFont.Default.With(size: 18), - }, - new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 4f, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, - count = new OsuSpriteText - { - Alpha = 0, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Horizontal = 5f }, - Font = OsuFont.Default.With(weight: FontWeight.SemiBold), - } - } - } + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + count = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 5f }, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Foreground1, } - }, - bar = new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - }, - new HoverClickSounds(), - }; + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); BeatmapSet.BindValueChanged(setInfo => { var beatmapsCount = setInfo.NewValue?.Beatmaps.Count(b => b.Ruleset.Equals(Value)) ?? 0; count.Text = beatmapsCount.ToString(); - count.Alpha = beatmapsCount > 0 ? 1f : 0f; + countContainer.FadeTo(beatmapsCount > 0 ? 1 : 0); Enabled.Value = beatmapsCount > 0; }, true); - - Enabled.BindValueChanged(v => nameContainer.Alpha = v.NewValue ? 1f : 0.5f, true); } - - [Resolved] - private OsuColour colour { get; set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - count.Colour = colour.Gray9; - bar.Colour = colour.Blue; - - updateState(); - } - - private void updateState() - { - var isHoveredOrActive = IsHovered || Active.Value; - - bar.ResizeHeightTo(isHoveredOrActive ? 4 : 0, 200, Easing.OutQuint); - - name.Colour = isHoveredOrActive ? colour.GrayE : colour.GrayC; - name.Font = name.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Regular); - } - - #region Hovering and activation logic - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return false; - } - - protected override void OnHoverLost(HoverLostEvent e) => updateState(); - - #endregion } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs new file mode 100644 index 0000000000..4626589d81 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapSetHeader : OverlayHeader + { + public readonly Bindable Ruleset = new Bindable(); + + public BeatmapRulesetSelector RulesetSelector { get; private set; } + + protected override OverlayTitle CreateTitle() => new BeatmapHeaderTitle(); + + protected override Drawable CreateTitleContent() => RulesetSelector = new BeatmapRulesetSelector + { + Current = Ruleset + }; + + private class BeatmapHeaderTitle : OverlayTitle + { + public BeatmapHeaderTitle() + { + Title = "beatmap info"; + IconTexture = "Icons/changelog"; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs new file mode 100644 index 0000000000..e6d433f7bc --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapSetLayoutSection : Container + { + public BeatmapSetLayoutSection() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 3, + Offset = new Vector2(0f, 1f), + }; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index af0987d183..742b1055b2 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -5,7 +5,6 @@ using System.Diagnostics; 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.Sprites; using osu.Game.Beatmaps; @@ -25,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly BindableBool favourited = new BindableBool(); private PostBeatmapFavouriteRequest request; - private DimmedLoadingLayer loading; + private LoadingLayer loading; private readonly Bindable localUser = new Bindable(); @@ -54,14 +53,11 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Size = new Vector2(18), Shadow = false, }, - loading = new DimmedLoadingLayer(0.8f, 0.5f), + loading = new LoadingLayer(icon, false), }); Action = () => { - if (loading.State.Value == Visibility.Visible) - return; - // guaranteed by disabled state above. Debug.Assert(BeatmapSet.Value.OnlineBeatmapSetID != null); diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs index 6de1d3fca7..99b0b2ed3b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs @@ -2,8 +2,8 @@ // 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.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.BeatmapSet.Buttons @@ -19,9 +19,9 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons [BackgroundDependencyLoader] private void load() { - BackgroundColour = OsuColour.FromHex(@"094c5f"); - Triangles.ColourLight = OsuColour.FromHex(@"0f7c9b"); - Triangles.ColourDark = OsuColour.FromHex(@"094c5f"); + 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 fe10287491..56c0052bfe 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -13,7 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Users; using osuTK; using osuTK.Graphics; @@ -22,9 +22,11 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { public class HeaderDownloadButton : BeatmapDownloadTrackingComposite, IHasTooltip { + private const int text_size = 12; + private readonly bool noVideo; - public string TooltipText => button.Enabled.Value ? "Download this beatmap" : "Login to download"; + public string TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; private readonly IBindable localUser = new Bindable(); @@ -80,8 +82,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Icon = FontAwesome.Solid.Download, - Size = new Vector2(16), - Margin = new MarginPadding { Right = 5 }, + Size = new Vector2(18), }, } }, @@ -120,7 +121,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Downloading...", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; break; @@ -131,7 +132,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Importing...", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; break; @@ -146,12 +147,12 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Download", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, new OsuSpriteText { - Text = BeatmapSet.Value.OnlineInfo.HasVideo && noVideo ? "without Video" : string.Empty, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold) + Text = getVideoSuffixText(), + Font = OsuFont.GetFont(size: text_size - 2, weight: FontWeight.Bold) }, }; this.FadeIn(200); @@ -163,5 +164,13 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private void userChanged(ValueChangedEvent e) => button.Enabled.Value = !(e.NewValue is GuestUser); private void enabledChanged(ValueChangedEvent e) => this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); + + private string getVideoSuffixText() + { + if (!BeatmapSet.Value.OnlineInfo.HasVideo) + return string.Empty; + + return noVideo ? "without Video" : "with Video"; + } } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs index 8c884e0950..6accce7d77 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs @@ -3,7 +3,6 @@ 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; @@ -12,9 +11,8 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet.Buttons { @@ -22,7 +20,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { private const float transition_duration = 500; - private readonly Box bg, progress; + private readonly Box background, progress; private readonly PlayButton playButton; private PreviewTrack preview => playButton.Preview; @@ -40,10 +38,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Children = new Drawable[] { - bg = new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.25f), + Alpha = 0.5f }, new Container { @@ -71,9 +69,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { progress.Colour = colours.Yellow; + background.Colour = colourProvider.Background6; } protected override void Update() @@ -91,13 +90,13 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons protected override bool OnHover(HoverEvent e) { - bg.FadeColour(Color4.Black.Opacity(0.5f), 100); + background.FadeTo(0.75f, 80); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - bg.FadeColour(Color4.Black.Opacity(0.25f), 100); + background.FadeTo(0.5f, 80); base.OnHoverLost(e); } } diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index d76f6a43db..680487ffbb 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -10,7 +9,6 @@ using osu.Game.Beatmaps; using osu.Game.Overlays.BeatmapSet.Buttons; using osu.Game.Screens.Select.Details; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { @@ -21,6 +19,7 @@ namespace osu.Game.Overlays.BeatmapSet private readonly PreviewButton preview; private readonly BasicStats basic; private readonly AdvancedStats advanced; + private readonly DetailBox ratingBox; private BeatmapSetInfo beatmapSet; @@ -54,6 +53,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDisplay() { Ratings.Metrics = BeatmapSet?.Metrics; + ratingBox.Alpha = BeatmapSet?.OnlineInfo?.Status > 0 ? 1 : 0; } public Details() @@ -74,7 +74,7 @@ namespace osu.Game.Overlays.BeatmapSet { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Vertical = 10 }, + Padding = new MarginPadding { Vertical = 10 } }, }, new DetailBox @@ -86,7 +86,7 @@ namespace osu.Game.Overlays.BeatmapSet Margin = new MarginPadding { Vertical = 7.5f }, }, }, - new DetailBox + ratingBox = new DetailBox { Child = Ratings = new UserRatings { @@ -107,6 +107,8 @@ namespace osu.Game.Overlays.BeatmapSet private class DetailBox : Container { private readonly Container content; + private readonly Box background; + protected override Container Content => content; public DetailBox() @@ -116,10 +118,10 @@ namespace osu.Game.Overlays.BeatmapSet InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), + Alpha = 0.5f }, content = new Container { @@ -129,6 +131,12 @@ namespace osu.Game.Overlays.BeatmapSet }, }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background6; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/Header.cs b/osu.Game/Overlays/BeatmapSet/Header.cs index 7b42e7e459..06e31277dd 100644 --- a/osu.Game/Overlays/BeatmapSet/Header.cs +++ b/osu.Game/Overlays/BeatmapSet/Header.cs @@ -15,8 +15,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Overlays.BeatmapSet.Buttons; -using osu.Game.Overlays.Direct; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -26,12 +26,11 @@ namespace osu.Game.Overlays.BeatmapSet public class Header : BeatmapDownloadTrackingComposite { private const float transition_duration = 200; - private const float tabs_height = 50; private const float buttons_height = 45; private const float buttons_spacing = 5; - private readonly Box tabsBg; private readonly UpdateableBeatmapSetCover cover; + private readonly Box coverGradient; private readonly OsuSpriteText title, artist; private readonly AuthorInfo author; private readonly FillFlowContainer downloadButtonsContainer; @@ -41,14 +40,13 @@ namespace osu.Game.Overlays.BeatmapSet public bool DownloadButtonsVisible => downloadButtonsContainer.Any(); - public readonly BeatmapRulesetSelector RulesetSelector; + public BeatmapRulesetSelector RulesetSelector => beatmapSetHeader.RulesetSelector; public readonly BeatmapPicker Picker; private readonly FavouriteButton favouriteButton; - private readonly FillFlowContainer fadeContent; - - private readonly LoadingAnimation loading; + private readonly LoadingSpinner loading; + private readonly BeatmapSetHeader beatmapSetHeader; [Cached(typeof(IBindable))] private readonly Bindable ruleset = new Bindable(); @@ -69,154 +67,147 @@ namespace osu.Game.Overlays.BeatmapSet Offset = new Vector2(0f, 1f), }; - InternalChildren = new Drawable[] + InternalChild = new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = tabs_height, - Children = new Drawable[] + beatmapSetHeader = new BeatmapSetHeader { - tabsBg = new Box - { - RelativeSizeAxes = Axes.Both, - }, - RulesetSelector = new BeatmapRulesetSelector - { - Current = ruleset, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - } + Ruleset = { BindTarget = ruleset }, }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Top = tabs_height }, - Children = new Drawable[] + new Container { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + new Container { - cover = new UpdateableBeatmapSetCover + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0.8f)), - }, - }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Top = 20, - Bottom = 30, - Left = BeatmapSetOverlay.X_PADDING, - Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, - }, - Children = new Drawable[] - { - fadeContent = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + cover = new UpdateableBeatmapSetCover { - new Container + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + coverGradient = new Box + { + RelativeSizeAxes = Axes.Both + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = BeatmapSetOverlay.Y_PADDING, + Left = BeatmapSetOverlay.X_PADDING, + Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + }, + Children = new Drawable[] + { + fadeContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = Picker = new BeatmapPicker(), - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + new Container { - title = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 37, weight: FontWeight.Bold, italics: true) - }, - externalLink = new ExternalLinkButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 3, Bottom = 4 }, //To better lineup with the font - }, - } - }, - artist = new OsuSpriteText { Font = OsuFont.GetFont(size: 25, weight: FontWeight.SemiBold, italics: true) }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 20 }, - Child = author = new AuthorInfo(), - }, - beatmapAvailability = new BeatmapAvailability(), - new Container - { - RelativeSizeAxes = Axes.X, - Height = buttons_height, - Margin = new MarginPadding { Top = 10 }, - Children = new Drawable[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Picker = new BeatmapPicker(), + }, + new FillFlowContainer { - favouriteButton = new FavouriteButton + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 15 }, + Children = new Drawable[] { - BeatmapSet = { BindTarget = BeatmapSet } - }, - downloadButtonsContainer = new FillFlowContainer + 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 = 3, Bottom = 4 }, // To better lineup with the font + }, + } + }, + artist = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), + Margin = new MarginPadding { Bottom = 20 } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = author = new AuthorInfo(), + }, + beatmapAvailability = new BeatmapAvailability(), + new Container + { + RelativeSizeAxes = Axes.X, + Height = buttons_height, + Margin = new MarginPadding { Top = 10 }, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, - Spacing = new Vector2(buttons_spacing), + favouriteButton = new FavouriteButton + { + BeatmapSet = { BindTarget = BeatmapSet } + }, + downloadButtonsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, + Spacing = new Vector2(buttons_spacing), + }, }, }, }, }, - }, - } - }, - loading = new LoadingAnimation - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(1.5f), - }, - new FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = BeatmapSetOverlay.TOP_PADDING, Right = BeatmapSetOverlay.X_PADDING }, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), - Children = new Drawable[] + } + }, + loading = new LoadingSpinner { - onlineStatusPill = new BeatmapSetOnlineStatusPill + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + }, + new FillFlowContainer + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = BeatmapSetOverlay.Y_PADDING, Right = BeatmapSetOverlay.X_PADDING }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - TextSize = 14, - TextPadding = new MarginPadding { Horizontal = 25, Vertical = 8 } + onlineStatusPill = new BeatmapSetOnlineStatusPill + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + TextSize = 14, + TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + }, + Details = new Details(), }, - Details = new Details(), }, }, }, - }, + } }; Picker.Beatmap.ValueChanged += b => @@ -227,9 +218,10 @@ namespace osu.Game.Overlays.BeatmapSet } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - tabsBg.Colour = colours.Gray3; + coverGradient.Colour = ColourInfo.GradientVertical(colourProvider.Background6.Opacity(0.3f), colourProvider.Background6.Opacity(0.8f)); + onlineStatusPill.BackgroundColour = colourProvider.Background6; State.BindValueChanged(_ => updateDownloadButtons()); @@ -272,7 +264,7 @@ namespace osu.Game.Overlays.BeatmapSet { if (BeatmapSet.Value == null) return; - if (BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) + if ((BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) && State.Value != DownloadState.LocallyAvailable) { downloadButtonsContainer.Clear(); return; @@ -282,10 +274,11 @@ namespace osu.Game.Overlays.BeatmapSet { case DownloadState.LocallyAvailable: // temporary for UX until new design is implemented. - downloadButtonsContainer.Child = new PanelDownloadButton(BeatmapSet.Value) + downloadButtonsContainer.Child = new BeatmapPanelDownloadButton(BeatmapSet.Value) { Width = 50, - RelativeSizeAxes = Axes.Y + RelativeSizeAxes = Axes.Y, + SelectedBeatmap = { BindTarget = Picker.Beatmap } }; break; diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 16d6236051..bac658b76e 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -3,27 +3,26 @@ 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.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { public class Info : Container { private const float transition_duration = 250; - private const float metadata_width = 225; + private const float metadata_width = 175; private const float spacing = 20; + private const float base_height = 220; private readonly Box successRateBackground; + private readonly Box background; private readonly SuccessRate successRate; public readonly Bindable BeatmapSet = new Bindable(); @@ -37,23 +36,16 @@ namespace osu.Game.Overlays.BeatmapSet public Info() { MetadataSection source, tags, genre, language; + OsuSpriteText unrankedPlaceholder; + RelativeSizeAxes = Axes.X; - Height = 220; - Masking = true; - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.25f), - Type = EdgeEffectType.Shadow, - Radius = 3, - Offset = new Vector2(0f, 1f), - }; + Height = base_height; Children = new Drawable[] { - new Box + background = new Box { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + RelativeSizeAxes = Axes.Both }, new Container { @@ -110,6 +102,14 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 20, Horizontal = 15 }, }, + unrankedPlaceholder = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Text = "Unranked beatmap", + Font = OsuFont.GetFont(size: 12) + }, }, }, }, @@ -122,18 +122,22 @@ namespace osu.Game.Overlays.BeatmapSet tags.Text = b.NewValue?.Metadata.Tags ?? string.Empty; genre.Text = b.NewValue?.OnlineInfo?.Genre?.Name ?? string.Empty; language.Text = b.NewValue?.OnlineInfo?.Language?.Name ?? string.Empty; + var setHasLeaderboard = b.NewValue?.OnlineInfo?.Status > 0; + successRate.Alpha = setHasLeaderboard ? 1 : 0; + unrankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; + Height = setHasLeaderboard ? 270 : base_height; }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - successRateBackground.Colour = colours.GrayE; + successRateBackground.Colour = colourProvider.Background4; + background.Colour = colourProvider.Background5; } private class MetadataSection : FillFlowContainer { - private readonly OsuSpriteText header; private readonly TextFlowContainer textFlow; public string Text @@ -148,7 +152,7 @@ namespace osu.Game.Overlays.BeatmapSet this.FadeIn(transition_duration); textFlow.Clear(); - textFlow.AddText(value, s => s.Font = s.Font.With(size: 14)); + textFlow.AddText(value, s => s.Font = s.Font.With(size: 12)); } } @@ -160,12 +164,11 @@ namespace osu.Game.Overlays.BeatmapSet InternalChildren = new Drawable[] { - header = new OsuSpriteText + new OsuSpriteText { Text = title, Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), - Shadow = false, - Margin = new MarginPadding { Top = 20 }, + Margin = new MarginPadding { Top = 15 }, }, textFlow = new OsuTextFlowContainer { @@ -174,12 +177,6 @@ namespace osu.Game.Overlays.BeatmapSet }, }; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - header.Colour = textFlow.Colour = colours.Gray5; - } } } } diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs index e2a725ec46..607355b7bf 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -3,7 +3,6 @@ using osu.Game.Screens.Select.Leaderboards; using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics; using osu.Framework.Allocation; using osuTK.Graphics; using osu.Framework.Graphics.UserInterface; @@ -26,10 +25,10 @@ namespace osu.Game.Overlays.BeatmapSet } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - AccentColour = colours.Blue; - LineColour = Color4.Gray; + AccentColour = colourProvider.Highlight1; + LineColour = colourProvider.Background1; } private class ScopeSelectorTabItem : PageTabItem @@ -37,7 +36,6 @@ namespace osu.Game.Overlays.BeatmapSet public ScopeSelectorTabItem(BeatmapLeaderboardScope value) : base(value) { - Text.Font = OsuFont.GetFont(size: 16); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs index d263483046..0ae8a8bef5 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs @@ -7,8 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Scoring; using osuTK; using osuTK.Graphics; @@ -17,20 +15,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { public class DrawableTopScore : CompositeDrawable { - private const float fade_duration = 100; - - private Color4 backgroundIdleColour; - private Color4 backgroundHoveredColour; - private readonly Box background; - public DrawableTopScore(ScoreInfo score, int position = 1) + public DrawableTopScore(ScoreInfo score, int? position = 1) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; - CornerRadius = 10; + CornerRadius = 4; EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, @@ -49,7 +42,12 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(10), + Padding = new MarginPadding + { + Vertical = 10, + Left = 10, + Right = 30, + }, Children = new Drawable[] { new AutoSizingGrid @@ -84,24 +82,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - backgroundIdleColour = colours.Gray3; - backgroundHoveredColour = colours.Gray4; - - background.Colour = backgroundIdleColour; - } - - protected override bool OnHover(HoverEvent e) - { - background.FadeColour(backgroundHoveredColour, fade_duration, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - background.FadeColour(backgroundIdleColour, fade_duration, Easing.OutQuint); - base.OnHoverLost(e); + background.Colour = colourProvider.Background4; } private class AutoSizingGrid : GridContainer diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs index ba08a78a61..b2c87a1477 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), + Spacing = new Vector2(0, 20), Children = new Drawable[] { new OsuSpriteText @@ -29,9 +29,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = @"You need to be an osu!supporter to access the friend and country rankings!", - Font = OsuFont.GetFont(weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), }, - text = new LinkFlowContainer(t => t.Font = t.Font.With(size: 12)) + text = new LinkFlowContainer(t => t.Font = t.Font.With(size: 11)) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index f6723839b2..097ca27bf7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -22,8 +22,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public class ScoreTable : TableContainer { private const float horizontal_inset = 20; - private const float row_height = 25; - private const int text_size = 14; + private const float row_height = 22; + private const int text_size = 12; private readonly FillFlowContainer backgroundFlow; @@ -52,22 +52,28 @@ namespace osu.Game.Overlays.BeatmapSet.Scores highAccuracyColour = colours.GreenLight; } - public IReadOnlyList Scores + private bool showPerformancePoints; + + public void DisplayScores(IReadOnlyList scores, bool showPerformanceColumn) { - set - { - Content = null; - backgroundFlow.Clear(); + ClearScores(); - if (value?.Any() != true) - return; + if (!scores.Any()) + return; - for (int i = 0; i < value.Count; i++) - backgroundFlow.Add(new ScoreTableRowBackground(i, value[i])); + showPerformancePoints = showPerformanceColumn; - Columns = createHeaders(value[0]); - Content = value.Select((s, i) => createContent(i, s)).ToArray().ToRectangular(); - } + for (int i = 0; i < scores.Count; i++) + backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height)); + + Columns = createHeaders(scores.FirstOrDefault()); + Content = scores.Select((s, i) => createContent(i, s)).ToArray().ToRectangular(); + } + + public void ClearScores() + { + Content = null; + backgroundFlow.Clear(); } private TableColumn[] createHeaders(ScoreInfo score) @@ -77,25 +83,30 @@ namespace osu.Game.Overlays.BeatmapSet.Scores new TableColumn("rank", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), new TableColumn("", Anchor.Centre, new Dimension(GridSizeMode.Absolute, 70)), // grade new TableColumn("score", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("accuracy", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("player", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 150)), - new TableColumn("max combo", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 90)) + new TableColumn("accuracy", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, minSize: 60, maxSize: 70)), + new TableColumn("", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 25)), // flag + new TableColumn("player", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 125)), + new TableColumn("max combo", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 120)) }; - foreach (var statistic in score.Statistics) - columns.Add(new TableColumn(statistic.Key.GetDescription(), Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 50, maxSize: 70))); + foreach (var statistic in score.SortedStatistics.Take(score.SortedStatistics.Count() - 1)) + columns.Add(new TableColumn(statistic.Key.GetDescription(), Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); - columns.AddRange(new[] - { - new TableColumn("pp", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 40, maxSize: 70)), - new TableColumn("mods", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - }); + columns.Add(new TableColumn(score.SortedStatistics.LastOrDefault().Key.GetDescription(), Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 45, maxSize: 95))); + + if (showPerformancePoints) + columns.Add(new TableColumn("pp", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 30))); + + columns.Add(new TableColumn("mods", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); return columns.ToArray(); } private Drawable[] createContent(int index, ScoreInfo score) { + var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: text_size)) { AutoSizeAxes = Axes.Both }; + username.AddUserLink(score.User); + var content = new List { new OsuSpriteText @@ -105,7 +116,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, new UpdateableRank(score.Rank) { - Size = new Vector2(30, 20) + Size = new Vector2(28, 14) }, new OsuSpriteText { @@ -116,41 +127,25 @@ namespace osu.Game.Overlays.BeatmapSet.Scores new OsuSpriteText { Margin = new MarginPadding { Right = horizontal_inset }, - Text = $@"{score.Accuracy:P2}", + Text = score.DisplayAccuracy, Font = OsuFont.GetFont(size: text_size), Colour = score.Accuracy == 1 ? highAccuracyColour : Color4.White }, - }; - - var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: text_size)) { AutoSizeAxes = Axes.Both }; - username.AddUserLink(score.User); - - content.AddRange(new Drawable[] - { - new FillFlowContainer + new UpdateableFlag(score.User.Country) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Right = horizontal_inset }, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - new UpdateableFlag(score.User.Country) - { - Size = new Vector2(20, 13), - ShowPlaceholderOnNull = false, - }, - username - } + Size = new Vector2(19, 13), + ShowPlaceholderOnNull = false, }, + username, new OsuSpriteText { Text = $@"{score.MaxCombo:N0}x", - Font = OsuFont.GetFont(size: text_size) + Font = OsuFont.GetFont(size: text_size), + Colour = score.MaxCombo == score.Beatmap?.MaxCombo ? highAccuracyColour : Color4.White } - }); + }; - foreach (var kvp in score.Statistics) + foreach (var kvp in score.SortedStatistics) { content.Add(new OsuSpriteText { @@ -160,24 +155,25 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }); } - content.AddRange(new Drawable[] + if (showPerformancePoints) { - new OsuSpriteText + content.Add(new OsuSpriteText { Text = $@"{score.PP:N0}", Font = OsuFont.GetFont(size: text_size) - }, - new FillFlowContainer + }); + } + + content.Add(new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(1), + ChildrenEnumerable = score.Mods.Select(m => new ModIcon(m) { - Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Spacing = new Vector2(1), - ChildrenEnumerable = score.Mods.Select(m => new ModIcon(m) - { - AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.3f) - }) - }, + Scale = new Vector2(0.3f) + }) }); return content.ToArray(); @@ -190,7 +186,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public HeaderText(string text) { Text = text.ToUpper(); - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Black); + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs index 724a7f8b55..d84e1eff8c 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs @@ -22,15 +22,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly int index; private readonly ScoreInfo score; - public ScoreTableRowBackground(int index, ScoreInfo score) + public ScoreTableRowBackground(int index, ScoreInfo score, float height) { this.index = index; this.score = score; RelativeSizeAxes = Axes.X; - Height = 25; + Height = height; - CornerRadius = 3; + CornerRadius = 5; Masking = true; InternalChildren = new Drawable[] @@ -48,18 +48,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } [BackgroundDependencyLoader] - private void load(OsuColour colours, IAPIProvider api) + private void load(OsuColour colours, OverlayColourProvider colourProvider, IAPIProvider api) { var isOwnScore = api.LocalUser.Value.Id == score.UserID; if (isOwnScore) background.Colour = colours.GreenDarker; else if (index % 2 == 0) - background.Colour = colours.Gray3; + background.Colour = colourProvider.Background4; else background.Alpha = 0; - hoveredBackground.Colour = isOwnScore ? colours.GreenDark : colours.Gray4; + hoveredBackground.Colour = isOwnScore ? colours.GreenDark : colourProvider.Background3; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 0378d364b8..a58d662de7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -5,8 +5,6 @@ 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 osuTK; using System.Linq; using osu.Game.Online.API.Requests.Responses; @@ -14,13 +12,14 @@ using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoresContainer : CompositeDrawable + public class ScoresContainer : BeatmapSetLayoutSection { private const int spacing = 15; @@ -32,10 +31,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly Box background; private readonly ScoreTable scoreTable; private readonly FillFlowContainer topScoresContainer; - private readonly DimmedLoadingLayer loading; + private readonly LoadingLayer loading; private readonly LeaderboardModSelector modSelector; private readonly NoScoresPlaceholder noScoresPlaceholder; - private readonly FillFlowContainer content; private readonly NotSupporterPlaceholder notSupporterPlaceholder; [Resolved] @@ -54,17 +52,17 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (value?.Scores.Any() != true) { - scoreTable.Scores = null; + scoreTable.ClearScores(); scoreTable.Hide(); return; } var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); + var topScore = scoreInfos.First(); - scoreTable.Scores = scoreInfos; + scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked); scoreTable.Show(); - var topScore = scoreInfos.First(); var userScore = value.UserScore; var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); @@ -77,23 +75,21 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public ScoresContainer() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + AddRange(new Drawable[] { background = new Box { RelativeSizeAxes = Axes.Both, }, - content = new FillFlowContainer + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Width = 0.95f, Direction = FillDirection.Vertical, - Margin = new MarginPadding { Vertical = spacing }, + Padding = new MarginPadding { Horizontal = 50 }, + Margin = new MarginPadding { Vertical = 20 }, Children = new Drawable[] { new FillFlowContainer @@ -122,7 +118,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Vertical = spacing }, + Margin = new MarginPadding { Top = spacing }, Children = new Drawable[] { noScoresPlaceholder = new NoScoresPlaceholder @@ -161,27 +157,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } }, - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Child = loading = new DimmedLoadingLayer(iconScale: 0.8f) - { - Alpha = 0, - }, - } + loading = new LoadingLayer() } } } - }, - }; + } + }); } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.Gray2; + background.Colour = colourProvider.Background5; user.BindTo(api.LocalUser); } @@ -192,8 +179,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores scope.BindValueChanged(_ => getScores()); ruleset.BindValueChanged(_ => getScores()); - modSelector.SelectedMods.ItemsAdded += _ => getScores(); - modSelector.SelectedMods.ItemsRemoved += _ => getScores(); + modSelector.SelectedMods.CollectionChanged += (_, __) => getScores(); Beatmap.BindValueChanged(onBeatmapChanged); user.BindValueChanged(onUserChanged, true); @@ -234,7 +220,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (Beatmap.Value?.OnlineBeatmapID.HasValue != true || Beatmap.Value.Status <= BeatmapSetOnlineStatus.Pending) { Scores = null; - content.Hide(); + Hide(); return; } @@ -248,7 +234,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores notSupporterPlaceholder.Hide(); - content.Show(); + Show(); loading.Show(); getScoresRequest = new GetScoresRequest(Beatmap.Value, Beatmap.Value.Ruleset, scope.Value, modSelector.SelectedMods); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index b9664d7c2f..a92346e0fe 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; @@ -23,9 +24,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public class TopScoreStatisticsSection : CompositeDrawable { private const float margin = 10; + private const float top_columns_min_width = 64; + private const float bottom_columns_min_width = 45; - private readonly FontUsage smallFont = OsuFont.GetFont(size: 20); - private readonly FontUsage largeFont = OsuFont.GetFont(size: 25); + private readonly FontUsage smallFont = OsuFont.GetFont(size: 16); + private readonly FontUsage largeFont = OsuFont.GetFont(size: 22, weight: FontWeight.Light); private readonly TextColumn totalScoreColumn; private readonly TextColumn accuracyColumn; @@ -44,9 +47,23 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10, 0), + Direction = FillDirection.Vertical, Children = new Drawable[] { + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(margin, 0), + Children = new Drawable[] + { + totalScoreColumn = new TextColumn("total score", largeFont, top_columns_min_width), + accuracyColumn = new TextColumn("accuracy", largeFont, top_columns_min_width), + maxComboColumn = new TextColumn("max combo", largeFont, top_columns_min_width) + } + }, new FillFlowContainer { Anchor = Anchor.TopRight, @@ -62,24 +79,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Direction = FillDirection.Horizontal, Spacing = new Vector2(margin, 0), }, - ppColumn = new TextColumn("pp", smallFont), + ppColumn = new TextColumn("pp", smallFont, bottom_columns_min_width), modsColumn = new ModsInfoColumn(), } }, - new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(margin, 0), - Children = new Drawable[] - { - totalScoreColumn = new TextColumn("total score", largeFont), - accuracyColumn = new TextColumn("accuracy", largeFont), - maxComboColumn = new TextColumn("max combo", largeFont) - } - }, } }; } @@ -92,16 +95,17 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { totalScoreColumn.Text = $@"{value.TotalScore:N0}"; - accuracyColumn.Text = $@"{value.Accuracy:P2}"; + accuracyColumn.Text = value.DisplayAccuracy; maxComboColumn.Text = $@"{value.MaxCombo:N0}x"; + ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0; ppColumn.Text = $@"{value.PP:N0}"; - statisticsColumns.ChildrenEnumerable = value.Statistics.Select(kvp => createStatisticsColumn(kvp.Key, kvp.Value)); + statisticsColumns.ChildrenEnumerable = value.SortedStatistics.Select(kvp => createStatisticsColumn(kvp.Key, kvp.Value)); modsColumn.Mods = value.Mods; } } - private TextColumn createStatisticsColumn(HitResult hitResult, int count) => new TextColumn(hitResult.GetDescription(), smallFont) + private TextColumn createStatisticsColumn(HitResult hitResult, int count) => new TextColumn(hitResult.GetDescription(), smallFont, bottom_columns_min_width) { Text = count.ToString() }; @@ -109,37 +113,61 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private class InfoColumn : CompositeDrawable { private readonly Box separator; + private readonly OsuSpriteText text; - public InfoColumn(string title, Drawable content) + public InfoColumn(string title, Drawable content, float? minWidth = null) { AutoSizeAxes = Axes.Both; + Margin = new MarginPadding { Vertical = 5 }; - InternalChild = new FillFlowContainer + InternalChild = new GridContainer { AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2), - Children = new[] + ColumnDimensions = new[] { - new OsuSpriteText + new Dimension(GridSizeMode.AutoSize, minSize: minWidth ?? 0) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 2), + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Black), - Text = title.ToUpper() + text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold), + Text = title.ToUpper(), + // 2px padding bottom + 1px vertical to compensate for the additional spacing because of 1.25 line-height in osu-web + Padding = new MarginPadding { Top = 1, Bottom = 3 } + } }, - separator = new Box + new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = 2 + separator = new Box + { + Anchor = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + Height = 2, + }, }, - content + new[] + { + // osu-web has 4px margin here but also uses 0.9 line-height, reducing margin to 2px seems like a good alternative to that + content.With(c => c.Margin = new MarginPadding { Top = 2 }) + } } }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - separator.Colour = colours.Gray5; + text.Colour = colourProvider.Foreground1; + separator.Colour = colourProvider.Background3; } } @@ -147,13 +175,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { private readonly SpriteText text; - public TextColumn(string title, FontUsage font) - : this(title, new OsuSpriteText { Font = font }) + public TextColumn(string title, FontUsage font, float? minWidth = null) + : this(title, new OsuSpriteText { Font = font }, minWidth) { } - private TextColumn(string title, SpriteText text) - : base(title, text) + private TextColumn(string title, SpriteText text, float? minWidth = null) + : base(title, text, minWidth) { this.text = text; } @@ -171,9 +199,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public ModsInfoColumn() : this(new FillFlowContainer { - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, Direction = FillDirection.Horizontal, Spacing = new Vector2(1), + Height = 18f }) { } @@ -189,15 +218,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { modsContainer.Clear(); - - foreach (Mod mod in value) + modsContainer.Children = value.Select(mod => new ModIcon(mod) { - modsContainer.Add(new ModIcon(mod) - { - AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.3f), - }); - } + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.25f), + }).ToList(); } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 38a909411a..9111a0cfc7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; +using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +13,6 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; using osu.Game.Scoring; using osu.Game.Users.Drawables; -using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -25,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly UpdateableRank rank; private readonly UpdateableAvatar avatar; private readonly LinkFlowContainer usernameText; - private readonly SpriteText date; + private readonly DrawableDate achievedOn; private readonly UpdateableFlag flag; public TopScoreUserSection() @@ -51,13 +50,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Bold, italics: true) + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) }, rank = new UpdateableRank(ScoreRank.D) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(40), + Size = new Vector2(28), FillMode = FillMode.Fit, }, } @@ -66,9 +65,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(80), + Size = new Vector2(70), Masking = true, - CornerRadius = 5, + CornerRadius = 4, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, @@ -87,23 +86,37 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Spacing = new Vector2(0, 3), Children = new Drawable[] { - usernameText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold, italics: true)) + usernameText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true)) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Both, }, - date = new OsuSpriteText + new FillFlowContainer { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold) + Children = new[] + { + new OsuSpriteText + { + Text = "achieved ", + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold) + }, + achievedOn = new DrawableDate(DateTimeOffset.MinValue) + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold) + }, + } }, flag = new UpdateableFlag { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(20, 13), + Size = new Vector2(19, 13), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even ShowPlaceholderOnNull = false, }, } @@ -112,15 +125,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + public int? ScorePosition { - rankText.Colour = colours.Yellow; - } - - public int ScorePosition - { - set => rankText.Text = $"#{value}"; + set => rankText.Text = value == null ? "-" : $"#{value}"; } /// @@ -132,7 +139,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { avatar.User = value.User; flag.Country = value.User.Country; - date.Text = $@"achieved {HumanizerUtils.Humanize(value.Date)}"; + achievedOn.Date = value.Date; usernameText.Clear(); usernameText.AddUserLink(value.User); diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index 0258a0301a..3bb36545cd 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -17,7 +17,7 @@ namespace osu.Game.Overlays.BeatmapSet protected readonly FailRetryGraph Graph; private readonly FillFlowContainer header; - private readonly OsuSpriteText successRateLabel, successPercent, graphLabel; + private readonly OsuSpriteText successPercent; private readonly Bar successRate; private readonly Container percentContainer; @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.BeatmapSet int playCount = beatmap?.OnlineInfo?.PlayCount ?? 0; var rate = playCount != 0 ? (float)passCount / playCount : 0; - successPercent.Text = rate.ToString("P0"); + successPercent.Text = rate.ToString("0.#%"); successRate.Length = rate; percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); @@ -60,12 +60,12 @@ namespace osu.Game.Overlays.BeatmapSet Direction = FillDirection.Vertical, Children = new Drawable[] { - successRateLabel = new OsuSpriteText + new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Success Rate", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, successRate = new Bar { @@ -82,15 +82,15 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.TopRight, Origin = Anchor.TopCentre, - Font = OsuFont.GetFont(size: 13), + Font = OsuFont.GetFont(size: 12), }, }, - graphLabel = new OsuSpriteText + new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Points of Failure", - Font = OsuFont.GetFont(size: 13), + Font = OsuFont.GetFont(size: 12), Margin = new MarginPadding { Vertical = 20 }, }, }, @@ -105,11 +105,10 @@ namespace osu.Game.Overlays.BeatmapSet } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { - successRateLabel.Colour = successPercent.Colour = graphLabel.Colour = colours.Gray5; successRate.AccentColour = colours.Green; - successRate.BackgroundColour = colours.GrayD; + successRate.BackgroundColour = colourProvider.Background6; updateDisplay(); } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 50fb2782d4..3e23442023 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -9,11 +9,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; +using osu.Game.Overlays.Comments; using osu.Game.Rulesets; using osuTK; @@ -22,46 +22,64 @@ namespace osu.Game.Overlays public class BeatmapSetOverlay : FullscreenOverlay { public const float X_PADDING = 40; - public const float TOP_PADDING = 25; + public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; protected readonly Header Header; - private RulesetStore rulesets; + [Resolved] + private RulesetStore rulesets { get; set; } private readonly Bindable beatmapSet = new Bindable(); // receive input outside our bounds so we can trigger a close event on ourselves. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + private readonly Box background; + public BeatmapSetOverlay() + : base(OverlayColourScheme.Blue) { - OsuScrollContainer scroll; + OverlayScrollContainer scroll; Info info; + CommentsSection comments; Children = new Drawable[] { - new Box + background = new Box { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f) + RelativeSizeAxes = Axes.Both }, - scroll = new OsuScrollContainer + scroll = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer + Child = new ReverseChildIDFillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Children = new Drawable[] + Spacing = new Vector2(0, 20), + Children = new[] { - Header = new Header(), - info = new Info(), + new BeatmapSetLayoutSection + { + Child = new ReverseChildIDFillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header = new Header(), + info = new Info() + } + }, + }, new ScoresContainer { Beatmap = { BindTarget = Header.Picker.Beatmap } - } + }, + comments = new CommentsSection() }, }, }, @@ -69,6 +87,7 @@ namespace osu.Game.Overlays Header.BeatmapSet.BindTo(beatmapSet); info.BeatmapSet.BindTo(beatmapSet); + comments.BeatmapSet.BindTo(beatmapSet); Header.Picker.Beatmap.ValueChanged += b => { @@ -79,9 +98,9 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { - this.rulesets = rulesets; + background.Colour = ColourProvider.Background6; } protected override void PopOutComplete() @@ -131,5 +150,30 @@ namespace osu.Game.Overlays beatmapSet.Value = set; Show(); } + + private class CommentsSection : BeatmapSetLayoutSection + { + public readonly Bindable BeatmapSet = new Bindable(); + + public CommentsSection() + { + CommentsContainer comments; + + Add(comments = new CommentsContainer()); + + BeatmapSet.BindValueChanged(beatmapSet => + { + if (beatmapSet.NewValue?.OnlineBeatmapSetID is int onlineBeatmapSetID) + { + Show(); + comments.ShowComments(CommentableType.Beatmapset, onlineBeatmapSetID); + } + else + { + Hide(); + } + }, true); + } + } } } diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 8a82b1f0c0..81315f9638 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -1,23 +1,29 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays { - public abstract class BreadcrumbControlOverlayHeader : OverlayHeader + public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader { - protected OverlayHeaderBreadcrumbControl BreadcrumbControl; - - protected override TabControl CreateTabControl() => BreadcrumbControl = new OverlayHeaderBreadcrumbControl(); + protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); public class OverlayHeaderBreadcrumbControl : BreadcrumbControl { public OverlayHeaderBreadcrumbControl() { RelativeSizeAxes = Axes.X; + Height = 47; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AccentColour = colourProvider.Light2; } protected override TabItem CreateTabItem(string value) => new ControlTabItem(value); @@ -29,10 +35,18 @@ namespace osu.Game.Overlays public ControlTabItem(string value) : base(value) { + RelativeSizeAxes = Axes.Y; Text.Font = Text.Font.With(size: 14); - Chevron.Y = 3; + Text.Anchor = Anchor.CentreLeft; + Text.Origin = Anchor.CentreLeft; + Chevron.Y = 1; Bar.Height = 0; } + + // base OsuTabItem makes font bold on activation, we don't want that here + protected override void OnActivated() => FadeHovered(); + + protected override void OnDeactivated() => FadeUnhovered(); } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index 5a3ce6291e..48bf6c2ddd 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -16,6 +16,7 @@ using osuTK.Graphics; using osu.Framework.Allocation; using System.Net; using osuTK; +using osu.Framework.Extensions.Color4Extensions; namespace osu.Game.Overlays.Changelog { @@ -51,28 +52,27 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { foreach (var categoryEntries in Build.ChangelogEntries.GroupBy(b => b.Category).OrderBy(c => c.Key)) { ChangelogEntries.Add(new OsuSpriteText { Text = categoryEntries.Key, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 24), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), Margin = new MarginPadding { Top = 35, Bottom = 15 }, }); - var fontLarge = OsuFont.GetFont(size: 18); - var fontMedium = OsuFont.GetFont(size: 14); - var fontSmall = OsuFont.GetFont(size: 12); + var fontLarge = OsuFont.GetFont(size: 16); + var fontMedium = OsuFont.GetFont(size: 12); - foreach (APIChangelogEntry entry in categoryEntries) + foreach (var entry in categoryEntries) { var entryColour = entry.Major ? colours.YellowLight : Color4.White; LinkFlowContainer title; - Container titleContainer = new Container + var titleContainer = new Container { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -83,9 +83,9 @@ namespace osu.Game.Overlays.Changelog { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreRight, - Size = new Vector2(fontSmall.Size), + Size = new Vector2(10), Icon = entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus, - Colour = entryColour, + Colour = entryColour.Opacity(0.5f), Margin = new MarginPadding { Right = 5 }, }, title = new LinkFlowContainer @@ -93,6 +93,7 @@ namespace osu.Game.Overlays.Changelog Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.BottomLeft, } } }; @@ -123,10 +124,11 @@ namespace osu.Game.Overlays.Changelog }); } - title.AddText(" by ", t => + title.AddText("by ", t => { t.Font = fontMedium; t.Colour = entryColour; + t.Padding = new MarginPadding { Left = 10 }; }); if (entry.GithubUser.UserId != null) @@ -153,7 +155,7 @@ namespace osu.Game.Overlays.Changelog { title.AddText(entry.GithubUser.DisplayName, t => { - t.Font = fontSmall; + t.Font = fontMedium; t.Colour = entryColour; }); } @@ -162,7 +164,7 @@ namespace osu.Game.Overlays.Changelog if (!string.IsNullOrEmpty(entry.MessageHtml)) { - TextFlowContainer message = new TextFlowContainer + var message = new TextFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -171,8 +173,8 @@ namespace osu.Game.Overlays.Changelog // todo: use markdown parsing once API returns markdown message.AddText(WebUtility.HtmlDecode(Regex.Replace(entry.MessageHtml, @"<(.|\n)*?>", string.Empty)), t => { - t.Font = fontSmall; - t.Colour = new Color4(235, 184, 254, 255); + t.Font = fontMedium; + t.Colour = colourProvider.Foreground1; }); ChangelogEntries.Add(message); diff --git a/osu.Game/Overlays/Changelog/ChangelogContent.cs b/osu.Game/Overlays/Changelog/ChangelogContent.cs index f8d5bbd66c..49dd9bb835 100644 --- a/osu.Game/Overlays/Changelog/ChangelogContent.cs +++ b/osu.Game/Overlays/Changelog/ChangelogContent.cs @@ -19,7 +19,6 @@ namespace osu.Game.Overlays.Changelog RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Bottom = 100 }; } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index 7e47a3e29f..050bdea03a 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -2,80 +2,73 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Shapes; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Changelog { public class ChangelogHeader : BreadcrumbControlOverlayHeader { - public readonly Bindable Current = new Bindable(); + public readonly Bindable Build = new Bindable(); public Action ListingSelected; - public UpdateStreamBadgeArea Streams; + public ChangelogUpdateStreamControl Streams; private const string listing_string = "listing"; + private Box streamsBackground; + public ChangelogHeader() { - BreadcrumbControl.AddItem(listing_string); - BreadcrumbControl.Current.ValueChanged += e => + TabControl.AddItem(listing_string); + Current.ValueChanged += e => { if (e.NewValue == listing_string) ListingSelected?.Invoke(); }; - Current.ValueChanged += showBuild; + Build.ValueChanged += showBuild; Streams.Current.ValueChanged += e => { - if (e.NewValue?.LatestBuild != null && e.NewValue != Current.Value?.UpdateStream) - Current.Value = e.NewValue.LatestBuild; + if (e.NewValue?.LatestBuild != null && !e.NewValue.Equals(Build.Value?.UpdateStream)) + Build.Value = e.NewValue.LatestBuild; }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - BreadcrumbControl.AccentColour = colours.Violet; - TitleBackgroundColour = colours.GreyVioletDarker; - ControlBackgroundColour = colours.GreyVioletDark; + streamsBackground.Colour = colourProvider.Background5; } - private ChangelogHeaderTitle title; - private void showBuild(ValueChangedEvent e) { if (e.OldValue != null) - BreadcrumbControl.RemoveItem(e.OldValue.ToString()); + TabControl.RemoveItem(e.OldValue.ToString()); if (e.NewValue != null) { - BreadcrumbControl.AddItem(e.NewValue.ToString()); - BreadcrumbControl.Current.Value = e.NewValue.ToString(); + TabControl.AddItem(e.NewValue.ToString()); + Current.Value = e.NewValue.ToString(); - Streams.Current.Value = Streams.Items.FirstOrDefault(s => s.Name == e.NewValue.UpdateStream.Name); - - title.Version = e.NewValue.UpdateStream.DisplayName; + updateCurrentStream(); } else { - BreadcrumbControl.Current.Value = listing_string; + Current.Value = listing_string; Streams.Current.Value = null; - title.Version = null; } } - protected override Drawable CreateBackground() => new HeaderBackground(); + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/changelog"); protected override Drawable CreateContent() => new Container { @@ -83,47 +76,47 @@ namespace osu.Game.Overlays.Changelog AutoSizeAxes = Axes.Y, Children = new Drawable[] { - Streams = new UpdateStreamBadgeArea(), + streamsBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 65, + Vertical = 20 + }, + Child = Streams = new ChangelogUpdateStreamControl() + } } }; - protected override ScreenTitle CreateTitle() => title = new ChangelogHeaderTitle(); + protected override OverlayTitle CreateTitle() => new ChangelogHeaderTitle(); - public class HeaderBackground : Sprite + public void Populate(List streams) { - public HeaderBackground() - { - RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fill; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Texture = textures.Get(@"Headers/changelog"); - } + Streams.Populate(streams); + updateCurrentStream(); } - private class ChangelogHeaderTitle : ScreenTitle + private void updateCurrentStream() { - public string Version - { - set => Section = value ?? listing_string; - } + if (Build.Value == null) + return; + Streams.Current.Value = Streams.Items.FirstOrDefault(s => s.Name == Build.Value.UpdateStream.Name); + } + + private class ChangelogHeaderTitle : OverlayTitle + { public ChangelogHeaderTitle() { Title = "changelog"; - Version = null; + IconTexture = "Icons/changelog"; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Violet; - } - - protected override Drawable CreateIcon() => new ScreenTitleTextureIcon(@"Icons/changelog"); } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogListing.cs b/osu.Game/Overlays/Changelog/ChangelogListing.cs index 41d8228475..9b74a8da6d 100644 --- a/osu.Game/Overlays/Changelog/ChangelogListing.cs +++ b/osu.Game/Overlays/Changelog/ChangelogListing.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; -using osuTK.Graphics; namespace osu.Game.Overlays.Changelog { @@ -24,13 +23,13 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - DateTime currentDate = DateTime.MinValue; + var currentDate = DateTime.MinValue; if (entries == null) return; - foreach (APIChangelogBuild build in entries) + foreach (var build in entries) { if (build.CreatedAt.Date != currentDate) { @@ -40,7 +39,7 @@ namespace osu.Game.Overlays.Changelog { RelativeSizeAxes = Axes.X, Height = 2, - Colour = new Color4(17, 17, 17, 255), + Colour = colourProvider.Background6, Margin = new MarginPadding { Top = 30 }, }); } @@ -49,10 +48,9 @@ namespace osu.Game.Overlays.Changelog { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 15 }, - Text = build.CreatedAt.Date.ToString("dd MMM yyyy"), + Margin = new MarginPadding { Top = 20 }, + Text = build.CreatedAt.Date.ToString("dd MMMM yyyy"), Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 24), - Colour = OsuColour.FromHex(@"FD5"), }); currentDate = build.CreatedAt.Date; @@ -68,7 +66,7 @@ namespace osu.Game.Overlays.Changelog Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = new Color4(32, 24, 35, 255), + Colour = colourProvider.Background6, } }); } diff --git a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs index 67bcb6f558..8b89d63aab 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs @@ -8,6 +8,7 @@ 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.Game.Graphics; using osu.Game.Graphics.Containers; @@ -16,6 +17,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; using osuTK; namespace osu.Game.Overlays.Changelog @@ -30,7 +32,7 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load(CancellationToken? cancellation, IAPIProvider api) + private void load(CancellationToken? cancellation, IAPIProvider api, OverlayColourProvider colourProvider) { bool complete = false; @@ -57,11 +59,22 @@ namespace osu.Game.Overlays.Changelog if (build != null) { + CommentsContainer comments; + Children = new Drawable[] { new ChangelogBuildWithNavigation(build) { SelectBuild = SelectBuild }, - new Comments(build) + new Box + { + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = colourProvider.Background6, + Margin = new MarginPadding { Top = 30 }, + }, + comments = new CommentsContainer() }; + + comments.ShowComments(CommentableType.Build, build.Id); } } @@ -72,6 +85,8 @@ namespace osu.Game.Overlays.Changelog { } + private OsuSpriteText date; + protected override FillFlowContainer CreateHeader() { var fill = base.CreateHeader(); @@ -81,11 +96,10 @@ namespace osu.Game.Overlays.Changelog existing.Scale = new Vector2(1.25f); existing.Action = null; - existing.Add(new OsuSpriteText + existing.Add(date = new OsuSpriteText { - Text = Build.CreatedAt.Date.ToString("dd MMM yyyy"), + Text = Build.CreatedAt.Date.ToString("dd MMMM yyyy"), Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 14), - Colour = OsuColour.FromHex(@"FD5"), Anchor = Anchor.BottomCentre, Origin = Anchor.TopCentre, Margin = new MarginPadding { Top = 5 }, @@ -105,6 +119,12 @@ namespace osu.Game.Overlays.Changelog return fill; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + date.Colour = colourProvider.Light1; + } } private class NavigationIconButton : IconButton diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs new file mode 100644 index 0000000000..509a6dabae --- /dev/null +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.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.Overlays.Changelog +{ + public class ChangelogUpdateStreamControl : OverlayStreamControl + { + protected override OverlayStreamItem CreateStreamItem(APIUpdateStream value) => new ChangelogUpdateStreamItem(value); + } +} diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs new file mode 100644 index 0000000000..f8e1ac0c84 --- /dev/null +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.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 Humanizer; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Changelog +{ + public class ChangelogUpdateStreamItem : OverlayStreamItem + { + public ChangelogUpdateStreamItem(APIUpdateStream stream) + : base(stream) + { + if (stream.IsFeatured) + Width *= 2; + } + + protected override string MainText => Value.DisplayName; + + protected override string AdditionalText => Value.LatestBuild.DisplayVersion; + + protected override string InfoText => Value.LatestBuild.Users > 0 ? $"{"user".ToQuantity(Value.LatestBuild.Users, "N0")} online" : null; + + protected override Color4 GetBarColour(OsuColour colours) => Value.Colour; + } +} diff --git a/osu.Game/Overlays/Changelog/Comments.cs b/osu.Game/Overlays/Changelog/Comments.cs deleted file mode 100644 index 4cf39e7b44..0000000000 --- a/osu.Game/Overlays/Changelog/Comments.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class Comments : CompositeDrawable - { - private readonly APIChangelogBuild build; - - public Comments(APIChangelogBuild build) - { - this.build = build; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - Padding = new MarginPadding - { - Horizontal = 50, - Vertical = 20, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - LinkFlowContainer text; - - InternalChildren = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.GreyVioletDarker - }, - }, - text = new LinkFlowContainer(t => - { - t.Colour = colours.PinkLighter; - t.Font = OsuFont.Default.With(size: 14); - }) - { - Padding = new MarginPadding(20), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }; - - text.AddParagraph("Got feedback?", t => - { - t.Colour = Color4.White; - t.Font = OsuFont.Default.With(italics: true, size: 20); - t.Padding = new MarginPadding { Bottom = 20 }; - }); - - text.AddParagraph("We would love to hear what you think of this update! "); - text.AddIcon(FontAwesome.Regular.GrinHearts); - - text.AddParagraph("Please visit the "); - text.AddLink("web version", $"{build.Url}#comments"); - text.AddText(" of this changelog to leave any comments."); - } - } -} diff --git a/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs b/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs deleted file mode 100644 index 52b77604d9..0000000000 --- a/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Humanizer; -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.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class UpdateStreamBadge : TabItem - { - private const float badge_height = 66.5f; - private const float badge_width = 100; - private const float transition_duration = 100; - - private readonly ExpandingBar expandingBar; - private SampleChannel sampleClick; - private SampleChannel sampleHover; - - private readonly FillFlowContainer text; - - public readonly Bindable SelectedTab = new Bindable(); - - private readonly Container fadeContainer; - - public UpdateStreamBadge(APIUpdateStream stream) - : base(stream) - { - Size = new Vector2(stream.IsFeatured ? badge_width * 2 : badge_width, badge_height); - Padding = new MarginPadding(5); - - Child = fadeContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - text = new FillFlowContainer - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new[] - { - new OsuSpriteText - { - Text = stream.DisplayName, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12), - Margin = new MarginPadding { Top = 6 }, - }, - new OsuSpriteText - { - Text = stream.LatestBuild.DisplayVersion, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 16), - }, - new OsuSpriteText - { - Text = stream.LatestBuild.Users > 0 ? $"{stream.LatestBuild.Users:N0} {"user".Pluralize(stream.LatestBuild.Users == 1)} online" : null, - Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 10), - Colour = new Color4(203, 164, 218, 255), - }, - } - }, - expandingBar = new ExpandingBar - { - Anchor = Anchor.TopCentre, - Colour = stream.Colour, - ExpandedSize = 4, - CollapsedSize = 2, - IsCollapsed = true - }, - } - }; - - SelectedTab.BindValueChanged(_ => updateState(), true); - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleClick = audio.Samples.Get(@"UI/generic-select-soft"); - sampleHover = audio.Samples.Get(@"UI/generic-hover-soft"); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - protected override bool OnClick(ClickEvent e) - { - sampleClick?.Play(); - return base.OnClick(e); - } - - protected override bool OnHover(HoverEvent e) - { - sampleHover?.Play(); - updateState(); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - // Expand based on the local state - bool shouldExpand = Active.Value || IsHovered; - - // Expand based on whether no build is selected and the badge area is hovered - shouldExpand |= SelectedTab.Value == null && !externalDimRequested; - - if (shouldExpand) - { - expandingBar.Expand(); - fadeContainer.FadeTo(1, transition_duration); - } - else - { - expandingBar.Collapse(); - fadeContainer.FadeTo(0.5f, transition_duration); - } - - text.FadeTo(externalDimRequested && !IsHovered ? 0.5f : 1, transition_duration); - } - - private bool externalDimRequested; - - public void EnableDim() - { - externalDimRequested = true; - updateState(); - } - - public void DisableDim() - { - externalDimRequested = false; - updateState(); - } - } -} diff --git a/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs b/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs deleted file mode 100644 index 2b48811bd6..0000000000 --- a/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Game.Online.API.Requests.Responses; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class UpdateStreamBadgeArea : TabControl - { - public UpdateStreamBadgeArea() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - AddInternal(new Box - { - Colour = Color4.Black, - Alpha = 0.12f, - RelativeSizeAxes = Axes.Both, - }); - } - - public void Populate(List streams) - { - Current.Value = null; - - foreach (APIUpdateStream updateStream in streams) - AddItem(updateStream); - } - - protected override bool OnHover(HoverEvent e) - { - foreach (UpdateStreamBadge streamBadge in TabContainer.Children.OfType()) - streamBadge.EnableDim(); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - foreach (UpdateStreamBadge streamBadge in TabContainer.Children.OfType()) - streamBadge.DisableDim(); - - base.OnHoverLost(e); - } - - protected override TabFillFlowContainer CreateTabFlow() - { - var flow = base.CreateTabFlow(); - - flow.RelativeSizeAxes = Axes.X; - flow.AutoSizeAxes = Axes.Y; - flow.AllowMultiline = true; - flow.Padding = new MarginPadding - { - Vertical = 20, - Horizontal = 85, - }; - - return flow; - } - - protected override Dropdown CreateDropdown() => null; - - protected override TabItem CreateTabItem(APIUpdateStream value) => - new UpdateStreamBadge(value) { SelectedTab = { BindTarget = Current } }; - } -} diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index 15b0079277..726be9e194 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -13,7 +13,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Online.API.Requests; @@ -26,7 +25,7 @@ namespace osu.Game.Overlays { public readonly Bindable Current = new Bindable(); - private ChangelogHeader header; + protected ChangelogHeader Header; private Container content; @@ -34,24 +33,24 @@ namespace osu.Game.Overlays private List builds; - private List streams; + protected List Streams; + + public ChangelogOverlay() + : base(OverlayColourScheme.Purple) + { + } [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colour) + private void load(AudioManager audio) { - Waves.FirstWaveColour = colour.GreyVioletLight; - Waves.SecondWaveColour = colour.GreyViolet; - Waves.ThirdWaveColour = colour.GreyVioletDark; - Waves.FourthWaveColour = colour.GreyVioletDarker; - Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colour.PurpleDarkAlternative, + Colour = ColourProvider.Background4, }, - new OsuScrollContainer + new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -62,7 +61,7 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, Children = new Drawable[] { - header = new ChangelogHeader + Header = new ChangelogHeader { ListingSelected = ShowListing, }, @@ -78,7 +77,7 @@ namespace osu.Game.Overlays sampleBack = audio.Samples.Get(@"UI/generic-select-soft"); - header.Current.BindTo(Current); + Header.Build.BindTo(Current); Current.BindValueChanged(e => { @@ -117,7 +116,7 @@ namespace osu.Game.Overlays performAfterFetch(() => { var build = builds.Find(b => b.Version == version && b.UpdateStream.Name == updateStream) - ?? streams.Find(s => s.Name == updateStream)?.LatestBuild; + ?? Streams.Find(s => s.Name == updateStream)?.LatestBuild; if (build != null) ShowBuild(build); @@ -179,9 +178,9 @@ namespace osu.Game.Overlays res.Streams.ForEach(s => s.LatestBuild.UpdateStream = res.Streams.Find(s2 => s2.Id == s.LatestBuild.UpdateStream.Id)); builds = res.Builds; - streams = res.Streams; + Streams = res.Streams; - header.Streams.Populate(res.Streams); + Header.Populate(res.Streams); tcs.SetResult(true); }); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 8abde8a24f..4eb348ae33 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -10,6 +10,7 @@ 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.Graphics; using osu.Game.Graphics.Containers; @@ -114,21 +115,26 @@ namespace osu.Game.Overlays.Chat Colour = Color4.Black.Opacity(0.3f), Type = EdgeEffectType.Shadow, }, - // Drop shadow effect Child = new Container { AutoSizeAxes = Axes.Both, + Y = 3, Masking = true, CornerRadius = 4, - EdgeEffect = new EdgeEffectParameters + Children = new Drawable[] { - Radius = 1, - Colour = OsuColour.FromHex(message.Sender.Colour), - Type = EdgeEffectType.Shadow, - }, - Padding = new MarginPadding { Left = 3, Right = 3, Bottom = 1, Top = -3 }, - Y = 3, - Child = username, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(message.Sender.Colour), + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, + Child = username + } + } } }; } @@ -172,7 +178,7 @@ namespace osu.Game.Overlays.Chat t.Font = OsuFont.GetFont(italics: true); if (senderHasBackground) - t.Colour = OsuColour.FromHex(message.Sender.Colour); + t.Colour = Color4Extensions.FromHex(message.Sender.Colour); } t.Font = t.Font.With(size: TextSize); @@ -249,41 +255,41 @@ namespace osu.Game.Overlays.Chat private static readonly Color4[] username_colours = { - OsuColour.FromHex("588c7e"), - OsuColour.FromHex("b2a367"), - OsuColour.FromHex("c98f65"), - OsuColour.FromHex("bc5151"), - OsuColour.FromHex("5c8bd6"), - OsuColour.FromHex("7f6ab7"), - OsuColour.FromHex("a368ad"), - OsuColour.FromHex("aa6880"), + Color4Extensions.FromHex("588c7e"), + Color4Extensions.FromHex("b2a367"), + Color4Extensions.FromHex("c98f65"), + Color4Extensions.FromHex("bc5151"), + Color4Extensions.FromHex("5c8bd6"), + Color4Extensions.FromHex("7f6ab7"), + Color4Extensions.FromHex("a368ad"), + Color4Extensions.FromHex("aa6880"), - OsuColour.FromHex("6fad9b"), - OsuColour.FromHex("f2e394"), - OsuColour.FromHex("f2ae72"), - OsuColour.FromHex("f98f8a"), - OsuColour.FromHex("7daef4"), - OsuColour.FromHex("a691f2"), - OsuColour.FromHex("c894d3"), - OsuColour.FromHex("d895b0"), + Color4Extensions.FromHex("6fad9b"), + Color4Extensions.FromHex("f2e394"), + Color4Extensions.FromHex("f2ae72"), + Color4Extensions.FromHex("f98f8a"), + Color4Extensions.FromHex("7daef4"), + Color4Extensions.FromHex("a691f2"), + Color4Extensions.FromHex("c894d3"), + Color4Extensions.FromHex("d895b0"), - OsuColour.FromHex("53c4a1"), - OsuColour.FromHex("eace5c"), - OsuColour.FromHex("ea8c47"), - OsuColour.FromHex("fc4f4f"), - OsuColour.FromHex("3d94ea"), - OsuColour.FromHex("7760ea"), - OsuColour.FromHex("af52c6"), - OsuColour.FromHex("e25696"), + Color4Extensions.FromHex("53c4a1"), + Color4Extensions.FromHex("eace5c"), + Color4Extensions.FromHex("ea8c47"), + Color4Extensions.FromHex("fc4f4f"), + Color4Extensions.FromHex("3d94ea"), + Color4Extensions.FromHex("7760ea"), + Color4Extensions.FromHex("af52c6"), + Color4Extensions.FromHex("e25696"), - OsuColour.FromHex("677c66"), - OsuColour.FromHex("9b8732"), - OsuColour.FromHex("8c5129"), - OsuColour.FromHex("8c3030"), - OsuColour.FromHex("1f5d91"), - OsuColour.FromHex("4335a5"), - OsuColour.FromHex("812a96"), - OsuColour.FromHex("992861"), + 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/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 443f2b7bf7..d63faebae4 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -26,6 +26,20 @@ namespace osu.Game.Overlays.Chat protected FillFlowContainer ChatLineFlow; private OsuScrollContainer scroll; + private bool scrollbarVisible = true; + + public bool ScrollbarVisible + { + set + { + if (scrollbarVisible == value) return; + + scrollbarVisible = value; + if (scroll != null) + scroll.ScrollbarVisible = value; + } + } + [Resolved] private OsuColour colours { get; set; } @@ -44,6 +58,7 @@ namespace osu.Game.Overlays.Chat Masking = true, Child = scroll = new OsuScrollContainer { + ScrollbarVisible = scrollbarVisible, RelativeSizeAxes = Axes.Both, // Some chat lines have effects that slightly protrude to the bottom, // which we do not want to mask away, hence the padding. @@ -90,6 +105,14 @@ namespace osu.Game.Overlays.Chat private void newMessagesArrived(IEnumerable newMessages) { + if (newMessages.Min(m => m.Id) < chatLines.Max(c => c.Message.Id)) + { + // there is a case (on initial population) that we may receive past messages and need to reorder. + // easiest way is to just combine messages and recreate drawables (less worrying about day separators etc.) + newMessages = newMessages.Concat(chatLines.Select(c => c.Message)).OrderBy(m => m.Id).ToList(); + ChatLineFlow.Clear(); + } + bool shouldScrollToEnd = scroll.IsScrolledToEnd(10) || !chatLines.Any() || newMessages.Any(m => m is LocalMessage); // Add up to last Channel.MAX_HISTORY messages diff --git a/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs b/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs index 31c48deee0..1e58e8b640 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Chat.Selection private const float text_size = 15; private const float transition_duration = 100; - private readonly Channel channel; + public readonly Channel Channel; private readonly Bindable joinedBind = new Bindable(); private readonly OsuSpriteText name; @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Chat.Selection private Color4 topicColour; private Color4 hoverColour; - public IEnumerable FilterTerms => new[] { channel.Name, channel.Topic }; + public IEnumerable FilterTerms => new[] { Channel.Name, Channel.Topic ?? string.Empty }; public bool MatchingFilter { @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.Chat.Selection public ChannelListItem(Channel channel) { - this.channel = channel; + Channel = channel; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -148,7 +148,7 @@ namespace osu.Game.Overlays.Chat.Selection hoverColour = colours.Yellow; joinedBind.ValueChanged += joined => updateColour(joined.NewValue); - joinedBind.BindTo(channel.Joined); + joinedBind.BindTo(Channel.Joined); joinedBind.TriggerChange(); FinishTransforms(true); @@ -156,7 +156,7 @@ namespace osu.Game.Overlays.Chat.Selection protected override bool OnHover(HoverEvent e) { - if (!channel.Joined.Value) + if (!Channel.Joined.Value) name.FadeColour(hoverColour, 50, Easing.OutQuint); return base.OnHover(e); @@ -164,7 +164,7 @@ namespace osu.Game.Overlays.Chat.Selection protected override void OnHoverLost(HoverLostEvent e) { - if (!channel.Joined.Value) + if (!Channel.Joined.Value) name.FadeColour(Color4.White, transition_duration); } diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs index 25a9a51638..b46ca6b040 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs @@ -41,10 +41,10 @@ namespace osu.Game.Overlays.Chat.Selection { RelativeSizeAxes = Axes.X; - Waves.FirstWaveColour = OsuColour.FromHex("353535"); - Waves.SecondWaveColour = OsuColour.FromHex("434343"); - Waves.ThirdWaveColour = OsuColour.FromHex("515151"); - Waves.FourthWaveColour = OsuColour.FromHex("595959"); + Waves.FirstWaveColour = Color4Extensions.FromHex("353535"); + Waves.SecondWaveColour = Color4Extensions.FromHex("434343"); + Waves.ThirdWaveColour = Color4Extensions.FromHex("515151"); + Waves.FourthWaveColour = Color4Extensions.FromHex("595959"); Children = new Drawable[] { @@ -154,7 +154,7 @@ namespace osu.Game.Overlays.Chat.Selection { bg.Colour = colours.Gray3; triangles.ColourDark = colours.Gray3; - triangles.ColourLight = OsuColour.FromHex(@"353535"); + triangles.ColourLight = Color4Extensions.FromHex(@"353535"); headerBg.Colour = colours.Gray2.Opacity(0.75f); } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs index d5d9a6c2ce..e3ede04edd 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs @@ -39,6 +39,7 @@ namespace osu.Game.Overlays.Chat.Tabs public ChannelSelectorTabChannel() { Name = "+"; + Type = ChannelType.System; } } } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs index 4b1d595b44..19c6f437b6 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs @@ -9,6 +9,7 @@ using osuTK; using System; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; namespace osu.Game.Overlays.Chat.Tabs { @@ -40,7 +41,7 @@ namespace osu.Game.Overlays.Chat.Tabs // performTabSort might've made selectorTab's position wonky, fix it TabContainer.SetLayoutPosition(selectorTab, float.MaxValue); - ((ChannelTabItem)item).OnRequestClose += tabCloseRequested; + ((ChannelTabItem)item).OnRequestClose += channelItem => OnRequestLeave?.Invoke(channelItem.Value); base.AddTabItem(item, addToDropdown); } @@ -67,21 +68,20 @@ namespace osu.Game.Overlays.Chat.Tabs if (!Items.Contains(channel)) AddItem(channel); - if (Current.Value == null) - Current.Value = channel; + Current.Value ??= channel; } /// /// Removes a channel from the ChannelTabControl. - /// If the selected channel is the one that is beeing removed, the next available channel will be selected. + /// If the selected channel is the one that is being removed, the next available channel will be selected. /// /// The channel that is going to be removed. public void RemoveChannel(Channel channel) { RemoveItem(channel); - if (Current.Value == channel) - Current.Value = Items.FirstOrDefault(); + if (SelectedTab == null) + SelectTab(selectorTab); } protected override void SelectTab(TabItem tab) @@ -96,19 +96,17 @@ namespace osu.Game.Overlays.Chat.Tabs selectorTab.Active.Value = false; } - private void tabCloseRequested(TabItem tab) + protected override TabFillFlowContainer CreateTabFlow() => new ChannelTabFillFlowContainer { - int totalTabs = TabContainer.Count - 1; // account for selectorTab - int currentIndex = Math.Clamp(TabContainer.IndexOf(tab), 1, totalTabs); + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.Both, + Depth = -1, + Masking = true + }; - if (tab == SelectedTab && totalTabs > 1) - // Select the tab after tab-to-be-removed's index, or the tab before if current == last - SelectTab(TabContainer[currentIndex == totalTabs ? currentIndex - 1 : currentIndex + 1]); - else if (totalTabs == 1 && !selectorTab.Active.Value) - // Open channel selection overlay if all channel tabs will be closed after removing this tab - SelectTab(selectorTab); - - OnRequestLeave?.Invoke(tab.Value); + private class ChannelTabFillFlowContainer : TabFillFlowContainer + { + protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y); } } } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs index 266e68f17e..09dc06b95f 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs @@ -141,16 +141,13 @@ namespace osu.Game.Overlays.Chat.Tabs updateState(); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { switch (e.Button) { case MouseButton.Middle: CloseButton.Click(); - return true; - - default: - return false; + break; } } diff --git a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs index 1413b8fe78..5b428a3825 100644 --- a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs @@ -89,7 +89,7 @@ namespace osu.Game.Overlays.Chat.Tabs { var user = Value.Users.First(); - BackgroundActive = user.Colour != null ? OsuColour.FromHex(user.Colour) : colours.BlueDark; + BackgroundActive = user.Colour != null ? Color4Extensions.FromHex(user.Colour) : colours.BlueDark; BackgroundInactive = BackgroundActive.Darken(0.5f); } } diff --git a/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs b/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs index bde930d4fb..178afda5ac 100644 --- a/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs +++ b/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs @@ -34,10 +34,10 @@ namespace osu.Game.Overlays.Chat.Tabs return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { icon.ScaleTo(0.75f, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index e80c51d82a..5ba55f6d45 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -30,13 +30,14 @@ namespace osu.Game.Overlays private const float textbox_height = 60; private const float channel_selection_min_height = 0.3f; - private ChannelManager channelManager; + [Resolved] + private ChannelManager channelManager { get; set; } private Container currentChannelContainer; private readonly List loadedChannels = new List(); - private LoadingAnimation loading; + private LoadingSpinner loading; private FocusedTextBox textbox; @@ -72,7 +73,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, OsuColour colours, ChannelManager channelManager) + private void load(OsuConfigManager config, OsuColour colours) { const float padding = 5; @@ -145,7 +146,7 @@ namespace osu.Game.Overlays } } }, - loading = new LoadingAnimation(), + loading = new LoadingSpinner(), } }, tabsArea = new TabsArea @@ -209,8 +210,6 @@ namespace osu.Game.Overlays chatBackground.Colour = colours.ChatBlue; - this.channelManager = channelManager; - loading.Show(); // This is a relatively expensive (and blocking) operation. @@ -279,6 +278,10 @@ namespace osu.Game.Overlays currentChannelContainer.Clear(false); currentChannelContainer.Add(loaded); } + + // mark channel as read when channel switched + if (e.NewValue.Messages.Any()) + channelManager.MarkChannelAsRead(e.NewValue); } private float startDragChatHeight; @@ -295,7 +298,7 @@ namespace osu.Game.Overlays return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { if (isDragging) { @@ -307,20 +310,20 @@ namespace osu.Game.Overlays ChatHeight.Value = targetChatHeight; } - - return true; } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { isDragging = false; - return base.OnDragEnd(e); + base.OnDragEnd(e); } private void selectTab(int index) { - var channel = ChannelTabControl.Items.Skip(index).FirstOrDefault(); - if (channel != null && !(channel is ChannelSelectorTabItem.ChannelSelectorTabChannel)) + var channel = ChannelTabControl.Items + .Where(tab => !(tab is ChannelSelectorTabItem.ChannelSelectorTabChannel)) + .ElementAtOrDefault(index); + if (channel != null) ChannelTabControl.Current.Value = channel; } @@ -355,7 +358,7 @@ namespace osu.Game.Overlays protected override void OnFocus(FocusEvent e) { - //this is necessary as textbox is masked away and therefore can't get focus :( + // this is necessary as textbox is masked away and therefore can't get focus :( textbox.TakeFocus(); base.OnFocus(e); } diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs new file mode 100644 index 0000000000..f7e0cb0a6c --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.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 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.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public abstract class CommentRepliesButton : CompositeDrawable + { + protected string Text + { + get => text.Text; + set => text.Text = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly SpriteIcon icon; + private readonly Box background; + private readonly OsuSpriteText text; + + protected CommentRepliesButton() + { + AutoSizeAxes = Axes.Both; + Margin = new MarginPadding + { + Vertical = 2 + }; + InternalChildren = new Drawable[] + { + new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding + { + Vertical = 5, + Horizontal = 10, + }, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AlwaysPresent = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }, + icon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(7.5f), + Icon = FontAwesome.Solid.ChevronDown + } + } + } + } + } + }, + new HoverClickSounds(), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colourProvider.Background2; + icon.Colour = colourProvider.Foreground1; + } + + protected void SetIconDirection(bool upwards) => icon.ScaleTo(new Vector2(1, upwards ? -1 : 1)); + + public void ToggleTextVisibility(bool visible) => text.FadeTo(visible ? 1 : 0, 200, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + background.FadeColour(colourProvider.Background1, 200, Easing.OutQuint); + icon.FadeColour(colourProvider.Light1, 200, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + background.FadeColour(colourProvider.Background2, 200, Easing.OutQuint); + icon.FadeColour(colourProvider.Foreground1, 200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs new file mode 100644 index 0000000000..4998e5391e --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class LoadRepliesButton : LoadingButton + { + private ButtonContent content; + + public LoadRepliesButton() + { + AutoSizeAxes = Axes.Both; + } + + protected override Drawable CreateContent() => content = new ButtonContent(); + + protected override void OnLoadStarted() => content.ToggleTextVisibility(false); + + protected override void OnLoadFinished() => content.ToggleTextVisibility(true); + + private class ButtonContent : CommentRepliesButton + { + public ButtonContent() + { + Text = "load replies"; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs new file mode 100644 index 0000000000..04e7e25cc5 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.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 Humanizer; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ShowRepliesButton : CommentRepliesButton + { + public readonly BindableBool Expanded = new BindableBool(true); + + public ShowRepliesButton(int count) + { + Text = "reply".ToQuantity(count); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(expanded => SetIconDirection(expanded.NewValue), true); + } + + protected override bool OnClick(ClickEvent e) + { + Expanded.Toggle(); + return true; + } + } +} diff --git a/osu.Game/Overlays/Comments/CancellableCommentEditor.cs b/osu.Game/Overlays/Comments/CancellableCommentEditor.cs new file mode 100644 index 0000000000..c226b7f07f --- /dev/null +++ b/osu.Game/Overlays/Comments/CancellableCommentEditor.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.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; + +namespace osu.Game.Overlays.Comments +{ + public abstract class CancellableCommentEditor : CommentEditor + { + public Action OnCancel; + + [BackgroundDependencyLoader] + private void load() + { + ButtonsContainer.Add(new CancelButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Action = () => OnCancel?.Invoke() + }); + } + + 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 = @"Cancel" + } + } + }; + } + + [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 new file mode 100644 index 0000000000..7b4bf882dc --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -0,0 +1,245 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +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 osuTK; +using osu.Framework.Bindables; + +namespace osu.Game.Overlays.Comments +{ + public abstract class CommentEditor : CompositeDrawable + { + private const int side_padding = 8; + + public Action OnCommit; + + public bool IsLoading + { + get => commitButton.IsLoading; + set => commitButton.IsLoading = value; + } + + 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; + CornerRadius = 6; + BorderThickness = 3; + BorderColour = colourProvider.Background3; + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3 + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + textBox = new EditorTextBox + { + Height = 40, + RelativeSizeAxes = Axes.X, + PlaceholderText = TextBoxPlaceholder, + Current = Current + }, + new Container + { + Name = "Footer", + RelativeSizeAxes = Axes.X, + Height = 35, + Padding = new MarginPadding { Horizontal = side_padding }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = FooterText + }, + ButtonsContainer = 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) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Action = () => + { + OnCommit?.Invoke(Current.Value); + Current.Value = string.Empty; + } + } + } + } + } + } + } + }); + + textBox.OnCommit += (u, v) => + { + if (commitButton.IsBlocked.Value) + return; + + commitButton.Click(); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(text => commitButton.IsBlocked.Value = string.IsNullOrEmpty(text.NewValue), true); + } + + private class EditorTextBox : BasicTextBox + { + protected override float LeftRightPadding => side_padding; + + protected override Color4 SelectionColour => Color4.Gray; + + private OsuSpriteText placeholder; + + public EditorTextBox() + { + Masking = false; + TextContainer.Height = 0.4f; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + BackgroundUnfocused = BackgroundFocused = colourProvider.Background5; + placeholder.Colour = colourProvider.Background3; + BackgroundCommit = colourProvider.Background3; + } + + protected override SpriteText CreatePlaceholder() => placeholder = new OsuSpriteText + { + 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 + { + 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 }; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private OsuSpriteText drawableText; + private Box background; + private Box blockedBackground; + + public CommitButton(string text) + { + AutoSizeAxes = Axes.Both; + LoadingAnimationSize = new Vector2(10); + + drawableText.Text = text; + } + + [BackgroundDependencyLoader] + private void load() + { + IdleColour = colourProvider.Light4; + HoverColour = colourProvider.Light3; + blockedBackground.Colour = colourProvider.Background5; + } + + 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 } + } + } + }; + + 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/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 560123eb55..f71808ba89 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -8,49 +8,49 @@ using osu.Game.Online.API.Requests; using osu.Framework.Graphics; using osu.Framework.Bindables; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Online.API.Requests.Responses; using System.Threading; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Threading; +using osu.Game.Users; namespace osu.Game.Overlays.Comments { public class CommentsContainer : CompositeDrawable { - private readonly CommentableType type; - private readonly long id; + private readonly Bindable type = new Bindable(); + private readonly BindableLong id = new BindableLong(); public readonly Bindable Sort = new Bindable(); public readonly BindableBool ShowDeleted = new BindableBool(); + protected readonly Bindable User = new Bindable(); + [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private OsuColour colours { get; set; } - private GetCommentsRequest request; + private ScheduledDelegate scheduledCommentsLoad; private CancellationTokenSource loadCancellation; private int currentPage; - private readonly Box background; - private readonly FillFlowContainer content; - private readonly DeletedChildrenPlaceholder deletedChildrenPlaceholder; - private readonly CommentsShowMoreButton moreButton; + private FillFlowContainer content; + private DeletedCommentsCounter deletedCommentsCounter; + private CommentsShowMoreButton moreButton; + private TotalCommentsCounter commentCounter; - public CommentsContainer(CommentableType type, long id) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - this.type = type; - this.id = id; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; AddRangeInternal(new Drawable[] { - background = new Box + new Box { RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 }, new FillFlowContainer { @@ -59,6 +59,7 @@ namespace osu.Game.Overlays.Comments Direction = FillDirection.Vertical, Children = new Drawable[] { + commentCounter = new TotalCommentsCounter(), new CommentsHeader { Sort = { BindTarget = Sort }, @@ -72,6 +73,7 @@ namespace osu.Game.Overlays.Comments }, new Container { + Name = @"Footer", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] @@ -79,7 +81,7 @@ namespace osu.Game.Overlays.Comments new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f) + Colour = colourProvider.Background4 }, new FillFlowContainer { @@ -88,7 +90,7 @@ namespace osu.Game.Overlays.Comments Direction = FillDirection.Vertical, Children = new Drawable[] { - deletedChildrenPlaceholder = new DeletedChildrenPlaceholder + deletedCommentsCounter = new DeletedCommentsCounter { ShowDeleted = { BindTarget = ShowDeleted } }, @@ -101,7 +103,8 @@ namespace osu.Game.Overlays.Comments Anchor = Anchor.Centre, Origin = Anchor.Centre, Margin = new MarginPadding(5), - Action = getComments + Action = getComments, + IsLoading = true, } } } @@ -111,21 +114,34 @@ namespace osu.Game.Overlays.Comments } } }); - } - [BackgroundDependencyLoader] - private void load() - { - background.Colour = colours.Gray2; + User.BindTo(api.LocalUser); } protected override void LoadComplete() { - Sort.BindValueChanged(onSortChanged, true); + User.BindValueChanged(_ => refetchComments()); + Sort.BindValueChanged(_ => refetchComments(), true); base.LoadComplete(); } - private void onSortChanged(ValueChangedEvent sort) + /// The type of resource to get comments for. + /// The id of the resource to get comments for. + public void ShowComments(CommentableType type, long id) + { + this.type.Value = type; + this.id.Value = id; + + if (!IsLoaded) + return; + + // only reset when changing ID/type. other refetch ops are generally just changing sort order. + commentCounter.Current.Value = 0; + + refetchComments(); + } + + private void refetchComments() { clearComments(); getComments(); @@ -133,17 +149,22 @@ namespace osu.Game.Overlays.Comments private void getComments() { + if (id.Value <= 0) + return; + request?.Cancel(); loadCancellation?.Cancel(); - request = new GetCommentsRequest(type, id, Sort.Value, currentPage++); - request.Success += onSuccess; - api.Queue(request); + scheduledCommentsLoad?.Cancel(); + request = new GetCommentsRequest(id.Value, type.Value, Sort.Value, currentPage++, 0); + request.Success += res => scheduledCommentsLoad = Schedule(() => onSuccess(res)); + api.PerformAsync(request); } private void clearComments() { currentPage = 1; - deletedChildrenPlaceholder.DeletedCount.Value = 0; + deletedCommentsCounter.Count.Value = 0; + moreButton.Show(); moreButton.IsLoading = true; content.Clear(); } @@ -152,29 +173,17 @@ namespace osu.Game.Overlays.Comments { loadCancellation = new CancellationTokenSource(); - FillFlowContainer page = new FillFlowContainer + LoadComponentAsync(new CommentsPage(response) { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - }; - - foreach (var c in response.Comments) - { - if (c.IsTopLevel) - { - page.Add(new DrawableComment(c) - { - ShowDeleted = { BindTarget = ShowDeleted } - }); - } - } - - LoadComponentAsync(page, loaded => + ShowDeleted = { BindTarget = ShowDeleted }, + Sort = { BindTarget = Sort }, + Type = { BindTarget = type }, + CommentableId = { BindTarget = id } + }, loaded => { content.Add(loaded); - deletedChildrenPlaceholder.DeletedCount.Value += response.Comments.Count(c => c.IsDeleted && c.IsTopLevel); + deletedCommentsCounter.Count.Value += response.Comments.Count(c => c.IsDeleted && c.IsTopLevel); if (response.HasMore) { @@ -184,8 +193,12 @@ namespace osu.Game.Overlays.Comments moreButton.Current.Value = response.TopLevelCount - loadedTopLevelComments; moreButton.IsLoading = false; } + else + { + moreButton.Hide(); + } - moreButton.FadeTo(response.HasMore ? 1 : 0); + commentCounter.Current.Value = response.Total; }, loadCancellation.Token); } diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs index 6a7a678cc7..0dd68bbd41 100644 --- a/osu.Game/Overlays/Comments/CommentsHeader.cs +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -16,8 +16,6 @@ namespace osu.Game.Overlays.Comments { public class CommentsHeader : CompositeDrawable { - private const int font_size = 14; - public readonly Bindable Sort = new Bindable(); public readonly BindableBool ShowDeleted = new BindableBool(); @@ -40,29 +38,11 @@ namespace osu.Game.Overlays.Comments Padding = new MarginPadding { Horizontal = 50 }, Children = new Drawable[] { - new FillFlowContainer + new OverlaySortTabControl { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: font_size), - Text = @"Sort by" - }, - new SortTabControl - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = Sort - } - } + Current = Sort }, new ShowDeletedButton { @@ -76,9 +56,9 @@ namespace osu.Game.Overlays.Comments } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.Gray3; + background.Colour = colourProvider.Background4; } private class ShowDeletedButton : HeaderButton @@ -106,7 +86,7 @@ namespace osu.Game.Overlays.Comments { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: font_size), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = @"Show deleted" } }, @@ -126,4 +106,12 @@ namespace osu.Game.Overlays.Comments } } } + + public enum CommentsSortCriteria + { + [System.ComponentModel.Description(@"Recent")] + New, + Old, + Top + } } diff --git a/osu.Game/Overlays/Comments/CommentsPage.cs b/osu.Game/Overlays/Comments/CommentsPage.cs new file mode 100644 index 0000000000..9b146b0a7d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsPage.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 osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using System.Linq; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsPage : CompositeDrawable + { + public readonly BindableBool ShowDeleted = new BindableBool(); + public readonly Bindable Sort = new Bindable(); + public readonly Bindable Type = new Bindable(); + public readonly BindableLong CommentableId = new BindableLong(); + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly CommentBundle commentBundle; + private FillFlowContainer flow; + + public CommentsPage(CommentBundle commentBundle) + { + this.commentBundle = commentBundle; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 + }, + flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } + }); + + if (!commentBundle.Comments.Any()) + { + flow.Add(new NoCommentsPlaceholder()); + return; + } + + AppendComments(commentBundle); + } + + private DrawableComment getDrawableComment(Comment comment) + { + if (CommentDictionary.TryGetValue(comment.Id, out var existing)) + return existing; + + return CommentDictionary[comment.Id] = new DrawableComment(comment) + { + ShowDeleted = { BindTarget = ShowDeleted }, + Sort = { BindTarget = Sort }, + RepliesRequested = onCommentRepliesRequested + }; + } + + private void onCommentRepliesRequested(DrawableComment drawableComment, int page) + { + var request = new GetCommentsRequest(CommentableId.Value, Type.Value, Sort.Value, page, drawableComment.Comment.Id); + + request.Success += response => Schedule(() => AppendComments(response)); + + api.PerformAsync(request); + } + + protected readonly Dictionary CommentDictionary = new Dictionary(); + + /// + /// Appends retrieved comments to the subtree rooted of comments in this page. + /// + /// The bundle of comments to add. + protected void AppendComments([NotNull] CommentBundle bundle) + { + var orphaned = new List(); + + foreach (var comment in bundle.Comments.Concat(bundle.IncludedComments)) + { + // Exclude possible duplicated comments. + if (CommentDictionary.ContainsKey(comment.Id)) + continue; + + addNewComment(comment); + } + + // Comments whose parents were seen later than themselves can now be added. + foreach (var o in orphaned) + addNewComment(o); + + void addNewComment(Comment comment) + { + var drawableComment = getDrawableComment(comment); + + if (comment.ParentId == null) + { + // Comments that have no parent are added as top-level comments to the flow. + flow.Add(drawableComment); + } + else if (CommentDictionary.TryGetValue(comment.ParentId.Value, out var parentDrawable)) + { + // The comment's parent has already been seen, so the parent<-> child links can be added. + comment.ParentComment = parentDrawable.Comment; + parentDrawable.Replies.Add(drawableComment); + } + else + { + // The comment's parent has not been seen yet, so keep it orphaned for the time being. This can occur if the comments arrive out of order. + // Since this comment has now been seen, any further children can be added to it without being orphaned themselves. + orphaned.Add(comment); + } + } + } + + private class NoCommentsPlaceholder : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = 80; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 50 }, + Text = @"No comments yet." + } + }); + } + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs index b0174e7b1a..d2ff7ecb1f 100644 --- a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs +++ b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Comments @@ -11,11 +11,14 @@ namespace osu.Game.Overlays.Comments { public readonly BindableInt Current = new BindableInt(); - public CommentsShowMoreButton() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - IdleColour = OsuColour.Gray(0.3f); - HoverColour = OsuColour.Gray(0.4f); - ChevronIconColour = OsuColour.Gray(0.5f); + Height = 20; + + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; + ChevronIconColour = colourProvider.Foreground1; } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs similarity index 51% rename from osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs rename to osu.Game/Overlays/Comments/DeletedCommentsCounter.cs index 6b41453b91..f22086bf23 100644 --- a/osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs +++ b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs @@ -12,51 +12,56 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Comments { - public class DeletedChildrenPlaceholder : FillFlowContainer + public class DeletedCommentsCounter : CompositeDrawable { public readonly BindableBool ShowDeleted = new BindableBool(); - public readonly BindableInt DeletedCount = new BindableInt(); + + public readonly BindableInt Count = new BindableInt(); private readonly SpriteText countText; - public DeletedChildrenPlaceholder() + public DeletedCommentsCounter() { AutoSizeAxes = Axes.Both; - Direction = FillDirection.Horizontal; - Spacing = new Vector2(3, 0); Margin = new MarginPadding { Vertical = 10, Left = 80 }; - Children = new Drawable[] + + InternalChild = new FillFlowContainer { - new SpriteIcon + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] { - Icon = FontAwesome.Solid.Trash, - Size = new Vector2(14), - }, - countText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + new SpriteIcon + { + Icon = FontAwesome.Solid.Trash, + Size = new Vector2(14), + }, + countText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + } } }; } protected override void LoadComplete() { - DeletedCount.BindValueChanged(_ => updateDisplay(), true); - ShowDeleted.BindValueChanged(_ => updateDisplay(), true); base.LoadComplete(); + + Count.BindValueChanged(_ => updateDisplay(), true); + ShowDeleted.BindValueChanged(_ => updateDisplay(), true); } private void updateDisplay() { - if (DeletedCount.Value != 0) + if (!ShowDeleted.Value && Count.Value != 0) { - countText.Text = @"deleted comment".ToQuantity(DeletedCount.Value); - this.FadeTo(ShowDeleted.Value ? 0 : 1); + countText.Text = @"deleted comment".ToQuantity(Count.Value); + Show(); } else - { Hide(); - } } } } diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 7ae6efda6a..3cdc0a0cbd 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -12,10 +12,16 @@ using osu.Game.Graphics.Containers; using osu.Game.Utils; using osu.Framework.Graphics.Cursor; using osu.Framework.Bindables; -using osu.Framework.Graphics.Shapes; using System.Linq; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; +using osu.Framework.Allocation; +using System.Collections.Generic; +using System; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Extensions.IEnumerableExtensions; +using System.Collections.Specialized; +using osu.Game.Overlays.Comments.Buttons; namespace osu.Game.Overlays.Comments { @@ -24,255 +30,342 @@ namespace osu.Game.Overlays.Comments private const int avatar_size = 40; private const int margin = 10; + public Action RepliesRequested; + + public readonly Comment Comment; + public readonly BindableBool ShowDeleted = new BindableBool(); + public readonly Bindable Sort = new Bindable(); + private readonly Dictionary loadedReplies = new Dictionary(); + + public readonly BindableList Replies = new BindableList(); private readonly BindableBool childrenExpanded = new BindableBool(true); - private readonly FillFlowContainer childCommentsVisibilityContainer; - private readonly Comment comment; + private int currentPage; + + private FillFlowContainer childCommentsVisibilityContainer; + private FillFlowContainer childCommentsContainer; + private LoadRepliesButton loadRepliesButton; + private ShowMoreButton showMoreButton; + private ShowRepliesButton showRepliesButton; + private ChevronButton chevronButton; + private DeletedCommentsCounter deletedCommentsCounter; public DrawableComment(Comment comment) + { + Comment = comment; + } + + [BackgroundDependencyLoader] + private void load() { LinkFlowContainer username; - FillFlowContainer childCommentsContainer; - DeletedChildrenPlaceholder deletedChildrenPlaceholder; FillFlowContainer info; LinkFlowContainer message; GridContainer content; VotePill votePill; - this.comment = comment; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - InternalChild = new FillFlowContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(margin) { Left = margin + 5 }, - Child = content = new GridContainer + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] + Padding = new MarginPadding(margin) { Left = margin + 5, Top = Comment.IsTopLevel ? 10 : 0 }, + Child = content = new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] { - new FillFlowContainer + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] { - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Horizontal = margin }, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new Drawable[] + new FillFlowContainer { - new Container + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Horizontal = margin }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 40, - AutoSizeAxes = Axes.Y, - Child = votePill = new VotePill(comment) + new Container { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - } - }, - new UpdateableAvatar(comment.User) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(avatar_size), - Masking = true, - CornerRadius = avatar_size / 2f, - CornerExponent = 2, - }, - } - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 3), - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7, 0), - Children = new Drawable[] - { - username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true)) + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 40, + AutoSizeAxes = Axes.Y, + Child = votePill = new VotePill(Comment) { - AutoSizeAxes = Axes.Both, - }, - new ParentUsername(comment), - new OsuSpriteText - { - Alpha = comment.IsDeleted ? 1 : 0, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = @"deleted", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, } - } - }, - message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 40 } - }, - info = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Colour = OsuColour.Gray(0.7f), - Children = new Drawable[] + }, + new UpdateableAvatar(Comment.User) { - new OsuSpriteText + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(avatar_size), + Masking = true, + CornerRadius = avatar_size / 2f, + CornerExponent = 2, + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 3), + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 12), - Text = HumanizerUtils.Humanize(comment.CreatedAt) - }, - new RepliesButton(comment.RepliesCount) + username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true)) + { + AutoSizeAxes = Axes.Both, + }, + new ParentUsername(Comment), + new OsuSpriteText + { + Alpha = Comment.IsDeleted ? 1 : 0, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = @"deleted", + } + } + }, + message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 40 } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Expanded = { BindTarget = childrenExpanded } - }, + info = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12), + Colour = OsuColour.Gray(0.7f), + Text = HumanizerUtils.Humanize(Comment.CreatedAt) + }, + } + }, + showRepliesButton = new ShowRepliesButton(Comment.RepliesCount) + { + Expanded = { BindTarget = childrenExpanded } + }, + loadRepliesButton = new LoadRepliesButton + { + Action = () => RepliesRequested(this, ++currentPage) + } + } } } } } } } - } - }, - childCommentsVisibilityContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + }, + childCommentsVisibilityContainer = new FillFlowContainer { - childCommentsContainer = new FillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Padding = new MarginPadding { Left = 20 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical - }, - deletedChildrenPlaceholder = new DeletedChildrenPlaceholder - { - ShowDeleted = { BindTarget = ShowDeleted } + childCommentsContainer = new FillFlowContainer + { + Padding = new MarginPadding { Left = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }, + deletedCommentsCounter = new DeletedCommentsCounter + { + ShowDeleted = { BindTarget = ShowDeleted } + }, + showMoreButton = new ShowMoreButton + { + Action = () => RepliesRequested(this, ++currentPage) + } } - } + }, } + }, + chevronButton = new ChevronButton + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 30, Top = margin }, + Expanded = { BindTarget = childrenExpanded }, + Alpha = 0 } }; - deletedChildrenPlaceholder.DeletedCount.Value = comment.DeletedChildrenCount; - - if (comment.UserId.HasValue) - username.AddUserLink(comment.User); + 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) { info.Add(new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: 12), - Text = $@"edited {HumanizerUtils.Humanize(comment.EditedAt.Value)} by {comment.EditedUser.Username}" + Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}" }); } - if (comment.HasMessage) + if (Comment.HasMessage) { - var formattedSource = MessageFormatter.FormatText(comment.GetMessage); + var formattedSource = MessageFormatter.FormatText(Comment.Message); message.AddLinks(formattedSource.Text, formattedSource.Links); } - if (comment.IsDeleted) + if (Comment.IsDeleted) { content.FadeColour(OsuColour.Gray(0.5f)); votePill.Hide(); } - if (comment.IsTopLevel) + if (Comment.IsTopLevel) { - AddInternal(new Container + AddInternal(new Box { - RelativeSizeAxes = Axes.X, - Height = 1.5f, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.1f) - } + RelativeSizeAxes = Axes.X, + Height = 1.5f, + Colour = OsuColour.Gray(0.1f) }); - - if (comment.ChildComments.Any()) - { - AddInternal(new ChevronButton(comment) - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Right = 30, Top = margin }, - Expanded = { BindTarget = childrenExpanded } - }); - } } - comment.ChildComments.ForEach(c => childCommentsContainer.Add(new DrawableComment(c) + if (Replies.Any()) + onRepliesAdded(Replies); + + Replies.CollectionChanged += (_, args) => { - ShowDeleted = { BindTarget = ShowDeleted } - })); + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + onRepliesAdded(args.NewItems.Cast()); + break; + + default: + throw new NotSupportedException(@"You can only add replies to this list. Other actions are not supported."); + } + }; } protected override void LoadComplete() { ShowDeleted.BindValueChanged(show => { - if (comment.IsDeleted) + if (Comment.IsDeleted) this.FadeTo(show.NewValue ? 1 : 0); }, true); childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); + + updateButtonsState(); + base.LoadComplete(); } + public bool ContainsReply(long replyId) => loadedReplies.ContainsKey(replyId); + + private void onRepliesAdded(IEnumerable replies) + { + var page = createRepliesPage(replies); + + if (LoadState == LoadState.Loading) + { + addRepliesPage(page, replies); + return; + } + + LoadComponentAsync(page, loaded => addRepliesPage(loaded, replies)); + } + + private void addRepliesPage(FillFlowContainer page, IEnumerable replies) + { + childCommentsContainer.Add(page); + + var newReplies = replies.Select(reply => reply.Comment); + newReplies.ForEach(reply => loadedReplies.Add(reply.Id, reply)); + deletedCommentsCounter.Count.Value += newReplies.Count(reply => reply.IsDeleted); + updateButtonsState(); + } + + private FillFlowContainer createRepliesPage(IEnumerable replies) => new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = replies.ToList() + }; + + private void updateButtonsState() + { + var loadedReplesCount = loadedReplies.Count; + var hasUnloadedReplies = loadedReplesCount != Comment.RepliesCount; + + loadRepliesButton.FadeTo(hasUnloadedReplies && loadedReplesCount == 0 ? 1 : 0); + showMoreButton.FadeTo(hasUnloadedReplies && loadedReplesCount > 0 ? 1 : 0); + showRepliesButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + + if (Comment.IsTopLevel) + chevronButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + + showMoreButton.IsLoading = loadRepliesButton.IsLoading = false; + } + private class ChevronButton : ShowChildrenButton { private readonly SpriteIcon icon; - public ChevronButton(Comment comment) + public ChevronButton() { - Alpha = comment.IsTopLevel && comment.ChildComments.Any() ? 1 : 0; Child = icon = new SpriteIcon { Size = new Vector2(12), - Colour = OsuColour.Gray(0.7f) }; } @@ -282,26 +375,17 @@ namespace osu.Game.Overlays.Comments } } - private class RepliesButton : ShowChildrenButton + private class ShowMoreButton : GetCommentRepliesButton { - private readonly SpriteText text; - private readonly int count; - - public RepliesButton(int count) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - this.count = count; - - Alpha = count == 0 ? 0 : 1; - Child = text = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - }; + Margin = new MarginPadding { Vertical = 10, Left = 80 }; + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; } - protected override void OnExpandedChanged(ValueChangedEvent expanded) - { - text.Text = $@"{(expanded.NewValue ? "[+]" : "[-]")} replies ({count})"; - } + protected override string GetText() => @"Show More"; } private class ParentUsername : FillFlowContainer, IHasTooltip @@ -338,7 +422,7 @@ namespace osu.Game.Overlays.Comments if (parentComment == null) return string.Empty; - return parentComment.HasMessage ? parentComment.GetMessage : parentComment.IsDeleted ? @"deleted" : string.Empty; + return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? @"deleted" : string.Empty; } } } diff --git a/osu.Game/Overlays/Comments/GetCommentRepliesButton.cs b/osu.Game/Overlays/Comments/GetCommentRepliesButton.cs new file mode 100644 index 0000000000..a3817ba416 --- /dev/null +++ b/osu.Game/Overlays/Comments/GetCommentRepliesButton.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using System.Collections.Generic; +using osuTK; + +namespace osu.Game.Overlays.Comments +{ + public abstract class GetCommentRepliesButton : LoadingButton + { + private const int duration = 200; + + protected override IEnumerable EffectTargets => new[] { text }; + + private OsuSpriteText text; + + protected GetCommentRepliesButton() + { + AutoSizeAxes = Axes.Both; + LoadingAnimationSize = new Vector2(8); + } + + protected override Drawable CreateContent() => new Container + { + AutoSizeAxes = Axes.Both, + Child = text = new OsuSpriteText + { + AlwaysPresent = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = GetText() + } + }; + + protected abstract string GetText(); + + protected override void OnLoadStarted() => text.FadeOut(duration, Easing.OutQuint); + + protected override void OnLoadFinished() => text.FadeIn(duration, Easing.OutQuint); + } +} diff --git a/osu.Game/Overlays/Comments/HeaderButton.cs b/osu.Game/Overlays/Comments/HeaderButton.cs index 8789cf5830..fdc8db35ab 100644 --- a/osu.Game/Overlays/Comments/HeaderButton.cs +++ b/osu.Game/Overlays/Comments/HeaderButton.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; @@ -45,9 +44,9 @@ namespace osu.Game.Overlays.Comments } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.Gray4; + background.Colour = colourProvider.Background3; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Comments/ShowChildrenButton.cs b/osu.Game/Overlays/Comments/ShowChildrenButton.cs index be04b6e5de..5ec7c1d471 100644 --- a/osu.Game/Overlays/Comments/ShowChildrenButton.cs +++ b/osu.Game/Overlays/Comments/ShowChildrenButton.cs @@ -3,8 +3,9 @@ using osu.Framework.Graphics; using osu.Game.Graphics.Containers; -using osu.Framework.Input.Events; using osu.Framework.Bindables; +using osuTK.Graphics; +using osu.Game.Graphics; namespace osu.Game.Overlays.Comments { @@ -15,20 +16,18 @@ namespace osu.Game.Overlays.Comments protected ShowChildrenButton() { AutoSizeAxes = Axes.Both; + IdleColour = OsuColour.Gray(0.7f); + HoverColour = Color4.White; } protected override void LoadComplete() { + Action = Expanded.Toggle; + Expanded.BindValueChanged(OnExpandedChanged, true); base.LoadComplete(); } protected abstract void OnExpandedChanged(ValueChangedEvent expanded); - - protected override bool OnClick(ClickEvent e) - { - Expanded.Value = !Expanded.Value; - return true; - } } } diff --git a/osu.Game/Overlays/Comments/SortTabControl.cs b/osu.Game/Overlays/Comments/SortTabControl.cs deleted file mode 100644 index a114197b8d..0000000000 --- a/osu.Game/Overlays/Comments/SortTabControl.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osuTK; -using osu.Game.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Bindables; -using osu.Framework.Allocation; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Comments -{ - public class SortTabControl : OsuTabControl - { - protected override Dropdown CreateDropdown() => null; - - protected override TabItem CreateTabItem(CommentsSortCriteria value) => new SortTabItem(value); - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - }; - - public SortTabControl() - { - AutoSizeAxes = Axes.Both; - } - - private class SortTabItem : TabItem - { - public SortTabItem(CommentsSortCriteria value) - : base(value) - { - AutoSizeAxes = Axes.Both; - Child = new TabButton(value) { Active = { BindTarget = Active } }; - } - - protected override void OnActivated() - { - } - - protected override void OnDeactivated() - { - } - - private class TabButton : HeaderButton - { - public readonly BindableBool Active = new BindableBool(); - - [Resolved] - private OsuColour colours { get; set; } - - private readonly SpriteText text; - - public TabButton(CommentsSortCriteria value) - { - Add(text = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 14), - Text = value.ToString() - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Active.BindValueChanged(active => - { - updateBackgroundState(); - - text.Font = text.Font.With(weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium); - text.Colour = active.NewValue ? colours.BlueLighter : Color4.White; - }, true); - } - - protected override bool OnHover(HoverEvent e) - { - updateBackgroundState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) => updateBackgroundState(); - - private void updateBackgroundState() - { - if (Active.Value || IsHovered) - ShowBackground(); - else - HideBackground(); - } - } - } - } - - public enum CommentsSortCriteria - { - New, - Old, - Top - } -} diff --git a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs index 376853c1de..1bb9b52689 100644 --- a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Framework.Graphics.Sprites; using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,9 +16,10 @@ namespace osu.Game.Overlays.Comments { public readonly BindableInt Current = new BindableInt(); - private readonly SpriteText counter; + private OsuSpriteText counter; - public TotalCommentsCounter() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; Height = 50; @@ -38,6 +38,7 @@ namespace osu.Game.Overlays.Comments Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: 20, italics: true), + Colour = colourProvider.Light1, Text = @"Comments" }, new CircularContainer @@ -51,14 +52,15 @@ namespace osu.Game.Overlays.Comments new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f) + Colour = colourProvider.Background6 }, counter = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Colour = colourProvider.Foreground1 } }, } @@ -66,12 +68,6 @@ namespace osu.Game.Overlays.Comments }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - counter.Colour = colours.BlueLighter; - } - protected override void LoadComplete() { Current.BindValueChanged(value => counter.Text = value.NewValue.ToString("N0"), true); diff --git a/osu.Game/Overlays/Comments/VotePill.cs b/osu.Game/Overlays/Comments/VotePill.cs index 978846549e..aa9723ea85 100644 --- a/osu.Game/Overlays/Comments/VotePill.cs +++ b/osu.Game/Overlays/Comments/VotePill.cs @@ -33,6 +33,9 @@ namespace osu.Game.Overlays.Comments [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + private readonly Comment comment; private Box background; private Box hoverLayer; @@ -68,7 +71,7 @@ namespace osu.Game.Overlays.Comments base.LoadComplete(); isVoted.Value = comment.IsVoted; votesCount.Value = comment.VotesCount; - isVoted.BindValueChanged(voted => background.Colour = voted.NewValue ? AccentColour : OsuColour.Gray(0.05f), true); + isVoted.BindValueChanged(voted => background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true); votesCount.BindValueChanged(count => votesCounter.Text = $"+{count.NewValue}", true); } diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs new file mode 100644 index 0000000000..9ee679a866 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.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. + +namespace osu.Game.Overlays.Dashboard +{ + public class DashboardOverlayHeader : TabControlOverlayHeader + { + protected override OverlayTitle CreateTitle() => new DashboardTitle(); + + private class DashboardTitle : OverlayTitle + { + public DashboardTitle() + { + Title = "dashboard"; + IconTexture = "Icons/changelog"; + } + } + } + + public enum DashboardOverlayTabs + { + Friends + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs new file mode 100644 index 0000000000..79fda99c73 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -0,0 +1,253 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendDisplay : OverlayView> + { + private List users = new List(); + + public List Users + { + get => users; + set + { + users = value; + onlineStreamControl.Populate(value); + } + } + + private CancellationTokenSource cancellationToken; + + private Drawable currentContent; + + private FriendOnlineStreamControl onlineStreamControl; + private Box background; + private Box controlBackground; + private UserListToolbar userListToolbar; + private Container itemsPlaceholder; + private LoadingLayer loading; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + controlBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Top = 20, + Horizontal = 45 + }, + Child = onlineStreamControl = new FriendOnlineStreamControl(), + } + } + }, + new Container + { + Name = "User List", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 40, + Vertical = 20 + }, + Child = userListToolbar = new UserListToolbar + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + itemsPlaceholder = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 50 } + }, + loading = new LoadingLayer(itemsPlaceholder) + } + } + } + } + } + } + } + }; + + background.Colour = colourProvider.Background4; + controlBackground.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); + userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); + userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); + } + + protected override APIRequest> CreateRequest() => new GetFriendsRequest(); + + protected override void OnSuccess(List response) + { + Users = response; + } + + private void recreatePanels() + { + if (!users.Any()) + return; + + cancellationToken?.Cancel(); + + if (itemsPlaceholder.Any()) + loading.Show(); + + var sortedUsers = sortUsers(getUsersInCurrentGroup()); + + LoadComponentAsync(createTable(sortedUsers), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + + private List getUsersInCurrentGroup() + { + switch (onlineStreamControl.Current.Value?.Status) + { + default: + case OnlineStatus.All: + return users; + + case OnlineStatus.Offline: + return users.Where(u => !u.IsOnline).ToList(); + + case OnlineStatus.Online: + return users.Where(u => u.IsOnline).ToList(); + } + } + + private void addContentToPlaceholder(Drawable content) + { + loading.Hide(); + + var lastContent = currentContent; + + if (lastContent != null) + { + lastContent.FadeOut(100, Easing.OutQuint).Expire(); + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); + } + + itemsPlaceholder.Add(currentContent = content); + currentContent.FadeIn(200, Easing.OutQuint); + } + + private FillFlowContainer createTable(List users) + { + var style = userListToolbar.DisplayStyle.Value; + + return new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), + Children = users.Select(u => createUserPanel(u, style)).ToList() + }; + } + + private UserPanel createUserPanel(User user, OverlayPanelDisplayStyle style) + { + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + return new UserGridPanel(user).With(panel => + { + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + panel.Width = 290; + }); + + case OverlayPanelDisplayStyle.List: + return new UserListPanel(user); + } + } + + private List sortUsers(List unsorted) + { + switch (userListToolbar.SortCriteria.Value) + { + default: + case UserSortCriteria.LastVisit: + return unsorted.OrderByDescending(u => u.LastVisit).ToList(); + + case UserSortCriteria.Rank: + return unsorted.OrderByDescending(u => u.CurrentModeRank.HasValue).ThenBy(u => u.CurrentModeRank ?? 0).ToList(); + + case UserSortCriteria.Username: + return unsorted.OrderBy(u => u.Username).ToList(); + } + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs new file mode 100644 index 0000000000..28546ceab8 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Users; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendOnlineStreamControl : OverlayStreamControl + { + protected override OverlayStreamItem CreateStreamItem(FriendStream value) => new FriendsOnlineStatusItem(value); + + public void Populate(List users) + { + Clear(); + + var userCount = users.Count; + var onlineUsersCount = users.Count(user => user.IsOnline); + + AddItem(new FriendStream(OnlineStatus.All, userCount)); + AddItem(new FriendStream(OnlineStatus.Online, onlineUsersCount)); + AddItem(new FriendStream(OnlineStatus.Offline, userCount - onlineUsersCount)); + + Current.Value = Items.FirstOrDefault(); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs new file mode 100644 index 0000000000..4abece9a8d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendStream.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.Overlays.Dashboard.Friends +{ + public class FriendStream + { + public OnlineStatus Status { get; } + + public int Count { get; } + + public FriendStream(OnlineStatus status, int count) + { + Status = status; + Count = count; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs new file mode 100644 index 0000000000..7e902203f8 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendsOnlineStatusItem : OverlayStreamItem + { + public FriendsOnlineStatusItem(FriendStream value) + : base(value) + { + } + + protected override string MainText => Value.Status.ToString(); + + protected override string AdditionalText => Value.Count.ToString(); + + protected override Color4 GetBarColour(OsuColour colours) + { + switch (Value.Status) + { + case OnlineStatus.All: + return Color4.White; + + case OnlineStatus.Online: + return colours.GreenLight; + + case OnlineStatus.Offline: + return Color4.Black; + + default: + throw new ArgumentException($@"{Value.Status} status does not provide a colour in {nameof(GetBarColour)}."); + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs new file mode 100644 index 0000000000..6f2f55a6ed --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.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.Overlays.Dashboard.Friends +{ + public enum OnlineStatus + { + All, + Online, + Offline + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs new file mode 100644 index 0000000000..fb4b938183 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Bindables; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class UserListToolbar : CompositeDrawable + { + public Bindable SortCriteria => sortControl.Current; + + public Bindable DisplayStyle => styleControl.Current; + + private readonly UserSortTabControl sortControl; + private readonly OverlayPanelDisplayStyleControl styleControl; + + public UserListToolbar() + { + AutoSizeAxes = Axes.Both; + + AddInternal(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + sortControl = new UserSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + styleControl = new OverlayPanelDisplayStyleControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs new file mode 100644 index 0000000000..3a5f65212d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.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.ComponentModel; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class UserSortTabControl : OverlaySortTabControl + { + } + + public enum UserSortCriteria + { + [Description(@"Recently Active")] + LastVisit, + Rank, + Username + } +} diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs new file mode 100644 index 0000000000..a72c3f4fa5 --- /dev/null +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays.Dashboard; +using osu.Game.Overlays.Dashboard.Friends; + +namespace osu.Game.Overlays +{ + public class DashboardOverlay : FullscreenOverlay + { + private CancellationTokenSource cancellationToken; + + private Box background; + private Container content; + private DashboardOverlayHeader header; + private LoadingLayer loading; + private OverlayScrollContainer scrollFlow; + + public DashboardOverlay() + : base(OverlayColourScheme.Purple) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + scrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + header = new DashboardOverlayHeader + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Depth = -float.MaxValue + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + loading = new LoadingLayer(content), + }; + + background.Colour = ColourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + header.Current.BindValueChanged(onTabChanged); + } + + private bool displayUpdateRequired = true; + + protected override void PopIn() + { + base.PopIn(); + + // We don't want to create a new display on every call, only when exiting from fully closed state. + if (displayUpdateRequired) + { + header.Current.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + loadDisplay(Empty()); + displayUpdateRequired = true; + } + + private void loadDisplay(Drawable display) + { + scrollFlow.ScrollToStart(); + + LoadComponentAsync(display, loaded => + { + if (API.IsLoggedIn) + loading.Hide(); + + content.Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private void onTabChanged(ValueChangedEvent tab) + { + cancellationToken?.Cancel(); + loading.Show(); + + if (!API.IsLoggedIn) + { + loadDisplay(Empty()); + return; + } + + switch (tab.NewValue) + { + case DashboardOverlayTabs.Friends: + loadDisplay(new FriendDisplay()); + break; + + default: + throw new NotImplementedException($"Display for {tab.NewValue} tab is not implemented"); + } + } + + public override void APIStateChanged(IAPIProvider api, APIState state) + { + if (State.Value == Visibility.Hidden) + return; + + header.Current.TriggerChange(); + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 37db78faa1..1bcbe4dd2f 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osuTK; @@ -43,25 +42,34 @@ namespace osu.Game.Overlays.Dialog set => icon.Icon = value; } - private string text; + private string headerText; public string HeaderText { - get => text; + get => headerText; set { - if (text == value) + if (headerText == value) return; - text = value; - + headerText = value; header.Text = value; } } + private string bodyText; + public string BodyText { - set => body.Text = value; + get => bodyText; + set + { + if (bodyText == value) + return; + + bodyText = value; + body.Text = value; + } } public IEnumerable Buttons @@ -114,13 +122,13 @@ namespace osu.Game.Overlays.Dialog new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"221a21"), + Colour = Color4Extensions.FromHex(@"221a21"), }, new Triangles { RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"271e26"), - ColourDark = OsuColour.FromHex(@"1e171e"), + ColourLight = Color4Extensions.FromHex(@"271e26"), + ColourDark = Color4Extensions.FromHex(@"1e171e"), TriangleScale = 4, }, }, diff --git a/osu.Game/Overlays/Dialog/PopupDialogButton.cs b/osu.Game/Overlays/Dialog/PopupDialogButton.cs index 75bae25b73..76ee438d6d 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogButton.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Graphics; +using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Dialog @@ -11,7 +11,7 @@ namespace osu.Game.Overlays.Dialog public PopupDialogButton() { Height = 50; - BackgroundColour = OsuColour.FromHex(@"150e14"); + BackgroundColour = Color4Extensions.FromHex(@"150e14"); TextSize = 18; } } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 59d748bc5d..9f9dbdbaf1 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -50,7 +50,7 @@ namespace osu.Game.Overlays { if (v != Visibility.Hidden) return; - //handle the dialog being dismissed. + // handle the dialog being dismissed. dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); if (dialog == CurrentDialog) diff --git a/osu.Game/Overlays/Direct/DirectRulesetSelector.cs b/osu.Game/Overlays/Direct/DirectRulesetSelector.cs deleted file mode 100644 index 106aaa616b..0000000000 --- a/osu.Game/Overlays/Direct/DirectRulesetSelector.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Direct -{ - public class DirectRulesetSelector : RulesetSelector - { - public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput; - - public override bool HandlePositionalInput => !Current.Disabled && base.HandlePositionalInput; - - public override bool PropagatePositionalInputSubTree => !Current.Disabled && base.PropagatePositionalInputSubTree; - - public DirectRulesetSelector() - { - TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(10, 0); - AutoSizeAxes = Axes.Both; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Current.BindDisabledChanged(value => SelectedTab.FadeColour(value ? Color4.DarkGray : Color4.White, 200, Easing.OutQuint), true); - } - - protected override TabItem CreateTabItem(RulesetInfo value) => new DirectRulesetTabItem(value); - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - }; - - private class DirectRulesetTabItem : TabItem - { - private readonly ConstrainedIconContainer iconContainer; - - public DirectRulesetTabItem(RulesetInfo value) - : base(value) - { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - iconContainer = new ConstrainedIconContainer - { - Icon = value.CreateInstance().CreateIcon(), - Size = new Vector2(32), - }, - new HoverClickSounds() - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - updateState(); - } - - protected override bool OnHover(HoverEvent e) - { - base.OnHover(e); - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - updateState(); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - private void updateState() => iconContainer.FadeColour(IsHovered || Active.Value ? Color4.White : Color4.Gray, 120, Easing.InQuad); - } - } -} diff --git a/osu.Game/Overlays/Direct/FilterControl.cs b/osu.Game/Overlays/Direct/FilterControl.cs deleted file mode 100644 index 8b04bf0387..0000000000 --- a/osu.Game/Overlays/Direct/FilterControl.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.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Direct -{ - public class FilterControl : SearchableListFilterControl - { - private DirectRulesetSelector rulesetSelector; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"384552"); - protected override DirectSortCriteria DefaultTab => DirectSortCriteria.Ranked; - protected override BeatmapSearchCategory DefaultCategory => BeatmapSearchCategory.Leaderboard; - - protected override Drawable CreateSupplementaryControls() => rulesetSelector = new DirectRulesetSelector(); - - public Bindable Ruleset => rulesetSelector.Current; - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, Bindable ruleset) - { - DisplayStyleControl.Dropdown.AccentColour = colours.BlueDark; - rulesetSelector.Current.BindTo(ruleset); - } - } - - public enum DirectSortCriteria - { - Relevance, - Title, - Artist, - Creator, - Difficulty, - Ranked, - Rating, - Plays, - Favourites, - } -} diff --git a/osu.Game/Overlays/Direct/Header.cs b/osu.Game/Overlays/Direct/Header.cs deleted file mode 100644 index 80870dcb68..0000000000 --- a/osu.Game/Overlays/Direct/Header.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.ComponentModel; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays.SearchableList; - -namespace osu.Game.Overlays.Direct -{ - public class Header : SearchableListHeader - { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"252f3a"); - - protected override DirectTab DefaultTab => DirectTab.Search; - protected override Drawable CreateHeaderText() => new OsuSpriteText { Text = @"osu!direct", Font = OsuFont.GetFont(size: 25) }; - protected override IconUsage Icon => OsuIcon.ChevronDownCircle; - - public Header() - { - Tabs.Current.Value = DirectTab.NewestMaps; - Tabs.Current.TriggerChange(); - } - } - - public enum DirectTab - { - Search, - - [Description("Newest Maps")] - NewestMaps = DirectSortCriteria.Ranked, - - [Description("Top Rated")] - TopRated = DirectSortCriteria.Rating, - - [Description("Most Played")] - MostPlayed = DirectSortCriteria.Plays, - } -} diff --git a/osu.Game/Overlays/Direct/PanelDownloadButton.cs b/osu.Game/Overlays/Direct/PanelDownloadButton.cs deleted file mode 100644 index 4037cd46f3..0000000000 --- a/osu.Game/Overlays/Direct/PanelDownloadButton.cs +++ /dev/null @@ -1,75 +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.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online; - -namespace osu.Game.Overlays.Direct -{ - public class PanelDownloadButton : BeatmapDownloadTrackingComposite - { - protected bool DownloadEnabled => button.Enabled.Value; - - private readonly bool noVideo; - - private readonly ShakeContainer shakeContainer; - private readonly DownloadButton button; - - public PanelDownloadButton(BeatmapSetInfo beatmapSet, bool noVideo = false) - : base(beatmapSet) - { - this.noVideo = noVideo; - - InternalChild = shakeContainer = new ShakeContainer - { - RelativeSizeAxes = Axes.Both, - Child = button = new DownloadButton - { - RelativeSizeAxes = Axes.Both, - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - button.State.BindTo(State); - FinishTransforms(true); - } - - [BackgroundDependencyLoader(true)] - private void load(OsuGame game, BeatmapManager beatmaps) - { - if (BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) - { - button.Enabled.Value = false; - button.TooltipText = "This beatmap is currently not available for download."; - return; - } - - button.Action = () => - { - switch (State.Value) - { - case DownloadState.Downloading: - case DownloadState.Downloaded: - shakeContainer.Shake(); - break; - - case DownloadState.LocallyAvailable: - game.PresentBeatmap(BeatmapSet.Value); - break; - - default: - beatmaps.Download(BeatmapSet.Value, noVideo); - break; - } - }; - } - } -} diff --git a/osu.Game/Overlays/DirectOverlay.cs b/osu.Game/Overlays/DirectOverlay.cs deleted file mode 100644 index 9daf55c796..0000000000 --- a/osu.Game/Overlays/DirectOverlay.cs +++ /dev/null @@ -1,303 +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 System.Threading.Tasks; -using Humanizer; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Threading; -using osu.Game.Audio; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.Direct; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays -{ - public class DirectOverlay : SearchableListOverlay - { - private const float panel_padding = 10f; - - private RulesetStore rulesets; - - private readonly FillFlowContainer resultCountsContainer; - private readonly OsuSpriteText resultCountsText; - private FillFlowContainer panels; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"485e74"); - protected override Color4 TrianglesColourLight => OsuColour.FromHex(@"465b71"); - protected override Color4 TrianglesColourDark => OsuColour.FromHex(@"3f5265"); - - protected override SearchableListHeader CreateHeader() => new Header(); - protected override SearchableListFilterControl CreateFilterControl() => new FilterControl(); - - private IEnumerable beatmapSets; - - public IEnumerable BeatmapSets - { - get => beatmapSets; - set - { - if (ReferenceEquals(beatmapSets, value)) return; - - beatmapSets = value?.ToList(); - - if (beatmapSets == null) return; - - var artists = new List(); - var songs = new List(); - var tags = new List(); - - foreach (var s in beatmapSets) - { - artists.Add(s.Metadata.Artist); - songs.Add(s.Metadata.Title); - tags.AddRange(s.Metadata.Tags.Split(' ')); - } - - ResultAmounts = new ResultCounts(distinctCount(artists), distinctCount(songs), distinctCount(tags)); - } - } - - private ResultCounts resultAmounts; - - public ResultCounts ResultAmounts - { - get => resultAmounts; - set - { - if (value == ResultAmounts) return; - - resultAmounts = value; - - updateResultCounts(); - } - } - - public DirectOverlay() - { - // osu!direct colours are not part of the standard palette - - Waves.FirstWaveColour = OsuColour.FromHex(@"19b0e2"); - Waves.SecondWaveColour = OsuColour.FromHex(@"2280a2"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"005774"); - Waves.FourthWaveColour = OsuColour.FromHex(@"003a4e"); - - ScrollFlow.Children = new Drawable[] - { - resultCountsContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = "Found ", - Font = OsuFont.GetFont(size: 15) - }, - resultCountsText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold) - }, - } - }, - }; - - Filter.Search.Current.ValueChanged += text => - { - if (!string.IsNullOrEmpty(text.NewValue)) - { - Header.Tabs.Current.Value = DirectTab.Search; - - if (Filter.Tabs.Current.Value == DirectSortCriteria.Ranked) - Filter.Tabs.Current.Value = DirectSortCriteria.Relevance; - } - else - { - Header.Tabs.Current.Value = DirectTab.NewestMaps; - - if (Filter.Tabs.Current.Value == DirectSortCriteria.Relevance) - Filter.Tabs.Current.Value = DirectSortCriteria.Ranked; - } - }; - ((FilterControl)Filter).Ruleset.ValueChanged += _ => queueUpdateSearch(); - Filter.DisplayStyleControl.DisplayStyle.ValueChanged += style => recreatePanels(style.NewValue); - Filter.DisplayStyleControl.Dropdown.Current.ValueChanged += _ => queueUpdateSearch(); - - Header.Tabs.Current.ValueChanged += tab => - { - if (tab.NewValue != DirectTab.Search) - { - currentQuery.Value = string.Empty; - Filter.Tabs.Current.Value = (DirectSortCriteria)Header.Tabs.Current.Value; - queueUpdateSearch(); - } - }; - - currentQuery.ValueChanged += text => queueUpdateSearch(!string.IsNullOrEmpty(text.NewValue)); - - currentQuery.BindTo(Filter.Search.Current); - - Filter.Tabs.Current.ValueChanged += tab => - { - if (Header.Tabs.Current.Value != DirectTab.Search && tab.NewValue != (DirectSortCriteria)Header.Tabs.Current.Value) - Header.Tabs.Current.Value = DirectTab.Search; - - queueUpdateSearch(); - }; - - updateResultCounts(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, RulesetStore rulesets, PreviewTrackManager previewTrackManager) - { - this.rulesets = rulesets; - this.previewTrackManager = previewTrackManager; - - resultCountsContainer.Colour = colours.Yellow; - } - - private void updateResultCounts() - { - resultCountsContainer.FadeTo(ResultAmounts == null ? 0f : 1f, 200, Easing.OutQuint); - if (ResultAmounts == null) return; - - resultCountsText.Text = "Artist".ToQuantity(ResultAmounts.Artists) + ", " + - "Song".ToQuantity(ResultAmounts.Songs) + ", " + - "Tag".ToQuantity(ResultAmounts.Tags); - } - - private void recreatePanels(PanelDisplayStyle displayStyle) - { - if (panels != null) - { - panels.FadeOut(200); - panels.Expire(); - panels = null; - } - - if (BeatmapSets == null) return; - - var newPanels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(panel_padding), - Margin = new MarginPadding { Top = 10 }, - ChildrenEnumerable = BeatmapSets.Select(b => - { - switch (displayStyle) - { - case PanelDisplayStyle.Grid: - return new DirectGridPanel(b) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; - - default: - return new DirectListPanel(b); - } - }) - }; - - LoadComponentAsync(newPanels, p => - { - if (panels != null) ScrollFlow.Remove(panels); - ScrollFlow.Add(panels = newPanels); - }); - } - - protected override void PopIn() - { - base.PopIn(); - - // Queries are allowed to be run only on the first pop-in - if (getSetsRequest == null) - queueUpdateSearch(); - } - - private SearchBeatmapSetsRequest getSetsRequest; - - private readonly Bindable currentQuery = new Bindable(string.Empty); - - private ScheduledDelegate queryChangedDebounce; - private PreviewTrackManager previewTrackManager; - - private void queueUpdateSearch(bool queryTextChanged = false) - { - BeatmapSets = null; - ResultAmounts = null; - - getSetsRequest?.Cancel(); - - queryChangedDebounce?.Cancel(); - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100); - } - - private void updateSearch() - { - if (!IsLoaded) - return; - - if (State.Value == Visibility.Hidden) - return; - - if (API == null) - return; - - previewTrackManager.StopAnyPlaying(this); - - getSetsRequest = new SearchBeatmapSetsRequest( - currentQuery.Value, - ((FilterControl)Filter).Ruleset.Value, - Filter.DisplayStyleControl.Dropdown.Current.Value, - Filter.Tabs.Current.Value); //todo: sort direction (?) - - getSetsRequest.Success += response => - { - Task.Run(() => - { - var sets = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList(); - - // may not need scheduling; loads async internally. - Schedule(() => - { - BeatmapSets = sets; - recreatePanels(Filter.DisplayStyleControl.DisplayStyle.Value); - }); - }); - }; - - API.Queue(getSetsRequest); - } - - private int distinctCount(List list) => list.Distinct().ToArray().Length; - - public class ResultCounts - { - public readonly int Artists; - public readonly int Songs; - public readonly int Tags; - - public ResultCounts(int artists, int songs, int tags) - { - Artists = artists; - Songs = songs; - Tags = tags; - } - } - } -} diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 0911ee84de..3464ce6086 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -6,7 +6,6 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osuTK.Graphics; @@ -18,12 +17,12 @@ namespace osu.Game.Overlays [Resolved] protected IAPIProvider API { get; private set; } - protected FullscreenOverlay() + [Cached] + protected readonly OverlayColourProvider ColourProvider; + + protected FullscreenOverlay(OverlayColourScheme colourScheme) { - Waves.FirstWaveColour = OsuColour.Gray(0.4f); - Waves.SecondWaveColour = OsuColour.Gray(0.3f); - Waves.ThirdWaveColour = OsuColour.Gray(0.2f); - Waves.FourthWaveColour = OsuColour.Gray(0.1f); + ColourProvider = new OverlayColourProvider(colourScheme); RelativeSizeAxes = Axes.Both; RelativePositionAxes = Axes.Both; @@ -41,6 +40,15 @@ namespace osu.Game.Overlays }; } + [BackgroundDependencyLoader] + private void load() + { + Waves.FirstWaveColour = ColourProvider.Light4; + Waves.SecondWaveColour = ColourProvider.Light3; + Waves.ThirdWaveColour = ColourProvider.Dark4; + Waves.FourthWaveColour = ColourProvider.Dark3; + } + public override void Show() { if (State.Value == Visibility.Visible) diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs index 56e93b6a1e..5b44c486a3 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings; @@ -9,7 +10,11 @@ namespace osu.Game.Overlays.KeyBinding { public class GlobalKeyBindingsSection : SettingsSection { - public override IconUsage Icon => FontAwesome.Solid.Globe; + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Globe + }; + public override string Header => "Global"; public GlobalKeyBindingsSection(GlobalActionContainer manager) diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index 8317951c8a..eafb4572ca 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -66,13 +66,12 @@ namespace osu.Game.Overlays.KeyBinding CornerRadius = padding; } - private KeyBindingStore store; + [Resolved] + private KeyBindingStore store { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours, KeyBindingStore store) + private void load(OsuColour colours) { - this.store = store; - EdgeEffect = new EdgeEffectParameters { Radius = 2, @@ -177,17 +176,19 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { // don't do anything until the last button is released. if (!HasFocus || e.HasAnyButtonPressed) - return base.OnMouseUp(e); + { + base.OnMouseUp(e); + return; + } if (bindTarget.IsHovered) finalise(); else updateBindTarget(); - return true; } protected override bool OnScroll(ScrollEvent e) @@ -216,12 +217,15 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { - if (!HasFocus) return base.OnKeyUp(e); + if (!HasFocus) + { + base.OnKeyUp(e); + return; + } finalise(); - return true; } protected override bool OnJoystickPress(JoystickPressEvent e) @@ -235,17 +239,44 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnJoystickRelease(JoystickReleaseEvent e) + protected override void OnJoystickRelease(JoystickReleaseEvent e) { if (!HasFocus) - return base.OnJoystickRelease(e); + { + base.OnJoystickRelease(e); + return; + } finalise(); + } + + protected override bool OnMidiDown(MidiDownEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + return true; } + protected override void OnMidiUp(MidiUpEvent e) + { + if (!HasFocus) + { + base.OnMidiUp(e); + return; + } + + finalise(); + } + private void clear() { + if (bindTarget == null) + return; + bindTarget.UpdateKeyCombination(InputKey.None); finalise(); } @@ -305,7 +336,7 @@ namespace osu.Game.Overlays.KeyBinding } } - private class ClearButton : TriangleButton + public class ClearButton : TriangleButton { public ClearButton() { @@ -313,14 +344,6 @@ namespace osu.Game.Overlays.KeyBinding Size = new Vector2(80, 20); } - protected override bool OnMouseUp(MouseUpEvent e) - { - base.OnMouseUp(e); - - // without this, the mouse up triggers a finalise (and deselection) of the current binding target. - return true; - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { diff --git a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs b/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs index 1f4042c57c..332fb6c8fc 100644 --- a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Overlays.Settings; @@ -10,7 +11,11 @@ namespace osu.Game.Overlays.KeyBinding { public class RulesetBindingsSection : SettingsSection { - public override IconUsage Icon => (ruleset.CreateInstance().CreateIcon() as SpriteIcon)?.Icon ?? OsuIcon.Hot; + public override Drawable CreateIcon() => ruleset?.CreateInstance()?.CreateIcon() ?? new SpriteIcon + { + Icon = OsuIcon.Hot + }; + public override string Header => ruleset.Name; private readonly RulesetInfo ruleset; diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 1f15c773f4..4425c2f168 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -21,7 +21,7 @@ using osu.Framework.Graphics.Shapes; using System; using osu.Framework.Graphics.Effects; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Overlays { @@ -41,105 +41,114 @@ namespace osu.Game.Overlays private SampleChannel getSample; + private readonly Container content; + public MedalOverlay(Medal medal) { this.medal = medal; RelativeSizeAxes = Axes.Both; - Children = new Drawable[] + Child = content = new Container { - background = new Box + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(60), - }, - outerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(DISC_SIZE + 500), - Alpha = 0f, - }, - backgroundStrip = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = border_width, - Alpha = 0f, - Children = new[] + background = new Box { - new Container + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(60), + }, + outerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(DISC_SIZE + 500), + Alpha = 0f, + }, + backgroundStrip = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = border_width, + Alpha = 0f, + Children = new[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, - Width = 0.5f, - Padding = new MarginPadding { Right = DISC_SIZE / 2 }, - Children = new[] + new Container { - leftStrip = new BackgroundStrip(0f, 1f) + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Width = 0.5f, + Padding = new MarginPadding { Right = DISC_SIZE / 2 }, + Children = new[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, + leftStrip = new BackgroundStrip(0f, 1f) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Width = 0.5f, + Padding = new MarginPadding { Left = DISC_SIZE / 2 }, + Children = new[] + { + rightStrip = new BackgroundStrip(1f, 0f), }, }, }, - new Container + }, + particleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + }, + disc = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0f, + Masking = true, + AlwaysPresent = true, + BorderColour = Color4.White, + BorderThickness = border_width, + Size = new Vector2(DISC_SIZE), + Scale = new Vector2(0.8f), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - Width = 0.5f, - Padding = new MarginPadding { Left = DISC_SIZE / 2 }, - Children = new[] + new Box { - rightStrip = new BackgroundStrip(1f, 0f), + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"05262f"), + }, + new Triangles + { + RelativeSizeAxes = Axes.Both, + TriangleScale = 2, + ColourDark = Color4Extensions.FromHex(@"04222b"), + ColourLight = Color4Extensions.FromHex(@"052933"), + }, + innerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1.05f), + Alpha = 0.25f, }, }, }, - }, - particleContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0f, - }, - disc = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0f, - Masking = true, - AlwaysPresent = true, - BorderColour = Color4.White, - BorderThickness = border_width, - Size = new Vector2(DISC_SIZE), - Scale = new Vector2(0.8f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"05262f"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - TriangleScale = 2, - ColourDark = OsuColour.FromHex(@"04222b"), - ColourLight = OsuColour.FromHex(@"052933"), - }, - innerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1.05f), - Alpha = 0.25f, - }, - }, - }, + } }; + + Show(); } [BackgroundDependencyLoader] @@ -154,19 +163,22 @@ namespace osu.Game.Overlays Colour = colours.Blue.Opacity(0.5f), Radius = 50, }; - - disc.Add(drawableMedal = new DrawableMedal(medal) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - }); } protected override void LoadComplete() { base.LoadComplete(); - Show(); + + LoadComponentAsync(drawableMedal = new DrawableMedal(medal) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + }, loaded => + { + disc.Add(loaded); + startAnimation(); + }); } protected override void Update() @@ -190,11 +202,10 @@ namespace osu.Game.Overlays private const double initial_duration = 400; private const double step_duration = 900; - protected override void PopIn() + private void startAnimation() { - base.PopIn(); + content.Show(); - this.FadeIn(200); background.FlashColour(Color4.White.Opacity(0.25f), 400); getSample.Play(); diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index a9b4bed334..3cf7befb45 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -51,7 +51,7 @@ namespace osu.Game.Overlays.MedalSplash { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Scale = new Vector2(0.81f), + Scale = new Vector2(0.41f), }, medalGlow = new Sprite { diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index 69a4a4181a..e574828cd2 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Mods foregroundIcon.RotateTo(rotate_angle * direction, mod_switch_duration, mod_switch_easing); backgroundIcon.RotateTo(-rotate_angle * direction, mod_switch_duration, mod_switch_easing); - backgroundIcon.Icon = modAfter.Icon; + backgroundIcon.Mod = modAfter; using (BeginDelayedSequence(mod_switch_duration, true)) { @@ -158,7 +158,7 @@ namespace osu.Game.Overlays.Mods return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { scaleContainer.ScaleTo(1, 500, Easing.OutElastic); @@ -172,8 +172,6 @@ namespace osu.Game.Overlays.Mods break; } } - - return true; } protected override bool OnClick(ClickEvent e) @@ -218,8 +216,8 @@ namespace osu.Game.Overlays.Mods private void displayMod(Mod mod) { if (backgroundIcon != null) - backgroundIcon.Icon = foregroundIcon.Icon; - foregroundIcon.Icon = mod.Icon; + backgroundIcon.Mod = foregroundIcon.Mod; + foregroundIcon.Mod = mod; text.Text = mod.Name; Colour = mod.HasImplementation ? Color4.White : Color4.Gray; } diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 7235a18a23..3701f9ecab 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -132,7 +132,7 @@ namespace osu.Game.Overlays.Mods { foreach (var button in buttons) { - int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t.IsInstanceOfType(m))); + int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t == m.GetType())); if (i >= 0) button.SelectAt(i); diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 7f07ce620c..8a5e4d2683 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets.Mods; using osu.Game.Screens; @@ -30,12 +31,13 @@ namespace osu.Game.Overlays.Mods { public class ModSelectOverlay : WaveOverlayContainer { + public const float HEIGHT = 510; + protected readonly TriangleButton DeselectAllButton; protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; protected readonly OsuSpriteText MultiplierLabel; - protected readonly OsuSpriteText UnrankedLabel; protected override bool BlockNonPositionalInput => false; @@ -47,7 +49,7 @@ namespace osu.Game.Overlays.Mods protected readonly Container ModSettingsContainer; - protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); + public readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); private Bindable>> availableMods; @@ -55,18 +57,21 @@ namespace osu.Game.Overlays.Mods protected Color4 HighMultiplierColour; private const float content_width = 0.8f; + private const float footer_button_spacing = 20; + private readonly FillFlowContainer footerContainer; private SampleChannel sampleOn, sampleOff; public ModSelectOverlay() { - Waves.FirstWaveColour = OsuColour.FromHex(@"19b0e2"); - Waves.SecondWaveColour = OsuColour.FromHex(@"2280a2"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"005774"); - Waves.FourthWaveColour = OsuColour.FromHex(@"003a4e"); + Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2"); + Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2"); + Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); + Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e"); + + RelativeSizeAxes = Axes.Both; - Height = 510; Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING }; Children = new Drawable[] @@ -85,8 +90,7 @@ namespace osu.Game.Overlays.Mods new Triangles { TriangleScale = 5, - RelativeSizeAxes = Axes.X, - Height = Height, //set the height from the start to ensure correct triangle density. + RelativeSizeAxes = Axes.Both, ColourLight = new Color4(53, 66, 82, 255), ColourDark = new Color4(41, 54, 70, 255), }, @@ -101,7 +105,7 @@ namespace osu.Game.Overlays.Mods { new Dimension(GridSizeMode.Absolute, 90), new Dimension(GridSizeMode.Distributed), - new Dimension(GridSizeMode.Absolute, 70), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -195,7 +199,8 @@ namespace osu.Game.Overlays.Mods // Footer new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, Children = new Drawable[] @@ -213,7 +218,9 @@ namespace osu.Game.Overlays.Mods AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Width = content_width, - Direction = FillDirection.Horizontal, + Spacing = new Vector2(footer_button_spacing, footer_button_spacing / 2), + LayoutDuration = 100, + LayoutEasing = Easing.OutQuint, Padding = new MarginPadding { Vertical = 15, @@ -226,10 +233,8 @@ namespace osu.Game.Overlays.Mods Width = 180, Text = "Deselect All", Action = DeselectAll, - Margin = new MarginPadding - { - Right = 20 - } + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, CustomiseButton = new TriangleButton { @@ -237,49 +242,41 @@ namespace osu.Game.Overlays.Mods Text = "Customisation", Action = () => ModSettingsContainer.Alpha = ModSettingsContainer.Alpha == 1 ? 0 : 1, Enabled = { Value = false }, - Margin = new MarginPadding - { - Right = 20 - } + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, CloseButton = new TriangleButton { Width = 180, Text = "Close", Action = Hide, - Margin = new MarginPadding - { - Right = 20 - } + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, - new OsuSpriteText + new FillFlowContainer { - Text = @"Score Multiplier:", - Font = OsuFont.GetFont(size: 30), - Margin = new MarginPadding + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(footer_button_spacing / 2, 0), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Children = new Drawable[] { - Top = 5, - Right = 10 - } + new OsuSpriteText + { + Text = @"Score Multiplier:", + Font = OsuFont.GetFont(size: 30), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + MultiplierLabel = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Width = 70, // make width fixed so reflow doesn't occur when multiplier number changes. + }, + }, }, - MultiplierLabel = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), - Margin = new MarginPadding - { - Top = 5 - } - }, - UnrankedLabel = new OsuSpriteText - { - Text = @"(Unranked)", - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), - Margin = new MarginPadding - { - Top = 5, - Left = 10 - } - } } } }, @@ -321,14 +318,12 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, AudioManager audio, Bindable> selectedMods, OsuGameBase osu) + private void load(OsuColour colours, AudioManager audio, OsuGameBase osu) { LowMultiplierColour = colours.Red; HighMultiplierColour = colours.Green; - UnrankedLabel.Colour = colours.Blue; availableMods = osu.AvailableMods.GetBoundCopy(); - SelectedMods.BindTo(selectedMods); sampleOn = audio.Samples.Get(@"UI/check-on"); sampleOff = audio.Samples.Get(@"UI/check-off"); @@ -409,6 +404,8 @@ namespace osu.Game.Overlays.Mods return base.OnKeyDown(e); } + public override bool OnPressed(GlobalAction action) => false; // handled by back button + private void availableModsChanged(ValueChangedEvent>> mods) { if (mods.NewValue == null) return; @@ -430,12 +427,10 @@ namespace osu.Game.Overlays.Mods private void updateMods() { var multiplier = 1.0; - var ranked = true; foreach (var mod in SelectedMods.Value) { multiplier *= mod.ScoreMultiplier; - ranked &= mod.Ranked; } MultiplierLabel.Text = $"{multiplier:N2}x"; @@ -445,8 +440,6 @@ namespace osu.Game.Overlays.Mods MultiplierLabel.FadeColour(LowMultiplierColour, 200); else MultiplierLabel.FadeColour(Color4.White, 200); - - UnrankedLabel.FadeTo(ranked ? 0 : 1, 200); } private void updateModSettings(ValueChangedEvent> selectedMods) @@ -473,7 +466,10 @@ namespace osu.Game.Overlays.Mods if (selectedMod != null) { if (State.Value == Visibility.Visible) sampleOn?.Play(); + DeselectTypes(selectedMod.IncompatibleMods, true); + + if (selectedMod.RequiresConfiguration) ModSettingsContainer.Alpha = 1; } else { diff --git a/osu.Game/Overlays/Music/CollectionsDropdown.cs b/osu.Game/Overlays/Music/CollectionsDropdown.cs index 4f59b053b6..5bd321f31e 100644 --- a/osu.Game/Overlays/Music/CollectionsDropdown.cs +++ b/osu.Game/Overlays/Music/CollectionsDropdown.cs @@ -29,14 +29,8 @@ namespace osu.Game.Overlays.Music { public CollectionsMenu() { + Masking = true; CornerRadius = 5; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.3f), - Radius = 3, - Offset = new Vector2(0f, 1f), - }; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs new file mode 100644 index 0000000000..621a533dd6 --- /dev/null +++ b/osu.Game/Overlays/Music/Playlist.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Overlays.Music +{ + public class Playlist : OsuRearrangeableListContainer + { + public Action RequestSelection; + + public readonly Bindable SelectedSet = new Bindable(); + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + public void Filter(string searchTerm) => ((SearchContainer>)ListContainer).SearchTerm = searchTerm; + + public BeatmapSetInfo FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); + + protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapSetInfo item) => new PlaylistItem(item) + { + SelectedSet = { BindTarget = SelectedSet }, + RequestSelection = set => RequestSelection?.Invoke(set) + }; + + protected override FillFlowContainer> CreateListFillFlowContainer() => new SearchContainer> + { + Spacing = new Vector2(0, 3), + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + }; + } +} diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 29b6ae00f3..840fa51b4f 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; -using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -14,105 +14,74 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.Music { - public class PlaylistItem : Container, IFilterable, IDraggable + public class PlaylistItem : OsuRearrangeableListItem, IFilterable { - private const float fade_duration = 100; + public readonly Bindable SelectedSet = new Bindable(); - private Color4 hoverColour; - private Color4 artistColour; + public Action RequestSelection; - private SpriteIcon handle; private TextFlowContainer text; private IEnumerable titleSprites; - private ILocalisedBindableString titleBind; - private ILocalisedBindableString artistBind; - public readonly BeatmapSetInfo BeatmapSetInfo; + private ILocalisedBindableString title; + private ILocalisedBindableString artist; - public Action OnSelect; + private Color4 selectedColour; + private Color4 artistColour; - public bool IsDraggable { get; private set; } - - protected override bool OnMouseDown(MouseDownEvent e) + public PlaylistItem(BeatmapSetInfo item) + : base(item) { - IsDraggable = handle.IsHovered; - return base.OnMouseDown(e); - } + Padding = new MarginPadding { Left = 5 }; - protected override bool OnMouseUp(MouseUpEvent e) - { - IsDraggable = false; - return base.OnMouseUp(e); - } - - private bool selected; - - public bool Selected - { - get => selected; - set - { - if (value == selected) return; - - selected = value; - - FinishTransforms(true); - foreach (Drawable s in titleSprites) - s.FadeColour(Selected ? hoverColour : Color4.White, fade_duration); - } - } - - public PlaylistItem(BeatmapSetInfo setInfo) - { - BeatmapSetInfo = setInfo; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Top = 3, Bottom = 3 }; + FilterTerms = item.Metadata.SearchableTerms; } [BackgroundDependencyLoader] private void load(OsuColour colours, LocalisationManager localisation) { - hoverColour = colours.Yellow; + selectedColour = colours.Yellow; artistColour = colours.Gray9; + HandleColour = colours.Gray5; - var metadata = BeatmapSetInfo.Metadata; - FilterTerms = metadata.SearchableTerms; - - Children = new Drawable[] - { - handle = new PlaylistItemHandle - { - Colour = colours.Gray5 - }, - text = new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 20 }, - ContentIndent = 10f, - }, - }; - - titleBind = localisation.GetLocalisedString(new LocalisedString((metadata.TitleUnicode, metadata.Title))); - artistBind = localisation.GetLocalisedString(new LocalisedString((metadata.ArtistUnicode, metadata.Artist))); - - artistBind.BindValueChanged(_ => recreateText(), true); + title = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.TitleUnicode, Model.Metadata.Title))); + artist = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.ArtistUnicode, Model.Metadata.Artist))); } + protected override void LoadComplete() + { + base.LoadComplete(); + + artist.BindValueChanged(_ => recreateText(), true); + + SelectedSet.BindValueChanged(set => + { + if (set.OldValue?.Equals(Model) != true && set.NewValue?.Equals(Model) != true) + return; + + foreach (Drawable s in titleSprites) + s.FadeColour(set.NewValue.Equals(Model) ? selectedColour : Color4.White, FADE_DURATION); + }, true); + } + + protected override Drawable CreateContent() => text = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + private void recreateText() { text.Clear(); - //space after the title to put a space between the title and artist - titleSprites = text.AddText(titleBind.Value + @" ", sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)).OfType(); + // space after the title to put a space between the title and artist + titleSprites = text.AddText(title.Value + @" ", sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)).OfType(); - text.AddText(artistBind.Value, sprite => + text.AddText(artist.Value, sprite => { sprite.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); sprite.Colour = artistColour; @@ -120,25 +89,13 @@ namespace osu.Game.Overlays.Music }); } - protected override bool OnHover(HoverEvent e) - { - handle.FadeIn(fade_duration); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - handle.FadeOut(fade_duration); - } - protected override bool OnClick(ClickEvent e) { - OnSelect?.Invoke(BeatmapSetInfo); + RequestSelection?.Invoke(Model); return true; } - public IEnumerable FilterTerms { get; private set; } + public IEnumerable FilterTerms { get; } private bool matching = true; @@ -156,28 +113,5 @@ namespace osu.Game.Overlays.Music } public bool FilteringActive { get; set; } - - private class PlaylistItemHandle : SpriteIcon - { - public PlaylistItemHandle() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - Size = new Vector2(12); - Icon = FontAwesome.Solid.Bars; - Alpha = 0f; - Margin = new MarginPadding { Left = 5 }; - } - - public override bool HandlePositionalInput => IsPresent; - } - } - - public interface IDraggable : IDrawable - { - /// - /// Whether this can be dragged in its current state. - /// - bool IsDraggable { get; } } } diff --git a/osu.Game/Overlays/Music/PlaylistList.cs b/osu.Game/Overlays/Music/PlaylistList.cs deleted file mode 100644 index 3cd04ac809..0000000000 --- a/osu.Game/Overlays/Music/PlaylistList.cs +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osuTK; - -namespace osu.Game.Overlays.Music -{ - public class PlaylistList : CompositeDrawable - { - public Action Selected; - - private readonly ItemsScrollContainer items; - - public PlaylistList() - { - InternalChild = items = new ItemsScrollContainer - { - RelativeSizeAxes = Axes.Both, - Selected = set => Selected?.Invoke(set), - }; - } - - public new MarginPadding Padding - { - get => base.Padding; - set => base.Padding = value; - } - - public BeatmapSetInfo FirstVisibleSet => items.FirstVisibleSet; - - public void Filter(string searchTerm) => items.SearchTerm = searchTerm; - - private class ItemsScrollContainer : OsuScrollContainer - { - public Action Selected; - - private readonly SearchContainer search; - private readonly FillFlowContainer items; - - private readonly IBindable beatmapBacking = new Bindable(); - - private IBindableList beatmaps; - - [Resolved] - private MusicController musicController { get; set; } - - public ItemsScrollContainer() - { - Children = new Drawable[] - { - search = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - items = new ItemSearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(IBindable beatmap) - { - beatmaps = musicController.BeatmapSets.GetBoundCopy(); - beatmaps.ItemsAdded += i => i.ForEach(addBeatmapSet); - beatmaps.ItemsRemoved += i => i.ForEach(removeBeatmapSet); - beatmaps.ForEach(addBeatmapSet); - - beatmapBacking.BindTo(beatmap); - beatmapBacking.ValueChanged += _ => Scheduler.AddOnce(updateSelectedSet); - } - - private void addBeatmapSet(BeatmapSetInfo obj) - { - if (obj == draggedItem?.BeatmapSetInfo) return; - - Schedule(() => items.Insert(items.Count - 1, new PlaylistItem(obj) { OnSelect = set => Selected?.Invoke(set) })); - } - - private void removeBeatmapSet(BeatmapSetInfo obj) - { - if (obj == draggedItem?.BeatmapSetInfo) return; - - Schedule(() => - { - var itemToRemove = items.FirstOrDefault(i => i.BeatmapSetInfo.ID == obj.ID); - if (itemToRemove != null) - items.Remove(itemToRemove); - }); - } - - private void updateSelectedSet() - { - foreach (PlaylistItem s in items.Children) - { - s.Selected = s.BeatmapSetInfo.ID == beatmapBacking.Value.BeatmapSetInfo?.ID; - if (s.Selected) - ScrollIntoView(s); - } - } - - public string SearchTerm - { - get => search.SearchTerm; - set => search.SearchTerm = value; - } - - public BeatmapSetInfo FirstVisibleSet => items.FirstOrDefault(i => i.MatchingFilter)?.BeatmapSetInfo; - - private Vector2 nativeDragPosition; - private PlaylistItem draggedItem; - - private int? dragDestination; - - protected override bool OnDragStart(DragStartEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - draggedItem = items.FirstOrDefault(d => d.IsDraggable); - return draggedItem != null || base.OnDragStart(e); - } - - protected override bool OnDrag(DragEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - if (draggedItem == null) - return base.OnDrag(e); - - return true; - } - - protected override bool OnDragEnd(DragEndEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - - if (draggedItem == null) - return base.OnDragEnd(e); - - if (dragDestination != null) - musicController.ChangeBeatmapSetPosition(draggedItem.BeatmapSetInfo, dragDestination.Value); - - draggedItem = null; - dragDestination = null; - - return true; - } - - protected override void Update() - { - base.Update(); - - if (draggedItem == null) - return; - - updateScrollPosition(); - updateDragPosition(); - } - - private void updateScrollPosition() - { - const float start_offset = 10; - const double max_power = 50; - const double exp_base = 1.05; - - var localPos = ToLocalSpace(nativeDragPosition); - - if (localPos.Y < start_offset) - { - if (Current <= 0) - return; - - var power = Math.Min(max_power, Math.Abs(start_offset - localPos.Y)); - ScrollBy(-(float)Math.Pow(exp_base, power)); - } - else if (localPos.Y > DrawHeight - start_offset) - { - if (IsScrolledToEnd()) - return; - - var power = Math.Min(max_power, Math.Abs(DrawHeight - start_offset - localPos.Y)); - ScrollBy((float)Math.Pow(exp_base, power)); - } - } - - private void updateDragPosition() - { - var itemsPos = items.ToLocalSpace(nativeDragPosition); - - int srcIndex = (int)items.GetLayoutPosition(draggedItem); - - // Find the last item with position < mouse position. Note we can't directly use - // the item positions as they are being transformed - float heightAccumulator = 0; - int dstIndex = 0; - - for (; dstIndex < items.Count; dstIndex++) - { - // Using BoundingBox here takes care of scale, paddings, etc... - heightAccumulator += items[dstIndex].BoundingBox.Height; - if (heightAccumulator > itemsPos.Y) - break; - } - - dstIndex = Math.Clamp(dstIndex, 0, items.Count - 1); - - if (srcIndex == dstIndex) - return; - - if (srcIndex < dstIndex) - { - for (int i = srcIndex + 1; i <= dstIndex; i++) - items.SetLayoutPosition(items[i], i - 1); - } - else - { - for (int i = dstIndex; i < srcIndex; i++) - items.SetLayoutPosition(items[i], i + 1); - } - - items.SetLayoutPosition(draggedItem, dstIndex); - dragDestination = dstIndex; - } - - private class ItemSearchContainer : FillFlowContainer, IHasFilterableChildren - { - public IEnumerable FilterTerms => Array.Empty(); - - public bool MatchingFilter - { - set - { - if (value) - InvalidateLayout(); - } - } - - public bool FilteringActive - { - set { } - } - - public IEnumerable FilterableChildren => Children; - - public ItemSearchContainer() - { - LayoutDuration = 200; - LayoutEasing = Easing.OutQuint; - } - } - } - } -} diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index b89a577282..b878aba489 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -21,17 +21,22 @@ namespace osu.Game.Overlays.Music private const float transition_duration = 600; private const float playlist_height = 510; + public IBindableList BeatmapSets => beatmapSets; + + private readonly BindableList beatmapSets = new BindableList(); + private readonly Bindable beatmap = new Bindable(); - private BeatmapManager beatmaps; + + [Resolved] + private BeatmapManager beatmaps { get; set; } private FilterControl filter; - private PlaylistList list; + private Playlist list; [BackgroundDependencyLoader] - private void load(OsuColour colours, Bindable beatmap, BeatmapManager beatmaps) + private void load(OsuColour colours, Bindable beatmap) { this.beatmap.BindTo(beatmap); - this.beatmaps = beatmaps; Children = new Drawable[] { @@ -53,11 +58,11 @@ namespace osu.Game.Overlays.Music Colour = colours.Gray3, RelativeSizeAxes = Axes.Both, }, - list = new PlaylistList + list = new Playlist { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 95, Bottom = 10, Right = 10 }, - Selected = itemSelected, + RequestSelection = itemSelected }, filter = new FilterControl { @@ -82,6 +87,14 @@ namespace osu.Game.Overlays.Music }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + list.Items.BindTo(beatmapSets); + beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo, true); + } + protected override void PopIn() { filter.Search.HoldFocus = true; diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index bafdad3508..546f7a1ec4 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -5,10 +5,11 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; @@ -25,7 +26,21 @@ namespace osu.Game.Overlays [Resolved] private BeatmapManager beatmaps { get; set; } - public IBindableList BeatmapSets => beatmapSets; + public IBindableList BeatmapSets + { + get + { + if (LoadState < LoadState.Ready) + throw new InvalidOperationException($"{nameof(BeatmapSets)} should not be accessed before the music controller is loaded."); + + return beatmapSets; + } + } + + /// + /// Point in time after which the current track will be restarted on triggering a "previous track" action. + /// + private const double restart_cutoff_point = 5000; private readonly BindableList beatmapSets = new BindableList(); @@ -46,19 +61,26 @@ namespace osu.Game.Overlays [Resolved(canBeNull: true)] private OnScreenDisplay onScreenDisplay { get; set; } + private IBindable> managerUpdated; + private IBindable> managerRemoved; + [BackgroundDependencyLoader] private void load() { - beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets().OrderBy(_ => RNG.Next())); - beatmaps.ItemAdded += handleBeatmapAdded; - beatmaps.ItemRemoved += handleBeatmapRemoved; + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(beatmapRemoved); + + beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal, true).OrderBy(_ => RNG.Next())); } protected override void LoadComplete() { + base.LoadComplete(); + beatmap.BindValueChanged(beatmapChanged, true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true); - base.LoadComplete(); } /// @@ -77,11 +99,28 @@ namespace osu.Game.Overlays /// public bool IsPlaying => current?.Track.IsRunning ?? false; - private void handleBeatmapAdded(BeatmapSetInfo set) => - Schedule(() => beatmapSets.Add(set)); + private void beatmapUpdated(ValueChangedEvent> weakSet) + { + if (weakSet.NewValue.TryGetTarget(out var set)) + { + Schedule(() => + { + beatmapSets.Remove(set); + beatmapSets.Add(set); + }); + } + } - private void handleBeatmapRemoved(BeatmapSetInfo set) => - Schedule(() => beatmapSets.RemoveAll(s => s.ID == set.ID)); + private void beatmapRemoved(ValueChangedEvent> weakSet) + { + if (weakSet.NewValue.TryGetTarget(out var set)) + { + Schedule(() => + { + beatmapSets.RemoveAll(s => s.ID == set.ID); + }); + } + } private ScheduledDelegate seekDelegate; @@ -95,6 +134,29 @@ namespace osu.Game.Overlays }); } + /// + /// Ensures music is playing, no matter what, unless the user has explicitly paused. + /// This means that if the current beatmap has a virtual track (see ) a new beatmap will be selected. + /// + public void EnsurePlayingSomething() + { + if (IsUserPaused) return; + + var track = current?.Track; + + if (track == null || track is TrackVirtual) + { + if (beatmap.Disabled) + return; + + NextTrack(); + } + else if (!IsPlaying) + { + Play(); + } + } + /// /// Start playing the current track (if not already playing). /// @@ -106,13 +168,7 @@ namespace osu.Game.Overlays IsUserPaused = false; if (track == null) - { - if (beatmap.Disabled) - return false; - - next(true); - return true; - } + return false; if (restart) track.Restart(); @@ -151,11 +207,27 @@ namespace osu.Game.Overlays } /// - /// Play the previous track. + /// Play the previous track or restart the current track if it's current time below . /// - /// Whether the operation was successful. - public bool PrevTrack() + public void PreviousTrack() => Schedule(() => prev()); + + /// + /// Play the previous track or restart the current track if it's current time below . + /// + /// The that indicate the decided action. + private PreviousTrackResult prev() { + if (beatmap.Disabled) + return PreviousTrackResult.None; + + var currentTrackPosition = current?.Track.CurrentTime; + + if (currentTrackPosition >= restart_cutoff_point) + { + SeekTo(0); + return PreviousTrackResult.Restart; + } + queuedDirection = TrackChangeDirection.Prev; var playable = BeatmapSets.TakeWhile(i => i.ID != current.BeatmapSetInfo.ID).LastOrDefault() ?? BeatmapSets.LastOrDefault(); @@ -166,24 +238,25 @@ namespace osu.Game.Overlays working.Value = beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value); beatmap.Value.Track.Restart(); - return true; + return PreviousTrackResult.Previous; } - return false; + return PreviousTrackResult.None; } /// /// Play the next random or playlist track. /// - /// Whether the operation was successful. - public bool NextTrack() => next(); + public void NextTrack() => Schedule(() => next()); - private bool next(bool instant = false) + private bool next() { - if (!instant) - queuedDirection = TrackChangeDirection.Next; + if (beatmap.Disabled) + return false; - var playable = BeatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).Skip(1).FirstOrDefault() ?? BeatmapSets.FirstOrDefault(); + queuedDirection = TrackChangeDirection.Next; + + var playable = BeatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).ElementAtOrDefault(1) ?? BeatmapSets.FirstOrDefault(); if (playable != null) { @@ -217,7 +290,7 @@ namespace osu.Game.Overlays } else { - //figure out the best direction based on order in playlist. + // figure out the best direction based on order in playlist. var last = BeatmapSets.TakeWhile(b => b.ID != current.BeatmapSetInfo?.ID).Count(); var next = beatmap.NewValue == null ? -1 : BeatmapSets.TakeWhile(b => b.ID != beatmap.NewValue.BeatmapSetInfo?.ID).Count(); @@ -266,17 +339,6 @@ namespace osu.Game.Overlays } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmaps != null) - { - beatmaps.ItemAdded -= handleBeatmapAdded; - beatmaps.ItemRemoved -= handleBeatmapRemoved; - } - } - public bool OnPressed(GlobalAction action) { if (beatmap.Disabled) @@ -290,14 +352,22 @@ namespace osu.Game.Overlays return true; case GlobalAction.MusicNext: - if (NextTrack()) + if (next()) onScreenDisplay?.Display(new MusicControllerToast("Next track")); return true; case GlobalAction.MusicPrev: - if (PrevTrack()) - onScreenDisplay?.Display(new MusicControllerToast("Previous track")); + switch (prev()) + { + case PreviousTrackResult.Restart: + onScreenDisplay?.Display(new MusicControllerToast("Restart track")); + break; + + case PreviousTrackResult.Previous: + onScreenDisplay?.Display(new MusicControllerToast("Previous track")); + break; + } return true; } @@ -305,7 +375,9 @@ namespace osu.Game.Overlays return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } public class MusicControllerToast : Toast { @@ -322,4 +394,11 @@ namespace osu.Game.Overlays Next, Prev } + + public enum PreviousTrackResult + { + None, + Restart, + Previous + } } diff --git a/osu.Game/Overlays/News/NewsArticleCover.cs b/osu.Game/Overlays/News/NewsArticleCover.cs index e484309a18..e3f5a8cea3 100644 --- a/osu.Game/Overlays/News/NewsArticleCover.cs +++ b/osu.Game/Overlays/News/NewsArticleCover.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -19,6 +20,10 @@ namespace osu.Game.Overlays.News { public class NewsArticleCover : Container { + private const int hover_duration = 300; + + private readonly Box gradient; + public NewsArticleCover(ArticleInfo info) { RelativeSizeAxes = Axes.X; @@ -47,11 +52,11 @@ namespace osu.Game.Overlays.News Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, - new Box + gradient = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.1f), Color4.Black.Opacity(0.6f)), - Alpha = 1f, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.1f), Color4.Black.Opacity(0.7f)), + Alpha = 0 }, new DateContainer(info.Time) { @@ -70,7 +75,7 @@ namespace osu.Game.Overlays.News Left = 25, Bottom = 50, }, - Font = OsuFont.GetFont(Typeface.Exo, 24, FontWeight.Bold), + Font = OsuFont.GetFont(Typeface.Torus, 24, FontWeight.Bold), Text = info.Title, }, new OsuSpriteText @@ -82,7 +87,7 @@ namespace osu.Game.Overlays.News Left = 25, Bottom = 30, }, - Font = OsuFont.GetFont(Typeface.Exo, 16, FontWeight.Bold), + Font = OsuFont.GetFont(Typeface.Torus, 16, FontWeight.Bold), Text = "by " + info.Author } }; @@ -90,6 +95,18 @@ namespace osu.Game.Overlays.News bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); } + protected override bool OnHover(HoverEvent e) + { + gradient.FadeIn(hover_duration, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + gradient.FadeOut(hover_duration, Easing.OutQuint); + } + [LongRunningLoad] private class NewsBackground : Sprite { @@ -131,7 +148,7 @@ namespace osu.Game.Overlays.News { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(Typeface.Exo, 12, FontWeight.Black, false, false), + Font = OsuFont.GetFont(Typeface.Torus, 12, FontWeight.Bold, false, false), Text = date.ToString("d MMM yyy").ToUpper(), Margin = new MarginPadding { @@ -145,7 +162,7 @@ namespace osu.Game.Overlays.News public string TooltipText => date.ToString("dddd dd MMMM yyyy hh:mm:ss UTCz").ToUpper(); } - //fake API data struct to use for now as a skeleton for data, as there is no API struct for news article info for now + // fake API data struct to use for now as a skeleton for data, as there is no API struct for news article info for now public class ArticleInfo { public string Title { get; set; } diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs new file mode 100644 index 0000000000..9c478a7c1d --- /dev/null +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -0,0 +1,198 @@ +// Copyright (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.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.News +{ + public class NewsCard : CompositeDrawable + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly APINewsPost post; + + private Box background; + private TextFlowContainer main; + + public NewsCard(APINewsPost post) + { + this.post = post; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 6; + + NewsBackground bg; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = 160, + Masking = true, + CornerRadius = 6, + Children = new Drawable[] + { + new DelayedLoadWrapper(bg = new NewsBackground(post.FirstImage) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0 + }) + { + RelativeSizeAxes = Axes.Both + }, + new DateContainer(post.PublishedAt) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding + { + Top = 10, + Right = 15 + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 15, + Vertical = 10 + }, + Child = main = new TextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + new HoverClickSounds() + }; + + 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)); + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeColour(colourProvider.Background3, 200, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeColour(colourProvider.Background4, 200, Easing.OutQuint); + base.OnHoverLost(e); + } + + [LongRunningLoad] + private class NewsBackground : Sprite + { + private readonly string sourceUrl; + + public NewsBackground(string sourceUrl) + { + this.sourceUrl = sourceUrl; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore store) + { + Texture = store.Get(createUrl(sourceUrl)); + } + + private string createUrl(string source) + { + if (string.IsNullOrEmpty(source)) + return "Headers/news"; + + if (source.StartsWith('/')) + return "https://osu.ppy.sh" + source; + + return source; + } + } + + private class DateContainer : CircularContainer, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => date; + + private readonly DateTimeOffset date; + + public DateContainer(DateTimeOffset date) + { + this.date = date; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6.Opacity(0.5f) + }, + new OsuSpriteText + { + Text = date.ToString("d MMM yyyy").ToUpper(), + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Margin = new MarginPadding + { + Horizontal = 20, + Vertical = 5 + } + } + }; + } + } + } +} diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index fc88c86df2..8214c71b3a 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -1,13 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; using System; namespace osu.Game.Overlays.News @@ -16,92 +11,49 @@ namespace osu.Game.Overlays.News { private const string front_page_string = "frontpage"; - private NewsHeaderTitle title; - - public readonly Bindable Current = new Bindable(null); + public readonly Bindable Post = new Bindable(null); public Action ShowFrontPage; public NewsHeader() { - BreadcrumbControl.AddItem(front_page_string); + TabControl.AddItem(front_page_string); - BreadcrumbControl.Current.ValueChanged += e => + Current.ValueChanged += e => { if (e.NewValue == front_page_string) ShowFrontPage?.Invoke(); }; - Current.ValueChanged += showPost; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BreadcrumbControl.AccentColour = colours.Violet; - TitleBackgroundColour = colours.GreyVioletDarker; - ControlBackgroundColour = colours.GreyVioletDark; + Post.ValueChanged += showPost; } private void showPost(ValueChangedEvent e) { if (e.OldValue != null) - BreadcrumbControl.RemoveItem(e.OldValue); + TabControl.RemoveItem(e.OldValue); if (e.NewValue != null) { - BreadcrumbControl.AddItem(e.NewValue); - BreadcrumbControl.Current.Value = e.NewValue; - - title.IsReadingPost = true; + TabControl.AddItem(e.NewValue); + Current.Value = e.NewValue; } else { - BreadcrumbControl.Current.Value = front_page_string; - title.IsReadingPost = false; + Current.Value = front_page_string; } } - protected override Drawable CreateBackground() => new NewsHeaderBackground(); + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/news"); - protected override ScreenTitle CreateTitle() => title = new NewsHeaderTitle(); + protected override OverlayTitle CreateTitle() => new NewsHeaderTitle(); - private class NewsHeaderBackground : Sprite + private class NewsHeaderTitle : OverlayTitle { - public NewsHeaderBackground() - { - RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fill; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Texture = textures.Get(@"Headers/news"); - } - } - - private class NewsHeaderTitle : ScreenTitle - { - private const string post_string = "post"; - - public bool IsReadingPost - { - set => Section = value ? post_string : front_page_string; - } - public NewsHeaderTitle() { Title = "news"; - IsReadingPost = false; - } - - protected override Drawable CreateIcon() => new ScreenTitleTextureIcon(@"Icons/news"); - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Violet; + IconTexture = "Icons/news"; } } } diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index e7471cb21d..46d692d44d 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -8,7 +8,6 @@ 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.Overlays.News; namespace osu.Game.Overlays @@ -21,6 +20,11 @@ namespace osu.Game.Overlays public readonly Bindable Current = new Bindable(null); + public NewsOverlay() + : base(OverlayColourScheme.Purple) + { + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -31,7 +35,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = colours.PurpleDarkAlternative }, - new OsuScrollContainer + new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, Child = new FillFlowContainer @@ -55,7 +59,7 @@ namespace osu.Game.Overlays }, }; - header.Current.BindTo(Current); + header.Post.BindTo(Current); Current.TriggerChange(); } diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index 17a2d4cf9f..c2a958b65e 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -84,13 +84,13 @@ namespace osu.Game.Overlays.Notifications new OsuSpriteText { Text = titleText.ToUpperInvariant(), - Font = OsuFont.GetFont(weight: FontWeight.Black) + Font = OsuFont.GetFont(weight: FontWeight.Bold) }, countDrawable = new OsuSpriteText { Text = "3", Colour = colours.Yellow, - Font = OsuFont.GetFont(weight: FontWeight.Black) + Font = OsuFont.GetFont(weight: FontWeight.Bold) }, } }, diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 99836705c4..3105ecd742 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Notifications { base.LoadComplete(); - //we may have received changes before we were displayed. + // we may have received changes before we were displayed. updateState(); } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index de30e1a754..ebb4a96d14 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -58,6 +58,9 @@ namespace osu.Game.Overlays [Resolved] private Bindable beatmap { get; set; } + [Resolved] + private OsuColour colours { get; set; } + public NowPlayingOverlay() { Width = 400; @@ -65,7 +68,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Children = new Drawable[] { @@ -137,7 +140,7 @@ namespace osu.Game.Overlays { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Action = () => musicController.PrevTrack(), + Action = () => musicController.PreviousTrack(), Icon = FontAwesome.Solid.StepBackward, }, playButton = new MusicIconButton @@ -182,14 +185,15 @@ namespace osu.Game.Overlays } } }; - - playlist.State.ValueChanged += s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint); } protected override void LoadComplete() { base.LoadComplete(); + playlist.BeatmapSets.BindTo(musicController.BeatmapSets); + playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + beatmap.BindDisabledChanged(beatmapDisabledChanged, true); musicController.TrackChanged += trackChanged; @@ -257,7 +261,7 @@ namespace osu.Game.Overlays // todo: this can likely be replaced with WorkingBeatmap.GetBeatmapAsync() Task.Run(() => { - if (beatmap?.Beatmap == null) //this is not needed if a placeholder exists + if (beatmap?.Beatmap == null) // this is not needed if a placeholder exists { title.Text = @"Nothing to play"; artist.Text = @"Nothing to play"; @@ -385,7 +389,7 @@ namespace osu.Game.Overlays return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { Vector2 change = e.MousePosition - e.MouseDownPosition; @@ -393,13 +397,12 @@ namespace osu.Game.Overlays change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.7f) / change.Length; this.MoveTo(change); - return true; } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); - return base.OnDragEnd(e); + base.OnDragEnd(e); } } diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 46c53ec409..1497ca8fa8 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -31,7 +31,7 @@ namespace osu.Game.Overlays.OSD InternalChildren = new Drawable[] { - new Container //this container exists just to set a minimum width for the toast + new Container // this container exists just to set a minimum width for the toast { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -53,7 +53,7 @@ namespace osu.Game.Overlays.OSD { Padding = new MarginPadding(10), Name = "Description", - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Black), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), Spacing = new Vector2(1, 0), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs new file mode 100644 index 0000000000..9816f313ad --- /dev/null +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public class OverlayColourProvider + { + private readonly OverlayColourScheme colourScheme; + + public OverlayColourProvider(OverlayColourScheme colourScheme) + { + this.colourScheme = colourScheme; + } + + public Color4 Highlight1 => getColour(1, 0.7f); + public Color4 Content1 => getColour(0.4f, 1); + public Color4 Content2 => getColour(0.4f, 0.9f); + public Color4 Light1 => getColour(0.4f, 0.8f); + public Color4 Light2 => getColour(0.4f, 0.75f); + public Color4 Light3 => getColour(0.4f, 0.7f); + public Color4 Light4 => getColour(0.4f, 0.5f); + public Color4 Dark1 => getColour(0.2f, 0.35f); + public Color4 Dark2 => getColour(0.2f, 0.3f); + public Color4 Dark3 => getColour(0.2f, 0.25f); + public Color4 Dark4 => getColour(0.2f, 0.2f); + public Color4 Dark5 => getColour(0.2f, 0.15f); + public Color4 Dark6 => getColour(0.2f, 0.1f); + public Color4 Foreground1 => getColour(0.1f, 0.6f); + public Color4 Background1 => getColour(0.1f, 0.4f); + public Color4 Background2 => getColour(0.1f, 0.3f); + public Color4 Background3 => getColour(0.1f, 0.25f); + public Color4 Background4 => getColour(0.1f, 0.2f); + public Color4 Background5 => getColour(0.1f, 0.15f); + public Color4 Background6 => getColour(0.1f, 0.1f); + + private Color4 getColour(float saturation, float lightness) => Color4.FromHsl(new Vector4(getBaseHue(colourScheme), saturation, lightness, 1)); + + // See https://github.com/ppy/osu-web/blob/4218c288292d7c810b619075471eaea8bbb8f9d8/app/helpers.php#L1463 + private static float getBaseHue(OverlayColourScheme colourScheme) + { + switch (colourScheme) + { + default: + throw new ArgumentException($@"{colourScheme} colour scheme does not provide a hue value in {nameof(getBaseHue)}."); + + case OverlayColourScheme.Red: + return 0; + + case OverlayColourScheme.Pink: + return 333 / 360f; + + case OverlayColourScheme.Orange: + return 46 / 360f; + + case OverlayColourScheme.Green: + return 115 / 360f; + + case OverlayColourScheme.Purple: + return 255 / 360f; + + case OverlayColourScheme.Blue: + return 200 / 360f; + } + } + } + + public enum OverlayColourScheme + { + Red, + Pink, + Orange, + Green, + Purple, + Blue + } +} diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index 53da2da634..dbc934bde9 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -2,35 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.UserInterface; using osuTK.Graphics; namespace osu.Game.Overlays { public abstract class OverlayHeader : Container { + public const int CONTENT_X_MARGIN = 50; + private readonly Box titleBackground; - private readonly Box controlBackground; - private readonly Container background; - protected Color4 TitleBackgroundColour - { - set => titleBackground.Colour = value; - } - - protected Color4 ControlBackgroundColour - { - set => controlBackground.Colour = value; - } - - protected float BackgroundHeight - { - set => background.Height = value; - } + protected readonly FillFlowContainer HeaderInfo; protected OverlayHeader() { @@ -44,47 +30,50 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, Children = new[] { - background = new Container - { - RelativeSizeAxes = Axes.X, - Height = 80, - Masking = true, - Child = CreateBackground() - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - titleBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Gray, - }, - CreateTitle().With(title => - { - title.Margin = new MarginPadding - { - Vertical = 10, - Left = UserProfileOverlay.CONTENT_X_MARGIN - }; - }) - } - }, - new Container + HeaderInfo = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, Depth = -float.MaxValue, - Children = new Drawable[] + Children = new[] { - controlBackground = new Box + CreateBackground(), + new Container { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Gray, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + titleBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = CONTENT_X_MARGIN, + }, + Children = new[] + { + CreateTitle().With(title => + { + title.Anchor = Anchor.CentreLeft; + title.Origin = Anchor.CentreLeft; + }), + CreateTitleContent().With(content => + { + content.Anchor = Anchor.CentreRight; + content.Origin = Anchor.CentreRight; + }) + } + } + } }, - CreateTabControl().With(control => control.Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }) } }, CreateContent() @@ -92,13 +81,24 @@ namespace osu.Game.Overlays }); } - protected abstract Drawable CreateBackground(); + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + titleBackground.Colour = colourProvider.Dark5; + } [NotNull] - protected virtual Drawable CreateContent() => new Container(); + protected virtual Drawable CreateContent() => Empty(); - protected abstract ScreenTitle CreateTitle(); + [NotNull] + protected virtual Drawable CreateBackground() => Empty(); - protected abstract TabControl CreateTabControl(); + /// + /// 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 new file mode 100644 index 0000000000..2fef593285 --- /dev/null +++ b/osu.Game/Overlays/OverlayHeaderBackground.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Overlays +{ + public class OverlayHeaderBackground : CompositeDrawable + { + public OverlayHeaderBackground(string textureName) + { + Height = 80; + RelativeSizeAxes = Axes.X; + Masking = true; + InternalChild = new Background(textureName); + } + + private class Background : Sprite + { + private readonly string textureName; + + public Background(string textureName) + { + this.textureName = textureName; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fill; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(textureName); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs new file mode 100644 index 0000000000..7269007b41 --- /dev/null +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.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 osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osuTK.Graphics; +using osu.Framework.Graphics.Cursor; + +namespace osu.Game.Overlays +{ + public class OverlayPanelDisplayStyleControl : OsuTabControl + { + protected override Dropdown CreateDropdown() => null; + + protected override TabItem CreateTabItem(OverlayPanelDisplayStyle value) => new PanelDisplayTabItem(value); + + protected override bool AddEnumEntriesAutomatically => false; + + public OverlayPanelDisplayStyleControl() + { + AutoSizeAxes = Axes.Both; + + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Card) + { + Icon = FontAwesome.Solid.Square + }); + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.List) + { + Icon = FontAwesome.Solid.Bars + }); + } + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal + }; + + private class PanelDisplayTabItem : TabItem, IHasTooltip + { + public IconUsage Icon + { + set => icon.Icon = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public string TooltipText => $@"{Value} view"; + + private readonly SpriteIcon icon; + + public PanelDisplayTabItem(OverlayPanelDisplayStyle value) + : base(value) + { + Size = new Vector2(11); + AddRange(new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit + }, + new HoverClickSounds() + }); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() => icon.Colour = Active.Value || IsHovered ? colourProvider.Light1 : Color4.White; + } + } + + public enum OverlayPanelDisplayStyle + { + Card, + List + } +} diff --git a/osu.Game/Overlays/OverlayRulesetSelector.cs b/osu.Game/Overlays/OverlayRulesetSelector.cs new file mode 100644 index 0000000000..8c44157f78 --- /dev/null +++ b/osu.Game/Overlays/OverlayRulesetSelector.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.UserInterface; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Overlays +{ + public class OverlayRulesetSelector : RulesetSelector + { + public OverlayRulesetSelector() + { + AutoSizeAxes = Axes.Both; + } + + protected override TabItem CreateTabItem(RulesetInfo value) => new OverlayRulesetTabItem(value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20, 0), + }; + } +} diff --git a/osu.Game/Overlays/OverlayRulesetTabItem.cs b/osu.Game/Overlays/OverlayRulesetTabItem.cs new file mode 100644 index 0000000000..9d4afc94d1 --- /dev/null +++ b/osu.Game/Overlays/OverlayRulesetTabItem.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK.Graphics; +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; + +namespace osu.Game.Overlays +{ + public class OverlayRulesetTabItem : TabItem + { + private Color4 accentColour; + + protected virtual Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + text.FadeColour(value, 120, Easing.OutQuint); + } + } + + protected override Container Content { get; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly OsuSpriteText text; + + public OverlayRulesetTabItem(RulesetInfo value) + : base(value) + { + AutoSizeAxes = Axes.Both; + + AddRangeInternal(new Drawable[] + { + Content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Child = text = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Text = value.Name, + Font = OsuFont.GetFont(size: 14), + ShadowColour = Color4.Black.Opacity(0.75f) + } + }, + new HoverClickSounds() + }); + + Enabled.Value = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateState(), true); + } + + public override bool PropagatePositionalInputSubTree => Enabled.Value && !Active.Value && base.PropagatePositionalInputSubTree; + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + private void updateState() + { + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Medium); + AccentColour = Enabled.Value ? getActiveColour() : colourProvider.Foreground1; + } + + private Color4 getActiveColour() => IsHovered || Active.Value ? Color4.White : colourProvider.Highlight1; + } +} diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs new file mode 100644 index 0000000000..e7415e6f74 --- /dev/null +++ b/osu.Game/Overlays/OverlayScrollContainer.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.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + /// + /// which provides . Mostly used in . + /// + public class OverlayScrollContainer : OsuScrollContainer + { + /// + /// Scroll position at which the will be shown. + /// + private const int button_scroll_position = 200; + + protected readonly ScrollToTopButton Button; + + public OverlayScrollContainer() + { + AddInternal(Button = new ScrollToTopButton + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(20), + Action = () => + { + ScrollToStart(); + Button.State = Visibility.Hidden; + } + }); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (ScrollContent.DrawHeight + button_scroll_position < DrawHeight) + { + Button.State = Visibility.Hidden; + return; + } + + Button.State = Target > button_scroll_position ? Visibility.Visible : Visibility.Hidden; + } + + public class ScrollToTopButton : OsuHoverContainer + { + private const int fade_duration = 500; + + private Visibility state; + + public Visibility State + { + get => state; + set + { + if (value == state) + return; + + state = value; + Enabled.Value = state == Visibility.Visible; + this.FadeTo(state == Visibility.Visible ? 1 : 0, fade_duration, Easing.OutQuint); + } + } + + protected override IEnumerable EffectTargets => new[] { background }; + + private Color4 flashColour; + + private readonly Container content; + private readonly Box background; + + public ScrollToTopButton() + { + Size = new Vector2(50); + Alpha = 0; + Add(content = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0f, 1f), + Radius = 3f, + Colour = Color4.Black.Opacity(0.25f), + }, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(15), + Icon = FontAwesome.Solid.ChevronUp + } + } + }); + + TooltipText = "Scroll to top"; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background6; + HoverColour = colourProvider.Background5; + flashColour = colourProvider.Light1; + } + + protected override bool OnClick(ClickEvent e) + { + background.FlashColour(flashColour, 800, Easing.OutQuint); + return base.OnClick(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + content.ScaleTo(0.75f, 2000, Easing.OutQuint); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + content.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + } + } +} diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs new file mode 100644 index 0000000000..b2212336ef --- /dev/null +++ b/osu.Game/Overlays/OverlaySortTabControl.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.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Bindables; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; +using osu.Game.Overlays.Comments; +using JetBrains.Annotations; +using System; +using osu.Framework.Extensions; + +namespace osu.Game.Overlays +{ + public class OverlaySortTabControl : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public string Title + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public OverlaySortTabControl() + { + AutoSizeAxes = Axes.Both; + AddInternal(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = @"Sort by" + }, + CreateControl().With(c => + { + c.Anchor = Anchor.CentreLeft; + c.Origin = Anchor.CentreLeft; + c.Current = current; + }) + } + }); + } + + [NotNull] + protected virtual SortTabControl CreateControl() => new SortTabControl(); + + protected class SortTabControl : OsuTabControl + { + protected override Dropdown CreateDropdown() => null; + + protected override TabItem CreateTabItem(T value) => new SortTabItem(value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + }; + + public SortTabControl() + { + AutoSizeAxes = Axes.Both; + } + } + + protected class SortTabItem : TabItem + { + public SortTabItem(T value) + : base(value) + { + AutoSizeAxes = Axes.Both; + Child = CreateTabButton(value); + } + + [NotNull] + protected virtual TabButton CreateTabButton(T value) => new TabButton(value) + { + Active = { BindTarget = Active } + }; + + protected override void OnActivated() + { + } + + protected override void OnDeactivated() + { + } + } + + protected class TabButton : HeaderButton + { + public readonly BindableBool Active = new BindableBool(); + + protected override Container Content => content; + + protected virtual Color4 ContentColour + { + set => text.Colour = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly SpriteText text; + private readonly FillFlowContainer content; + + public TabButton(T value) + { + base.Content.Add(content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = (value as Enum)?.GetDescription() ?? value.ToString() + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Active.BindValueChanged(_ => UpdateState(), true); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => UpdateState(); + + protected virtual void UpdateState() + { + if (Active.Value || IsHovered) + ShowBackground(); + else + HideBackground(); + + ContentColour = Active.Value && !IsHovered ? colourProvider.Light1 : Color4.White; + + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.SemiBold); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayStreamControl.cs b/osu.Game/Overlays/OverlayStreamControl.cs new file mode 100644 index 0000000000..8b6aca6d5d --- /dev/null +++ b/osu.Game/Overlays/OverlayStreamControl.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 osu.Framework.Graphics; +using osu.Framework.Input.Events; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics.UserInterface; +using JetBrains.Annotations; + +namespace osu.Game.Overlays +{ + public abstract class OverlayStreamControl : TabControl + { + protected OverlayStreamControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + public void Populate(List streams) => streams.ForEach(AddItem); + + protected override Dropdown CreateDropdown() => null; + + protected override TabItem CreateTabItem(T value) => CreateStreamItem(value).With(item => + { + item.SelectedItem.BindTo(Current); + }); + + [NotNull] + protected abstract OverlayStreamItem CreateStreamItem(T value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AllowMultiline = true, + }; + + protected override bool OnHover(HoverEvent e) + { + foreach (var streamBadge in TabContainer.Children.OfType>()) + streamBadge.UserHoveringArea = true; + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + foreach (var streamBadge in TabContainer.Children.OfType>()) + streamBadge.UserHoveringArea = false; + + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs new file mode 100644 index 0000000000..7f8559e7de --- /dev/null +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public abstract class OverlayStreamItem : TabItem + { + public readonly Bindable SelectedItem = new Bindable(); + + private bool userHoveringArea; + + public bool UserHoveringArea + { + set + { + if (value == userHoveringArea) + return; + + userHoveringArea = value; + updateState(); + } + } + + private FillFlowContainer text; + private ExpandingBar expandingBar; + + protected OverlayStreamItem(T value) + : base(value) + { + Height = 60; + Width = 100; + Padding = new MarginPadding(5); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + AddRange(new Drawable[] + { + text = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Top = 6 }, + Children = new[] + { + new OsuSpriteText + { + Text = MainText, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }, + new OsuSpriteText + { + Text = AdditionalText, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + }, + new OsuSpriteText + { + Text = InfoText, + Font = OsuFont.GetFont(size: 10), + Colour = colourProvider.Foreground1 + }, + } + }, + expandingBar = new ExpandingBar + { + Anchor = Anchor.TopCentre, + Colour = GetBarColour(colours), + ExpandedSize = 4, + CollapsedSize = 2, + Expanded = true + }, + new HoverClickSounds() + }); + + SelectedItem.BindValueChanged(_ => updateState(), true); + } + + protected abstract string MainText { get; } + + protected abstract string AdditionalText { get; } + + protected virtual string InfoText => string.Empty; + + protected abstract Color4 GetBarColour(OsuColour colours); + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + // highlighted regardless if we are hovered + bool textHighlighted = IsHovered; + bool barExpanded = IsHovered; + + if (SelectedItem.Value == null) + { + // at listing, all badges are highlighted when user is not hovering any badge. + textHighlighted |= !userHoveringArea; + barExpanded |= !userHoveringArea; + } + else + { + // bar is always expanded when active + barExpanded |= Active.Value; + + // text is highlighted only when hovered or active (but not if in selection mode) + textHighlighted |= Active.Value && !userHoveringArea; + } + + expandingBar.Expanded = barExpanded; + text.FadeTo(textHighlighted ? 1 : 0.5f, 100, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/OverlayTabControl.cs b/osu.Game/Overlays/OverlayTabControl.cs index 812f8963c9..a1cbf2c1e7 100644 --- a/osu.Game/Overlays/OverlayTabControl.cs +++ b/osu.Game/Overlays/OverlayTabControl.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; @@ -13,60 +14,49 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class OverlayTabControl : TabControl + public abstract class OverlayTabControl : OsuTabControl { private readonly Box bar; - private Color4 accentColour = Color4.White; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - bar.Colour = value; - - foreach (TabItem tabItem in TabContainer) - { - ((OverlayTabItem)tabItem).AccentColour = value; - } - } - } - - public new MarginPadding Padding - { - get => TabContainer.Padding; - set => TabContainer.Padding = value; - } - protected float BarHeight { set => bar.Height = value; } + public override Color4 AccentColour + { + get => base.AccentColour; + set + { + base.AccentColour = value; + bar.Colour = value; + } + } + protected OverlayTabControl() { TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(15, 0); + TabContainer.Spacing = new Vector2(20, 0); AddInternal(bar = new Box { RelativeSizeAxes = Axes.X, - Height = 2, Anchor = Anchor.BottomLeft, - Origin = Anchor.CentreLeft + Origin = Anchor.BottomLeft }); } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AccentColour = colourProvider.Highlight1; + } + protected override Dropdown CreateDropdown() => null; protected override TabItem CreateTabItem(T value) => new OverlayTabItem(value); - protected class OverlayTabItem : TabItem + protected class OverlayTabItem : TabItem, IHasAccentColour { protected readonly ExpandingBar Bar; protected readonly OsuSpriteText Text; @@ -106,7 +96,7 @@ namespace osu.Game.Overlays Bar = new ExpandingBar { Anchor = Anchor.BottomCentre, - ExpandedSize = 7.5f, + ExpandedSize = 5f, CollapsedSize = 0 }, new HoverClickSounds() @@ -135,6 +125,7 @@ namespace osu.Game.Overlays { HoverAction(); Text.Font = Text.Font.With(weight: FontWeight.Bold); + Text.FadeColour(Color4.White, 120, Easing.InQuad); } protected override void OnDeactivated() @@ -151,11 +142,7 @@ namespace osu.Game.Overlays OnDeactivated(); } - protected virtual void HoverAction() - { - Bar.Expand(); - Text.FadeColour(Color4.White, 120, Easing.InQuad); - } + protected virtual void HoverAction() => Bar.Expand(); protected virtual void UnhoverAction() { diff --git a/osu.Game/Overlays/OverlayTitle.cs b/osu.Game/Overlays/OverlayTitle.cs new file mode 100644 index 0000000000..1c9567428c --- /dev/null +++ b/osu.Game/Overlays/OverlayTitle.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays +{ + public abstract class OverlayTitle : CompositeDrawable + { + private readonly OsuSpriteText title; + private readonly Container icon; + + protected string Title + { + set => title.Text = value; + } + + protected string IconTexture + { + set => icon.Child = new OverlayTitleIcon(value); + } + + protected OverlayTitle() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + icon = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 5 }, // compensates for osu-web sprites having around 5px of whitespace on each side + Size = new Vector2(30) + }, + title = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Regular), + Margin = new MarginPadding { Vertical = 17.5f } // 15px padding + 2.5px line-height difference compensation + } + } + }; + } + + private class OverlayTitleIcon : Sprite + { + private readonly string textureName; + + public OverlayTitleIcon(string textureName) + { + this.textureName = textureName; + + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + FillMode = FillMode.Fit; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(textureName); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayView.cs b/osu.Game/Overlays/OverlayView.cs new file mode 100644 index 0000000000..3e2c54c726 --- /dev/null +++ b/osu.Game/Overlays/OverlayView.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; + +namespace osu.Game.Overlays +{ + /// + /// A subview containing online content, to be displayed inside a . + /// + /// + /// Automatically performs a data fetch on load. + /// + /// The type of the API response. + public abstract class OverlayView : CompositeDrawable, IOnlineComponent + where T : class + { + [Resolved] + protected IAPIProvider API { get; private set; } + + private APIRequest request; + + protected OverlayView() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + API.Register(this); + } + + /// + /// Create the API request for fetching data. + /// + protected abstract APIRequest CreateRequest(); + + /// + /// Fired when results arrive from the main API request. + /// + /// + protected abstract void OnSuccess(T response); + + /// + /// Force a re-request for data from the API. + /// + protected void PerformFetch() + { + request?.Cancel(); + + request = CreateRequest(); + request.Success += response => Schedule(() => OnSuccess(response)); + + API.Queue(request); + } + + public virtual void APIStateChanged(IAPIProvider api, APIState state) + { + switch (state) + { + case APIState.Online: + PerformFetch(); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + API?.Unregister(this); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index 158641d816..c27b5f4b4a 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.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 System; @@ -33,16 +33,16 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - iconColour = colours.GreySeafoamLighter; + iconColour = colourProvider.Foreground1; InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDark, + Colour = colourProvider.Background4 }, new FillFlowContainer { @@ -82,7 +82,7 @@ namespace osu.Game.Overlays.Profile.Header else { topLinkContainer.AddText("Joined "); - topLinkContainer.AddText(new DrawableDate(user.JoinDate), embolden); + topLinkContainer.AddText(new DrawableDate(user.JoinDate, italic: false), embolden); } addSpacer(topLinkContainer); @@ -95,7 +95,7 @@ namespace osu.Game.Overlays.Profile.Header else if (user.LastVisit.HasValue) { topLinkContainer.AddText("Last seen "); - topLinkContainer.AddText(new DrawableDate(user.LastVisit.Value), embolden); + topLinkContainer.AddText(new DrawableDate(user.LastVisit.Value, italic: false), embolden); addSpacer(topLinkContainer); } @@ -111,34 +111,42 @@ namespace osu.Game.Overlays.Profile.Header topLinkContainer.AddText("Contributed "); topLinkContainer.AddLink($@"{user.PostCount:#,##0} forum posts", $"https://osu.ppy.sh/users/{user.Id}/posts", creationParameters: embolden); - string websiteWithoutProtcol = user.Website; + string websiteWithoutProtocol = user.Website; - if (!string.IsNullOrEmpty(websiteWithoutProtcol)) + if (!string.IsNullOrEmpty(websiteWithoutProtocol)) { - if (Uri.TryCreate(websiteWithoutProtcol, UriKind.Absolute, out var uri)) + if (Uri.TryCreate(websiteWithoutProtocol, UriKind.Absolute, out var uri)) { - websiteWithoutProtcol = uri.Host + uri.PathAndQuery + uri.Fragment; - websiteWithoutProtcol = websiteWithoutProtcol.TrimEnd('/'); + websiteWithoutProtocol = uri.Host + uri.PathAndQuery + uri.Fragment; + websiteWithoutProtocol = websiteWithoutProtocol.TrimEnd('/'); } } - tryAddInfo(FontAwesome.Solid.MapMarker, user.Location); - tryAddInfo(OsuIcon.Heart, user.Interests); - tryAddInfo(FontAwesome.Solid.Suitcase, user.Occupation); - bottomLinkContainer.NewLine(); + bool anyInfoAdded = false; + + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.MapMarker, user.Location); + anyInfoAdded |= tryAddInfo(OsuIcon.Heart, user.Interests); + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Suitcase, user.Occupation); + + if (anyInfoAdded) + bottomLinkContainer.NewLine(); + if (!string.IsNullOrEmpty(user.Twitter)) - tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); - tryAddInfo(FontAwesome.Brands.Discord, user.Discord); - tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); - tryAddInfo(FontAwesome.Brands.Lastfm, user.Lastfm, $@"https://last.fm/users/{user.Lastfm}"); - tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtcol, user.Website); + anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); + anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Discord, user.Discord); + anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); + anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Lastfm, user.Lastfm, $@"https://last.fm/users/{user.Lastfm}"); + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtocol, user.Website); + + // If no information was added to the bottomLinkContainer, hide it to avoid unwanted padding + bottomLinkContainer.Alpha = anyInfoAdded ? 1 : 0; } private void addSpacer(OsuTextFlowContainer textFlow) => textFlow.AddArbitraryDrawable(new Container { Width = 15 }); - private void tryAddInfo(IconUsage icon, string content, string link = null) + private bool tryAddInfo(IconUsage icon, string content, string link = null) { - if (string.IsNullOrEmpty(content)) return; + if (string.IsNullOrEmpty(content)) return false; // newlines could be contained in API returned user content. content = content.Replace("\n", " "); @@ -155,6 +163,7 @@ namespace osu.Game.Overlays.Profile.Header bottomLinkContainer.AddText(" " + content, embolden); addSpacer(bottomLinkContainer); + return true; } private void embolden(SpriteText text) => text.Font = text.Font.With(weight: FontWeight.Bold); diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 68fd77dd84..658cdb8ce3 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -28,7 +27,7 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours, TextureStore textures) + private void load(OverlayColourProvider colourProvider, TextureStore textures) { Container hiddenDetailContainer; Container expandedDetailContainer; @@ -38,7 +37,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoam + Colour = colourProvider.Background4 }, new FillFlowContainer { @@ -119,12 +118,12 @@ namespace osu.Game.Overlays.Profile.Header hiddenDetailGlobal = new OverlinedInfoContainer { Title = "Global Ranking", - LineColour = colours.Yellow + LineColour = colourProvider.Highlight1 }, hiddenDetailCountry = new OverlinedInfoContainer { Title = "Country Ranking", - LineColour = colours.Yellow + LineColour = colourProvider.Highlight1 }, } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 46d24608ed..29e13e4f51 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -6,7 +6,6 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components @@ -25,10 +24,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - IdleColour = colours.GreySeafoamLight; - HoverColour = colours.GreySeafoamLight.Darken(0.2f); + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background2.Lighten(0.2f); Child = icon = new SpriteIcon { diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index 8069937810..29471375b5 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelBadge() { - TooltipText = "Level"; + TooltipText = "level"; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index 6a6532764f..c97df3bc4d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -25,11 +25,11 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelProgressBar() { - TooltipText = "Progress to next level"; + TooltipText = "progress to next level"; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { InternalChildren = new Drawable[] { @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Header.Components RelativeSizeAxes = Axes.Both, BackgroundColour = Color4.Black, Direction = BarDirection.LeftToRight, - AccentColour = colours.Yellow + AccentColour = colourProvider.Highlight1 } }, levelProgressText = new OsuSpriteText diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs index c40ddca688..9f56a34aa6 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs @@ -43,7 +43,8 @@ namespace osu.Game.Overlays.Profile.Header.Components line = new Circle { RelativeSizeAxes = Axes.X, - Height = 4, + Height = 2, + Margin = new MarginPadding { Bottom = 2 } }, title = new OsuSpriteText { @@ -53,7 +54,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Font = OsuFont.GetFont(size: big ? 40 : 18, weight: FontWeight.Light) }, - new Container //Add a minimum size to the FillFlowContainer + new Container // Add a minimum size to the FillFlowContainer { Width = minimumWidth, } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index 2c88a83680..be96840217 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -6,7 +6,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Game.Graphics; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -27,12 +26,12 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { InternalChild = info = new OverlinedInfoContainer { Title = "Total Play Time", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }; User.BindValueChanged(updateTime, true); diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs index ddcf011277..e14d73dd98 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs @@ -2,12 +2,11 @@ // 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.Containers; -using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { @@ -24,9 +23,6 @@ namespace osu.Game.Overlays.Profile.Header.Components { AutoSizeAxes = Axes.X; - IdleColour = Color4.Black; - HoverColour = OsuColour.Gray(0.1f); - base.Content.Add(new CircularContainer { Masking = true, @@ -47,5 +43,12 @@ namespace osu.Game.Overlays.Profile.Header.Components } }); } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background6; + HoverColour = colourProvider.Background5; + } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs index 2c9a3dd5f9..41a3ee8ad6 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs @@ -1,63 +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.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; using osu.Game.Rulesets; using osu.Game.Users; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetSelector : RulesetSelector + public class ProfileRulesetSelector : OverlayRulesetSelector { - private Color4 accentColour = Color4.White; - public readonly Bindable User = new Bindable(); - public ProfileRulesetSelector() - { - TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(10, 0); - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - accentColour = colours.Seafoam; - - foreach (TabItem tabItem in TabContainer) - ((ProfileRulesetTabItem)tabItem).AccentColour = accentColour; - } - protected override void LoadComplete() { base.LoadComplete(); - User.BindValueChanged(u => SetDefaultRuleset(Rulesets.GetRuleset(u.NewValue?.PlayMode ?? "osu")), true); } public void SetDefaultRuleset(RulesetInfo ruleset) { - foreach (TabItem tabItem in TabContainer) + foreach (var tabItem in TabContainer) ((ProfileRulesetTabItem)tabItem).IsDefault = ((ProfileRulesetTabItem)tabItem).Value.ID == ruleset.ID; } - protected override TabItem CreateTabItem(RulesetInfo value) => new ProfileRulesetTabItem(value) - { - AccentColour = accentColour - }; - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - }; + protected override TabItem CreateTabItem(RulesetInfo value) => new ProfileRulesetTabItem(value); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs index b5170ea3a2..3d20fba542 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs @@ -2,40 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetTabItem : TabItem, IHasAccentColour + public class ProfileRulesetTabItem : OverlayRulesetTabItem { - private readonly OsuSpriteText text; - private readonly SpriteIcon icon; - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateState(); - } - } - private bool isDefault; public bool IsDefault @@ -52,74 +27,30 @@ namespace osu.Game.Overlays.Profile.Header.Components } } + protected override Color4 AccentColour + { + get => base.AccentColour; + set + { + base.AccentColour = value; + icon.FadeColour(value, 120, Easing.OutQuint); + } + } + + private readonly SpriteIcon icon; + public ProfileRulesetTabItem(RulesetInfo value) : base(value) { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] + Add(icon = new SpriteIcon { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(3, 0), - Children = new Drawable[] - { - text = new OsuSpriteText - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Text = value.Name, - }, - icon = new SpriteIcon - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Alpha = 0, - AlwaysPresent = true, - Icon = FontAwesome.Solid.Star, - Size = new Vector2(12), - }, - } - }, - new HoverClickSounds() - }; - } - - protected override bool OnHover(HoverEvent e) - { - base.OnHover(e); - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - updateState(); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - private void updateState() - { - text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Medium); - - if (IsHovered || Active.Value) - { - text.FadeColour(Color4.White, 120, Easing.InQuad); - icon.FadeColour(Color4.White, 120, Easing.InQuad); - } - else - { - text.FadeColour(AccentColour, 120, Easing.InQuad); - icon.FadeColour(AccentColour, 120, Easing.InQuad); - } + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Alpha = 0, + AlwaysPresent = true, + Icon = FontAwesome.Solid.Star, + Size = new Vector2(12), + }); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index 250b345db7..ffc060b3f1 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -167,9 +167,9 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { - ballBg.Colour = colours.GreySeafoamDarker; + ballBg.Colour = colourProvider.Background5; movingBall.BorderColour = line.Colour = colours.Yellow; } @@ -270,7 +270,9 @@ namespace osu.Game.Overlays.Profile.Header.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - background.Colour = colours.GreySeafoamDark; + // Temporary colour since it's currently impossible to change it without bugs (see https://github.com/ppy/osu-framework/issues/3231) + // If above is fixed, this should use OverlayColourProvider + background.Colour = colours.Gray1; } public bool SetContent(object content) diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 6ee0d9ee8f..cf6ae1a3fc 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { AutoSizeAxes = Axes.Y; @@ -65,7 +65,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDarker, + Colour = colourProvider.Background5, }, fillFlow = new FillFlowContainer { @@ -152,12 +152,12 @@ namespace osu.Game.Overlays.Profile.Header detailGlobalRank = new OverlinedInfoContainer(true, 110) { Title = "Global Ranking", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }, detailCountryRank = new OverlinedInfoContainer(false, 110) { Title = "Country Ranking", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }, } } diff --git a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs index 45bc60f794..e7df4eb5eb 100644 --- a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -23,7 +22,7 @@ namespace osu.Game.Overlays.Profile.Header public readonly Bindable User = new Bindable(); [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { Alpha = 0; AutoSizeAxes = Axes.Y; @@ -34,9 +33,9 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDarker, + Colour = colourProvider.Background5, }, - new Container //artificial shadow + new Container // artificial shadow { RelativeSizeAxes = Axes.X, Height = 3, diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index b0d7070994..2cc1f6533f 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Header private FillFlowContainer userStats; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { Height = 150; @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDark, + Colour = colourProvider.Background5, }, new FillFlowContainer { @@ -117,7 +117,7 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.X, Height = 1.5f, Margin = new MarginPadding { Top = 10 }, - Colour = colours.GreySeafoamLighter, + Colour = colourProvider.Light1, }, new FillFlowContainer { @@ -137,7 +137,7 @@ namespace osu.Game.Overlays.Profile.Header Margin = new MarginPadding { Left = 10 }, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Colour = colours.GreySeafoamLighter, + Colour = colourProvider.Light1, } } }, @@ -171,14 +171,14 @@ namespace osu.Game.Overlays.Profile.Header userCountryText.Text = user?.Country?.FullName ?? "Alien"; supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; - titleText.Colour = OsuColour.FromHex(user?.Colour ?? "fff"); + titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); userStats.Clear(); if (user?.Statistics != null) { userStats.Add(new UserStatsLine("Ranked Score", user.Statistics.RankedScore.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Hit Accuracy", Math.Round(user.Statistics.Accuracy, 2).ToString("#0.00'%'"))); + userStats.Add(new UserStatsLine("Hit Accuracy", user.Statistics.DisplayAccuracy)); userStats.Add(new UserStatsLine("Play Count", user.Statistics.PlayCount.ToString("#,##0"))); userStats.Add(new UserStatsLine("Total Score", user.Statistics.TotalScore.ToString("#,##0"))); userStats.Add(new UserStatsLine("Total Hits", user.Statistics.TotalHits.ToString("#,##0"))); diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 59e64dfc26..0161d91daa 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -1,21 +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.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.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Profile.Header; using osu.Game.Users; namespace osu.Game.Overlays.Profile { - public class ProfileHeader : TabControlOverlayHeader + public class ProfileHeader : TabControlOverlayHeader { private UserCoverBackground coverContainer; @@ -26,8 +23,6 @@ namespace osu.Game.Overlays.Profile public ProfileHeader() { - BackgroundHeight = 150; - User.ValueChanged += e => updateDisplay(e.NewValue); TabControl.AddItem("info"); @@ -36,18 +31,12 @@ namespace osu.Game.Overlays.Profile centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - TabControl.AccentColour = colours.Seafoam; - TitleBackgroundColour = colours.GreySeafoamDarker; - ControlBackgroundColour = colours.GreySeafoam; - } - protected override Drawable CreateBackground() => new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + Height = 150, + Masking = true, Children = new Drawable[] { coverContainer = new UserCoverBackground @@ -57,7 +46,7 @@ namespace osu.Game.Overlays.Profile new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.FromHex("222").Opacity(0.8f), OsuColour.FromHex("222").Opacity(0.2f)) + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("222").Opacity(0.8f), Color4Extensions.FromHex("222").Opacity(0.2f)) }, } }; @@ -97,25 +86,17 @@ namespace osu.Game.Overlays.Profile } }; - protected override ScreenTitle CreateTitle() => new ProfileHeaderTitle(); + protected override OverlayTitle CreateTitle() => new ProfileHeaderTitle(); private void updateDisplay(User user) => coverContainer.User = user; - private class ProfileHeaderTitle : ScreenTitle + private class ProfileHeaderTitle : OverlayTitle { public ProfileHeaderTitle() { - Title = "player"; - Section = "info"; + Title = "player info"; + IconTexture = "Icons/profile"; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Seafoam; - } - - protected override Drawable CreateIcon() => new ScreenTitleTextureIcon(@"Icons/profile"); } } } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index f3590d4bb7..2e19ae4b64 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -95,10 +95,10 @@ namespace osu.Game.Overlays.Profile } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.GreySeafoamDarker; - underscore.Colour = colours.Seafoam; + background.Colour = colourProvider.Background5; + underscore.Colour = colourProvider.Highlight1; } private class SectionTriangles : Container @@ -128,11 +128,11 @@ namespace osu.Game.Overlays.Profile } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - triangles.ColourLight = colours.GreySeafoamDark; - triangles.ColourDark = colours.GreySeafoamDarker.Darken(0.2f); - foreground.Colour = ColourInfo.GradientVertical(colours.GreySeafoamDarker, colours.GreySeafoamDarker.Opacity(0)); + 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/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 13b547eed3..67a976fe6f 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Profile.Sections { /// - /// Display artist/title/mapper information, commonly used as the left portion of a profile or score display row (see ). + /// Display artist/title/mapper information, commonly used as the left portion of a profile or score display row. /// public abstract class BeatmapMetadataContainer : OsuHoverContainer { diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index fcd12e2b54..191f3c908a 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -7,7 +7,7 @@ using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Users; using osuTK; @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps protected override Drawable CreateDrawableItem(APIBeatmapSet model) => !model.OnlineBeatmapSetID.HasValue ? null - : new DirectGridPanel(model.ToBeatmapSet(Rulesets)) + : new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs index bd760c4139..52adefa4ad 100644 --- a/osu.Game/Overlays/Profile/Sections/CounterPill.cs +++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Framework.Bindables; using osu.Game.Graphics.Sprites; +using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections { @@ -16,9 +17,10 @@ namespace osu.Game.Overlays.Profile.Sections public readonly BindableInt Current = new BindableInt(); - private readonly OsuSpriteText counter; + private OsuSpriteText counter; - public CounterPill() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { AutoSizeAxes = Axes.Both; Alpha = 0; @@ -28,14 +30,15 @@ namespace osu.Game.Overlays.Profile.Sections new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f) + Colour = colourProvider.Background6 }, counter = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold) + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Colour = colourProvider.Foreground1 } }; } @@ -54,7 +57,7 @@ namespace osu.Game.Overlays.Profile.Sections return; } - counter.Text = value.NewValue.ToString(); + counter.Text = value.NewValue.ToString("N0"); this.FadeIn(duration, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs b/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs deleted file mode 100644 index 03ee29d0c2..0000000000 --- a/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Profile.Sections -{ - public abstract class DrawableProfileRow : Container - { - private const int fade_duration = 200; - - private Box underscoreLine; - private Box coloredBackground; - private Container background; - - /// - /// A visual element displayed to the left of content. - /// - protected abstract Drawable CreateLeftVisual(); - - protected FillFlowContainer LeftFlowContainer { get; private set; } - protected FillFlowContainer RightFlowContainer { get; private set; } - - protected override Container Content { get; } - - protected DrawableProfileRow() - { - RelativeSizeAxes = Axes.X; - Height = 60; - - Content = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Width = 0.97f, - }; - } - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colour) - { - InternalChildren = new Drawable[] - { - background = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 3, - Alpha = 0, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 1f), - Radius = 1f, - Colour = Color4.Black.Opacity(0.2f), - }, - Child = coloredBackground = new Box { RelativeSizeAxes = Axes.Both } - }, - Content, - underscoreLine = new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - Height = 1, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - CreateLeftVisual(), - LeftFlowContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 10 }, - Direction = FillDirection.Vertical, - }, - } - }, - RightFlowContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Vertical, - }, - }; - - coloredBackground.Colour = underscoreLine.Colour = colour.Gray4; - } - - protected override bool OnClick(ClickEvent e) => true; - - protected override bool OnHover(HoverEvent e) - { - background.FadeIn(fade_duration, Easing.OutQuint); - underscoreLine.FadeOut(fade_duration, Easing.OutQuint); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - background.FadeOut(fade_duration, Easing.OutQuint); - underscoreLine.FadeIn(fade_duration, Easing.OutQuint); - base.OnHoverLost(e); - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 0206c4e13b..5b7c5efbe2 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -4,7 +4,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.Localisation; using osu.Game.Beatmaps; @@ -13,44 +12,34 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using System.Collections.Generic; using osu.Framework.Graphics.Cursor; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class DrawableMostPlayedBeatmap : OsuHoverContainer + public class DrawableMostPlayedBeatmap : CompositeDrawable { private const int cover_width = 100; private const int corner_radius = 6; - private const int height = 50; private readonly BeatmapInfo beatmap; private readonly int playCount; - private Box background; - - protected override IEnumerable EffectTargets => new[] { background }; - public DrawableMostPlayedBeatmap(BeatmapInfo beatmap, int playCount) { this.beatmap = beatmap; this.playCount = playCount; - Enabled.Value = true; //manually enabled, because we have no action RelativeSizeAxes = Axes.X; - Height = height; + Height = 50; Masking = true; CornerRadius = corner_radius; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - IdleColour = colours.GreySeafoam; - HoverColour = colours.GreySeafoamLight; - - Children = new Drawable[] + AddRangeInternal(new Drawable[] { new UpdateableBeatmapSetCover { @@ -72,46 +61,61 @@ namespace osu.Game.Overlays.Profile.Sections.Historical CornerRadius = corner_radius, Children = new Drawable[] { - background = new Box { RelativeSizeAxes = Axes.Both }, - new Container + new MostPlayedBeatmapContainer { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(10), - Children = new Drawable[] + Child = new Container { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new MostPlayedBeatmapMetadataContainer(beatmap), - new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular)) + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Colour = colours.GreySeafoamLighter - }.With(d => - { - d.AddText("mapped by "); - d.AddUserLink(beatmap.Metadata.Author); - }), - } - }, - new PlayCountText(playCount) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight - }, - } - }, + new MostPlayedBeatmapMetadataContainer(beatmap), + new LinkFlowContainer(t => + { + t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); + t.Colour = colourProvider.Foreground1; + }) + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(d => + { + d.AddText("mapped by "); + d.AddUserLink(beatmap.Metadata.Author); + }), + } + }, + new PlayCountText(playCount) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight + }, + } + }, + } } } } } - }; + }); + } + + private class MostPlayedBeatmapContainer : ProfileItemContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background4; + HoverColour = colourProvider.Background3; + } } private class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index aabfa56ee6..d4d0976724 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -101,7 +101,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { Masking = true, RelativeSizeAxes = Axes.X, - Height = 5, + Height = 2, Child = lineBackground = new Box { RelativeSizeAxes = Axes.Both, @@ -128,10 +128,10 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - lineBackground.Colour = colours.Yellow; - DescriptionText.Colour = colours.GreySeafoamLighter; + lineBackground.Colour = colourProvider.Highlight1; + DescriptionText.Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs new file mode 100644 index 0000000000..afa6bd9f79 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Profile.Sections +{ + public class ProfileItemContainer : Container + { + private const int hover_duration = 200; + + protected override Container Content => content; + + private readonly Box background; + private readonly Container content; + + private Color4 idleColour; + + protected Color4 IdleColour + { + get => idleColour; + set + { + idleColour = value; + fadeBackgroundColour(); + } + } + + private Color4 hoverColour; + + protected Color4 HoverColour + { + get => hoverColour; + set + { + hoverColour = value; + fadeBackgroundColour(); + } + } + + public ProfileItemContainer() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 6; + + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background3; + HoverColour = colourProvider.Background2; + } + + protected override bool OnHover(HoverEvent e) + { + fadeBackgroundColour(hover_duration); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + fadeBackgroundColour(hover_duration); + } + + private void fadeBackgroundColour(double fadeDuration = 0) + { + background.FadeColour(IsHovered ? HoverColour : IdleColour, fadeDuration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs b/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs index 28486cc743..426ebeebe6 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections @@ -10,11 +9,11 @@ namespace osu.Game.Overlays.Profile.Sections public class ProfileShowMoreButton : ShowMoreButton { [BackgroundDependencyLoader] - private void load(OsuColour colors) + private void load(OverlayColourProvider colourProvider) { - IdleColour = colors.GreySeafoamDark; - HoverColour = colors.GreySeafoam; - ChevronIconColour = colors.Yellow; + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; + ChevronIconColour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.cs deleted file mode 100644 index 843f9b7ef2..0000000000 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.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.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; - -namespace osu.Game.Overlays.Profile.Sections.Ranks -{ - public class DrawablePerformanceScore : DrawableProfileScore - { - private readonly double? weight; - - public DrawablePerformanceScore(ScoreInfo score, double? weight = null) - : base(score) - { - this.weight = weight; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - double pp = Score.PP ?? 0; - RightFlowContainer.Add(new OsuSpriteText - { - Text = $"{pp:0}pp", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) - }); - - if (weight.HasValue) - { - RightFlowContainer.Add(new OsuSpriteText - { - Text = $"weighted: {pp * weight:0}pp ({weight:P0})", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colour.GrayA, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Regular, italics: true) - }); - } - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 6362d3dfb0..2c20dcc0ef 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -1,76 +1,251 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; +using System.Linq; +using JetBrains.Annotations; 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.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Beatmaps; -using osu.Framework.Localisation; -using osu.Framework.Graphics.Containers; +using osuTK; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public abstract class DrawableProfileScore : DrawableProfileRow + public class DrawableProfileScore : CompositeDrawable { - private readonly FillFlowContainer modsContainer; + private const int height = 40; + private const int performance_width = 100; + + private const float performance_background_shear = 0.45f; + protected readonly ScoreInfo Score; - protected DrawableProfileScore(ScoreInfo score) + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public DrawableProfileScore(ScoreInfo score) { Score = score; RelativeSizeAxes = Axes.X; - Height = 60; - Children = new Drawable[] + Height = height; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new ProfileItemContainer { - modsContainer = new FillFlowContainer + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Spacing = new Vector2(1), - Margin = new MarginPadding { Right = 160 } + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 20, Right = performance_width }, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UpdateableRank(Score.Rank) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(50, 20), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + Children = new Drawable[] + { + new ScoreBeatmapMetadataContainer(Score.Beatmap), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = $"{Score.Beatmap.Version}", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Colour = colours.Yellow + }, + new DrawableDate(Score.Date, 12) + { + Colour = colourProvider.Foreground1 + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15), + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Child = CreateRightContent() + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2), + Children = Score.Mods.Select(mod => new ModIcon(mod) + { + Scale = new Vector2(0.35f) + }).ToList(), + } + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Y, + Width = performance_width, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Colour = colourProvider.Background4, + Shear = new Vector2(-performance_background_shear, 0), + EdgeSmoothness = new Vector2(2, 0), + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + Height = -0.5f, + Position = new Vector2(0, 1), + Colour = colourProvider.Background4, + Shear = new Vector2(performance_background_shear, 0), + EdgeSmoothness = new Vector2(2, 0), + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Vertical = 5, + Left = 30, + Right = 20 + }, + Child = createDrawablePerformance().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }) + } + } + } } - }; + }); } - [BackgroundDependencyLoader(true)] - private void load(OsuColour colour) - { - var text = new OsuSpriteText - { - Text = $"accuracy: {Score.Accuracy:P2}", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colour.GrayA, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Regular, italics: true) - }; - - RightFlowContainer.Insert(1, text); - - LeftFlowContainer.Add(new ProfileScoreBeatmapMetadataContainer(Score.Beatmap)); - LeftFlowContainer.Add(new DrawableDate(Score.Date)); - - foreach (Mod mod in Score.Mods) - modsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.5f) }); - } - - protected override Drawable CreateLeftVisual() => new UpdateableRank(Score.Rank) + [NotNull] + protected virtual Drawable CreateRightContent() => CreateDrawableAccuracy(); + + protected Drawable CreateDrawableAccuracy() => new Container { + Width = 65, RelativeSizeAxes = Axes.Y, - Width = 60, - FillMode = FillMode.Fit, + Child = new OsuSpriteText + { + Text = Score.DisplayAccuracy, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Colour = colours.Yellow, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } }; - private class ProfileScoreBeatmapMetadataContainer : BeatmapMetadataContainer + private Drawable createDrawablePerformance() { - public ProfileScoreBeatmapMetadataContainer(BeatmapInfo beatmap) + if (Score.PP.HasValue) + { + return new FillFlowContainer + { + 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 + } + } + }; + } + + return new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = "-", + Colour = colourProvider.Highlight1 + }; + } + + private class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer + { + public ScoreBeatmapMetadataContainer(BeatmapInfo beatmap) : base(beatmap) { } @@ -79,16 +254,19 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { new OsuSpriteText { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, Text = new LocalisedString(( - $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} [{beatmap.Version}] ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] ")), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold, italics: true) + $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} ", + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} ")), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) }, new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), - Padding = new MarginPadding { Top = 3 }, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular, italics: true) + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Font = OsuFont.GetFont(size: 12, italics: true) }, }; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs new file mode 100644 index 0000000000..3afa79e59e --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.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.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Overlays.Profile.Sections.Ranks +{ + public class DrawableProfileWeightedScore : DrawableProfileScore + { + private readonly double weight; + + public DrawableProfileWeightedScore(ScoreInfo score, double weight) + : base(score) + { + this.weight = weight; + } + + protected override Drawable CreateRightContent() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new[] + { + CreateDrawableAccuracy(), + new Container + { + AutoSizeAxes = Axes.Y, + Width = 50, + Child = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = $"{Score.PP * weight:0}pp", + }, + } + } + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = $@"weighted {weight:0%}" + } + } + }; + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs deleted file mode 100644 index 8bfca08fe7..0000000000 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; - -namespace osu.Game.Overlays.Profile.Sections.Ranks -{ - public class DrawableTotalScore : DrawableProfileScore - { - public DrawableTotalScore(ScoreInfo score) - : base(score) - { - } - - [BackgroundDependencyLoader] - private void load() - { - RightFlowContainer.Add(new OsuSpriteText - { - Text = Score.TotalScore.ToString("#,###"), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) - }); - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index e0f1c935da..64494f9814 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -15,14 +15,12 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { public class PaginatedScoreContainer : PaginatedContainer { - private readonly bool includeWeight; private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, string header, string missing, bool includeWeight = false) + public PaginatedScoreContainer(ScoreType type, Bindable user, string header, string missing) : base(user, header, missing) { this.type = type; - this.includeWeight = includeWeight; ItemsPerPage = 5; @@ -43,10 +41,10 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks switch (type) { default: - return new DrawablePerformanceScore(model.CreateScoreInfo(Rulesets), includeWeight ? Math.Pow(0.95, ItemsContainer.Count) : (double?)null); + return new DrawableProfileScore(model.CreateScoreInfo(Rulesets)); - case ScoreType.Recent: - return new DrawableTotalScore(model.CreateScoreInfo(Rulesets)); + case ScoreType.Best: + return new DrawableProfileWeightedScore(model.CreateScoreInfo(Rulesets), Math.Pow(0.95, ItemsContainer.Count)); } } } diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index c4b933593e..dbdff3a273 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Sections { Children = new[] { - new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance", "No performance records. :(", true), + new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance", "No performance records. :("), new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks", "No awesome performance records yet. :("), }; } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index 4e856845ac..8782e82642 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -11,12 +13,19 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class DrawableRecentActivity : DrawableProfileRow + public class DrawableRecentActivity : CompositeDrawable { - private IAPIProvider api; + private const int font_size = 14; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } private readonly APIRecentActivity activity; @@ -28,139 +37,191 @@ namespace osu.Game.Overlays.Profile.Sections.Recent } [BackgroundDependencyLoader] - private void load(IAPIProvider api) + private void load(OverlayColourProvider colourProvider) { - this.api = api; - - LeftFlowContainer.Padding = new MarginPadding { Left = 10, Right = 160 }; - - LeftFlowContainer.Add(content = new LinkFlowContainer + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AddInternal(new GridContainer { - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 28), + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = createIcon().With(icon => + { + icon.Anchor = Anchor.Centre; + icon.Origin = Anchor.Centre; + }) + }, + content = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: font_size)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, + new DrawableDate(activity.CreatedAt) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Colour = colourProvider.Foreground1, + Font = OsuFont.GetFont(size: font_size), + } + } + } }); - RightFlowContainer.Add(new DrawableDate(activity.CreatedAt) - { - Font = OsuFont.GetFont(size: 13), - Colour = OsuColour.Gray(0xAA), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }); - - var formatted = createMessage(); - - content.AddLinks(formatted.Text, formatted.Links); + createMessage(); } - protected override Drawable CreateLeftVisual() + private Drawable createIcon() { switch (activity.Type) { case RecentActivityType.Rank: return new UpdateableRank(activity.ScoreRank) { - RelativeSizeAxes = Axes.Y, - Width = 60, + RelativeSizeAxes = Axes.X, + Height = 11, FillMode = FillMode.Fit, + Margin = new MarginPadding { Top = 2 } }; case RecentActivityType.Achievement: return new DelayedLoadWrapper(new MedalIcon(activity.Achievement.Slug) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, }) { - RelativeSizeAxes = Axes.Y, - Width = 60, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = 18 }; default: - return new Container - { - RelativeSizeAxes = Axes.Y, - Width = 60, - FillMode = FillMode.Fit, - }; + return Empty(); } } - private string toAbsoluteUrl(string url) => $"{api.Endpoint}{url}"; - - private MessageFormatter.MessageFormatterResult createMessage() + private void createMessage() { - string userLinkTemplate() => $"[{toAbsoluteUrl(activity.User?.Url)} {activity.User?.Username}]"; - string beatmapLinkTemplate() => $"[{toAbsoluteUrl(activity.Beatmap?.Url)} {activity.Beatmap?.Title}]"; - string beatmapsetLinkTemplate() => $"[{toAbsoluteUrl(activity.Beatmapset?.Url)} {activity.Beatmapset?.Title}]"; - - string message; - switch (activity.Type) { case RecentActivityType.Achievement: - message = $"{userLinkTemplate()} unlocked the {activity.Achievement.Name} medal!"; + addUserLink(); + addText($" unlocked the \"{activity.Achievement.Name}\" medal!"); break; case RecentActivityType.BeatmapPlaycount: - message = $"{beatmapLinkTemplate()} has been played {activity.Count} times!"; + addBeatmapLink(); + addText($" has been played {activity.Count} times!"); break; case RecentActivityType.BeatmapsetApprove: - message = $"{beatmapsetLinkTemplate()} has been {activity.Approval.ToString().ToLowerInvariant()}!"; + addBeatmapsetLink(); + addText($" has been {activity.Approval.ToString().ToLowerInvariant()}!"); break; case RecentActivityType.BeatmapsetDelete: - message = $"{beatmapsetLinkTemplate()} has been deleted."; + addBeatmapsetLink(); + addText(" has been deleted."); break; case RecentActivityType.BeatmapsetRevive: - message = $"{beatmapsetLinkTemplate()} has been revived from eternal slumber by {userLinkTemplate()}."; + addBeatmapsetLink(); + addText(" has been revived from eternal slumber by "); + addUserLink(); break; case RecentActivityType.BeatmapsetUpdate: - message = $"{userLinkTemplate()} has updated the beatmap {beatmapsetLinkTemplate()}!"; + addUserLink(); + addText(" has updated the beatmap "); + addBeatmapsetLink(); break; case RecentActivityType.BeatmapsetUpload: - message = $"{userLinkTemplate()} has submitted a new beatmap {beatmapsetLinkTemplate()}!"; + addUserLink(); + addText(" has submitted a new beatmap "); + addBeatmapsetLink(); break; case RecentActivityType.Medal: // apparently this shouldn't exist look at achievement instead (https://github.com/ppy/osu-web/blob/master/resources/assets/coffee/react/profile-page/recent-activity.coffee#L111) - message = string.Empty; break; case RecentActivityType.Rank: - message = $"{userLinkTemplate()} achieved rank #{activity.Rank} on {beatmapLinkTemplate()} ({activity.Mode}!)"; + addUserLink(); + addText($" achieved rank #{activity.Rank} on "); + addBeatmapLink(); + addText($" ({getRulesetName()})"); break; case RecentActivityType.RankLost: - message = $"{userLinkTemplate()} has lost first place on {beatmapLinkTemplate()} ({activity.Mode}!)"; + addUserLink(); + addText(" has lost first place on "); + addBeatmapLink(); + addText($" ({getRulesetName()})"); break; case RecentActivityType.UserSupportAgain: - message = $"{userLinkTemplate()} has once again chosen to support osu! - thanks for your generosity!"; + addUserLink(); + addText(" has once again chosen to support osu! - thanks for your generosity!"); break; case RecentActivityType.UserSupportFirst: - message = $"{userLinkTemplate()} has become an osu!supporter - thanks for your generosity!"; + addUserLink(); + addText(" has become an osu!supporter - thanks for your generosity!"); break; case RecentActivityType.UserSupportGift: - message = $"{userLinkTemplate()} has received the gift of osu!supporter!"; + addUserLink(); + addText(" has received the gift of osu!supporter!"); break; case RecentActivityType.UsernameChange: - message = $"{activity.User?.PreviousUsername} has changed their username to {userLinkTemplate()}!"; - break; - - default: - message = string.Empty; + addText($"{activity.User?.PreviousUsername} has changed their username to "); + addUserLink(); break; } - - return MessageFormatter.FormatText(message); } + + private string getRulesetName() => + 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)); + + private void addBeatmapLink() + => content.AddLink(activity.Beatmap?.Title, LinkAction.OpenBeatmap, getLinkArgument(activity.Beatmap?.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()); + + private string getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.Endpoint}{url}").Argument; + + private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular) + => OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true); + + private void addText(string text) + => content.AddText(text, t => t.Font = OsuFont.GetFont(size: font_size, weight: FontWeight.SemiBold)); } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs index 4563510046..0c1f8b2e92 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs @@ -23,8 +23,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent Child = sprite = new Sprite { - Height = 40, - Width = 40, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, }; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index 3f9d4dc93e..a37f398272 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API; using System.Collections.Generic; +using osuTK; namespace osu.Game.Overlays.Profile.Sections.Recent { @@ -16,7 +17,8 @@ namespace osu.Game.Overlays.Profile.Sections.Recent public PaginatedRecentActivityContainer(Bindable user, string header, string missing) : base(user, header, missing) { - ItemsPerPage = 5; + ItemsPerPage = 10; + ItemsContainer.Spacing = new Vector2(0, 8); } protected override APIRequest> CreateRequest() => diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs new file mode 100644 index 0000000000..4bdefb06ef --- /dev/null +++ b/osu.Game/Overlays/Rankings/CountryFilter.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Rankings +{ + public class CountryFilter : CompositeDrawable, IHasCurrentValue + { + private const int duration = 200; + private const int height = 50; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Box background; + private readonly CountryPill countryPill; + private readonly Container content; + + public CountryFilter() + { + RelativeSizeAxes = Axes.X; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.X, + Height = height, + Alpha = 0, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = @"filtered by country:", + Font = OsuFont.GetFont(size: 14) + }, + countryPill = new CountryPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Alpha = 0, + Current = Current + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(onCountryChanged, true); + } + + private void onCountryChanged(ValueChangedEvent country) + { + if (country.NewValue == null) + { + countryPill.Collapse(); + this.ResizeHeightTo(0, duration, Easing.OutQuint); + content.FadeOut(duration, Easing.OutQuint); + return; + } + + this.ResizeHeightTo(height, duration, Easing.OutQuint); + content.FadeIn(duration, Easing.OutQuint); + countryPill.Expand(); + } + } +} diff --git a/osu.Game/Overlays/Rankings/CountryPill.cs b/osu.Game/Overlays/Rankings/CountryPill.cs new file mode 100644 index 0000000000..1b19bbd95e --- /dev/null +++ b/osu.Game/Overlays/Rankings/CountryPill.cs @@ -0,0 +1,164 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Rankings +{ + public class CountryPill : CompositeDrawable, IHasCurrentValue + { + private const int duration = 200; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Container content; + private readonly Box background; + private readonly UpdateableFlag flag; + private readonly OsuSpriteText countryName; + + public CountryPill() + { + AutoSizeAxes = Axes.Both; + + InternalChild = content = new CircularContainer + { + Height = 25, + AutoSizeDuration = duration, + AutoSizeEasing = Easing.OutQuint, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 10 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(8, 0), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + flag = new UpdateableFlag + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(22, 15) + }, + countryName = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 14) + } + } + }, + new CloseButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => Current.Value = null + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(onCountryChanged, true); + } + + public void Expand() + { + content.ClearTransforms(); + content.AutoSizeAxes = Axes.X; + + this.FadeIn(duration, Easing.OutQuint); + } + + public void Collapse() + { + content.ClearTransforms(); + content.AutoSizeAxes = Axes.None; + content.ResizeWidthTo(0, duration, Easing.OutQuint); + + this.FadeOut(duration, Easing.OutQuint); + } + + private void onCountryChanged(ValueChangedEvent country) + { + if (country.NewValue == null) + return; + + flag.Country = country.NewValue; + countryName.Text = country.NewValue.FullName; + } + + private class CloseButton : OsuHoverContainer + { + private readonly SpriteIcon icon; + + protected override IEnumerable EffectTargets => new[] { icon }; + + public CloseButton() + { + AutoSizeAxes = Axes.Both; + Add(icon = new SpriteIcon + { + Size = new Vector2(8), + Icon = FontAwesome.Solid.Times + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = Color4.White; + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/DismissableFlag.cs b/osu.Game/Overlays/Rankings/DismissableFlag.cs deleted file mode 100644 index 7a55b0bba6..0000000000 --- a/osu.Game/Overlays/Rankings/DismissableFlag.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Users.Drawables; -using osuTK.Graphics; -using osuTK; -using osu.Framework.Input.Events; -using System; - -namespace osu.Game.Overlays.Rankings -{ - public class DismissableFlag : UpdateableFlag - { - private const int duration = 200; - - public Action Action; - - private readonly SpriteIcon hoverIcon; - - public DismissableFlag() - { - AddInternal(hoverIcon = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Depth = -1, - Alpha = 0, - Size = new Vector2(10), - Icon = FontAwesome.Solid.Times, - }); - } - - protected override bool OnHover(HoverEvent e) - { - hoverIcon.FadeIn(duration, Easing.OutQuint); - this.FadeColour(Color4.Gray, duration, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - hoverIcon.FadeOut(duration, Easing.OutQuint); - this.FadeColour(Color4.White, duration, Easing.OutQuint); - } - - protected override bool OnClick(ClickEvent e) - { - Action?.Invoke(); - return true; - } - } -} diff --git a/osu.Game/Overlays/Rankings/HeaderTitle.cs b/osu.Game/Overlays/Rankings/HeaderTitle.cs deleted file mode 100644 index b08a2a3900..0000000000 --- a/osu.Game/Overlays/Rankings/HeaderTitle.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Users; -using osu.Framework.Graphics; -using osuTK; -using osu.Game.Graphics; -using osu.Framework.Allocation; -using osu.Game.Graphics.Sprites; - -namespace osu.Game.Overlays.Rankings -{ - public class HeaderTitle : CompositeDrawable - { - private const int spacing = 10; - private const int flag_margin = 5; - private const int text_size = 40; - - public readonly Bindable Scope = new Bindable(); - public readonly Bindable Country = new Bindable(); - - private readonly SpriteText scopeText; - private readonly DismissableFlag flag; - - public HeaderTitle() - { - AutoSizeAxes = Axes.Both; - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(spacing, 0), - Children = new Drawable[] - { - flag = new DismissableFlag - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Bottom = flag_margin }, - Size = new Vector2(30, 20), - }, - scopeText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Light) - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Light), - Text = @"Ranking" - } - } - }; - - flag.Action += () => Country.Value = null; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - scopeText.Colour = colours.Lime; - } - - protected override void LoadComplete() - { - Scope.BindValueChanged(onScopeChanged, true); - Country.BindValueChanged(onCountryChanged, true); - base.LoadComplete(); - } - - private void onScopeChanged(ValueChangedEvent scope) => scopeText.Text = scope.NewValue.ToString(); - - private void onCountryChanged(ValueChangedEvent country) - { - if (country.NewValue == null) - { - flag.Hide(); - return; - } - - flag.Country = country.NewValue; - flag.Show(); - } - } -} diff --git a/osu.Game/Overlays/Rankings/RankingsHeader.cs b/osu.Game/Overlays/Rankings/RankingsHeader.cs deleted file mode 100644 index 6aa3e75df9..0000000000 --- a/osu.Game/Overlays/Rankings/RankingsHeader.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Rulesets; -using osu.Game.Users; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osuTK; -using osu.Game.Graphics.UserInterface; -using System.Collections.Generic; - -namespace osu.Game.Overlays.Rankings -{ - public class RankingsHeader : CompositeDrawable - { - private const int content_height = 250; - - public IEnumerable Spotlights - { - get => dropdown.Items; - set => dropdown.Items = value; - } - - public readonly Bindable Scope = new Bindable(); - public readonly Bindable Ruleset = new Bindable(); - public readonly Bindable Country = new Bindable(); - public readonly Bindable Spotlight = new Bindable(); - - private readonly OsuDropdown dropdown; - - public RankingsHeader() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - AddInternal(new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new RankingsRulesetSelector - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Current = Ruleset - }, - new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = content_height, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Child = new HeaderBackground(), - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - new RankingsScopeSelector - { - Margin = new MarginPadding { Top = 10 }, - Current = Scope - }, - new HeaderTitle - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 10 }, - Scope = { BindTarget = Scope }, - Country = { BindTarget = Country }, - }, - dropdown = new OsuDropdown - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Width = 0.8f, - Current = Spotlight, - } - } - }, - } - } - } - }); - } - - protected override void LoadComplete() - { - Scope.BindValueChanged(onScopeChanged, true); - base.LoadComplete(); - } - - private void onScopeChanged(ValueChangedEvent scope) => - dropdown.FadeTo(scope.NewValue == RankingsScope.Spotlights ? 1 : 0, 200, Easing.OutQuint); - - private class HeaderBackground : Sprite - { - public HeaderBackground() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fill; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Texture = textures.Get(@"Headers/rankings"); - } - } - } -} diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs new file mode 100644 index 0000000000..e30c6f07a8 --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Overlays.Rankings +{ + public class RankingsOverlayHeader : TabControlOverlayHeader + { + public Bindable Ruleset => rulesetSelector.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 CreateContent() => countryFilter = new CountryFilter(); + + protected override Drawable CreateBackground() => new OverlayHeaderBackground("Headers/rankings"); + + private class RankingsTitle : OverlayTitle + { + public RankingsTitle() + { + Title = "ranking"; + IconTexture = "Icons/rankings"; + } + } + } + + public enum RankingsScope + { + Performance, + Spotlights, + Score, + Country + } +} diff --git a/osu.Game/Overlays/Rankings/RankingsRulesetSelector.cs b/osu.Game/Overlays/Rankings/RankingsRulesetSelector.cs deleted file mode 100644 index 3d25e3995a..0000000000 --- a/osu.Game/Overlays/Rankings/RankingsRulesetSelector.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osuTK; -using System.Linq; - -namespace osu.Game.Overlays.Rankings -{ - public class RankingsRulesetSelector : PageTabControl - { - protected override TabItem CreateTabItem(RulesetInfo value) => new RankingsTabItem(value); - - protected override Dropdown CreateDropdown() => null; - - public RankingsRulesetSelector() - { - AutoSizeAxes = Axes.X; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, RulesetStore rulesets) - { - foreach (var r in rulesets.AvailableRulesets) - AddItem(r); - - AccentColour = colours.Lime; - - SelectTab(TabContainer.FirstOrDefault()); - } - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(20, 0), - }; - - private class RankingsTabItem : PageTabItem - { - public RankingsTabItem(RulesetInfo value) - : base(value) - { - } - - protected override string CreateText() => $"{Value.Name}"; - } - } -} diff --git a/osu.Game/Overlays/Rankings/RankingsScopeSelector.cs b/osu.Game/Overlays/Rankings/RankingsScopeSelector.cs deleted file mode 100644 index 2095bcc61c..0000000000 --- a/osu.Game/Overlays/Rankings/RankingsScopeSelector.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 osu.Framework.Allocation; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Rankings -{ - public class RankingsScopeSelector : GradientLineTabControl - { - [BackgroundDependencyLoader] - private void load() - { - AccentColour = LineColour = Color4.Black; - } - } - - public enum RankingsScope - { - Performance, - Spotlights, - Score, - Country - } -} diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs new file mode 100644 index 0000000000..c0bbf46e30 --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.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.Overlays.Rankings +{ + public class RankingsSortTabControl : OverlaySortTabControl + { + public RankingsSortTabControl() + { + Title = "Show"; + } + } + + public enum RankingsSortCriteria + { + All, + Friends + } +} diff --git a/osu.Game/Overlays/Rankings/Spotlight.cs b/osu.Game/Overlays/Rankings/Spotlight.cs deleted file mode 100644 index e956b4f449..0000000000 --- a/osu.Game/Overlays/Rankings/Spotlight.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 Newtonsoft.Json; - -namespace osu.Game.Overlays.Rankings -{ - public class Spotlight - { - [JsonProperty("id")] - public int Id; - - [JsonProperty("text")] - public string Text; - - public override string ToString() => Text; - } -} diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs new file mode 100644 index 0000000000..f112c1ec43 --- /dev/null +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -0,0 +1,199 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using System; +using System.Collections.Generic; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Overlays.Rankings +{ + public class SpotlightSelector : VisibilityContainer, IHasCurrentValue + { + private const int duration = 300; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + public readonly Bindable Sort = new Bindable(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public IEnumerable Spotlights + { + get => dropdown.Items; + set => dropdown.Items = value; + } + + protected override bool StartHidden => true; + + private readonly Box background; + private readonly Container content; + private readonly SpotlightsDropdown dropdown; + private readonly InfoColumn startDateColumn; + private readonly InfoColumn endDateColumn; + private readonly InfoColumn mapCountColumn; + private readonly InfoColumn participantsColumn; + + public SpotlightSelector() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Add(content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + Margin = new MarginPadding { Vertical = 20 }, + RelativeSizeAxes = Axes.X, + Height = 40, + Depth = -float.MaxValue, + Child = dropdown = new SpotlightsDropdown + { + RelativeSizeAxes = Axes.X, + Current = Current + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Bottom = 5 }, + Children = new Drawable[] + { + startDateColumn = new InfoColumn(@"Start Date"), + endDateColumn = new InfoColumn(@"End Date"), + mapCountColumn = new InfoColumn(@"Map Count"), + participantsColumn = new InfoColumn(@"Participants") + } + }, + new RankingsSortTabControl + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Current = Sort + } + } + } + } + } + } + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark3; + } + + public void ShowInfo(GetSpotlightRankingsResponse response) + { + startDateColumn.Value = dateToString(response.Spotlight.StartDate); + endDateColumn.Value = dateToString(response.Spotlight.EndDate); + mapCountColumn.Value = response.BeatmapSets.Count.ToString(); + participantsColumn.Value = response.Spotlight.Participants?.ToString("N0"); + } + + protected override void PopIn() => content.FadeIn(duration, Easing.OutQuint); + + protected override void PopOut() => content.FadeOut(duration, Easing.OutQuint); + + private string dateToString(DateTimeOffset date) => date.ToString("yyyy-MM-dd"); + + private class InfoColumn : FillFlowContainer + { + public string Value + { + set => valueText.Text = value; + } + + private readonly OsuSpriteText valueText; + + public InfoColumn(string name) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Vertical; + Margin = new MarginPadding { Vertical = 10 }; + Children = new Drawable[] + { + new OsuSpriteText + { + Text = name, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular), + }, + new Container + { + AutoSizeAxes = Axes.X, + Height = 20, + Child = valueText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Light), + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + valueText.Colour = colourProvider.Content2; + } + } + + private class SpotlightsDropdown : OsuDropdown + { + private DropdownMenu menu; + + protected override DropdownMenu CreateMenu() => menu = base.CreateMenu().With(m => m.MaxHeight = 400); + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + menu.BackgroundColour = colourProvider.Background5; + AccentColour = colourProvider.Background6; + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs new file mode 100644 index 0000000000..0f9b07bf89 --- /dev/null +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -0,0 +1,165 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Rankings.Tables; +using System.Linq; +using System.Threading; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.BeatmapListing.Panels; + +namespace osu.Game.Overlays.Rankings +{ + public class SpotlightsLayout : CompositeDrawable + { + public readonly Bindable Ruleset = new Bindable(); + + private readonly Bindable selectedSpotlight = new Bindable(); + private readonly Bindable sort = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + private CancellationTokenSource cancellationToken; + private GetSpotlightRankingsRequest getRankingsRequest; + private GetSpotlightsRequest spotlightsRequest; + + private SpotlightSelector selector; + private Container content; + private LoadingLayer loading; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + selector = new SpotlightSelector + { + Current = selectedSpotlight, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Vertical = 10 } + }, + loading = new LoadingLayer(content) + } + } + } + }; + + sort.BindTo(selector.Sort); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selector.Show(); + + selectedSpotlight.BindValueChanged(_ => onSpotlightChanged()); + sort.BindValueChanged(_ => onSpotlightChanged()); + Ruleset.BindValueChanged(onRulesetChanged); + + getSpotlights(); + } + + private void getSpotlights() + { + spotlightsRequest = new GetSpotlightsRequest(); + spotlightsRequest.Success += response => Schedule(() => selector.Spotlights = response.Spotlights); + api.Queue(spotlightsRequest); + } + + private void onRulesetChanged(ValueChangedEvent ruleset) + { + if (!selector.Spotlights.Any()) + return; + + selectedSpotlight.TriggerChange(); + } + + private void onSpotlightChanged() + { + loading.Show(); + + cancellationToken?.Cancel(); + getRankingsRequest?.Cancel(); + + getRankingsRequest = new GetSpotlightRankingsRequest(Ruleset.Value, selectedSpotlight.Value.Id, sort.Value); + getRankingsRequest.Success += onSuccess; + api.Queue(getRankingsRequest); + } + + private void onSuccess(GetSpotlightRankingsResponse response) + { + LoadComponentAsync(createContent(response), loaded => + { + selector.ShowInfo(response); + + content.Clear(); + content.Add(loaded); + + loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private Drawable createContent(GetSpotlightRankingsResponse response) => new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new ScoresTable(1, response.Users), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(10), + Children = response.BeatmapSets.Select(b => new GridBeatmapPanel(b.ToBeatmapSet(rulesets)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }).ToList() + } + } + }; + + protected override void Dispose(bool isDisposing) + { + spotlightsRequest?.Cancel(); + getRankingsRequest?.Cancel(); + cancellationToken?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index a0e4f694bd..0b9a48ce0e 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -8,6 +8,8 @@ using osu.Game.Users; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Rankings.Tables { @@ -30,11 +32,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected override Country GetCountry(CountryStatistics item) => item.Country; - protected override Drawable CreateFlagContent(CountryStatistics item) => new OsuSpriteText - { - Font = OsuFont.GetFont(size: TEXT_SIZE), - Text = $@"{item.Country.FullName}", - }; + protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Country); protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { @@ -63,5 +61,37 @@ namespace osu.Game.Overlays.Rankings.Tables Text = $@"{item.Performance / Math.Max(item.ActiveUsers, 1):N0}", } }; + + private class CountryName : OsuHoverContainer + { + protected override IEnumerable EffectTargets => new[] { text }; + + [Resolved(canBeNull: true)] + private RankingsOverlay rankings { get; set; } + + private readonly OsuSpriteText text; + private readonly Country country; + + public CountryName(Country country) + { + this.country = country; + + AutoSizeAxes = Axes.Both; + Add(text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = country.FullName ?? string.Empty, + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Content2; + + Action = () => rankings?.ShowCountry(country); + } + } } } diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index f947c5585c..943897581e 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Rankings.Tables { public abstract class RankingsTable : TableContainer { - protected const int TEXT_SIZE = 14; + protected const int TEXT_SIZE = 12; private const float horizontal_inset = 20; private const float row_height = 25; private const int items_per_page = 50; @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.Rankings.Tables private static TableColumn[] mainHeaders => new[] { - new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 50)), // place + new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 40)), // place new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed)), // flag and username (country name) }; @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Rankings.Tables private OsuSpriteText createIndexDrawable(int index) => new OsuSpriteText { Text = $"#{index + 1}", - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.SemiBold) }; private FillFlowContainer createMainContent(TModel item) => new FillFlowContainer @@ -112,10 +112,10 @@ namespace osu.Game.Overlays.Rankings.Tables } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { if (Text != highlighted) - Colour = colours.GreySeafoamLighter; + Colour = colourProvider.Foreground1; } } @@ -131,9 +131,9 @@ namespace osu.Game.Overlays.Rankings.Tables protected class ColoredRowText : RowText { [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - Colour = colours.GreySeafoamLighter; + Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs index 04e1c22dae..fe87a8b3d4 100644 --- a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs +++ b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osuTK.Graphics; namespace osu.Game.Overlays.Rankings.Tables @@ -35,10 +34,10 @@ namespace osu.Game.Overlays.Rankings.Tables } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = idleColour = colours.GreySeafoam; - hoverColour = colours.GreySeafoamLight; + background.Colour = idleColour = colourProvider.Background4; + hoverColour = colourProvider.Background3; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 019a278771..cad7364103 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Users; +using osu.Game.Scoring; namespace osu.Game.Overlays.Rankings.Tables { @@ -33,20 +34,20 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override Drawable CreateFlagContent(UserStatistics item) { - var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE)) { AutoSizeAxes = Axes.Both }; + var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { AutoSizeAxes = Axes.Both }; username.AddUserLink(item.User); return username; } protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] { - new ColoredRowText { Text = $@"{item.Accuracy:F2}%", }, + new ColoredRowText { Text = item.DisplayAccuracy, }, new ColoredRowText { Text = $@"{item.PlayCount:N0}", }, }.Concat(CreateUniqueContent(item)).Concat(new[] { - new ColoredRowText { Text = $@"{item.GradesCount.SS + item.GradesCount.SSPlus:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount.S + item.GradesCount.SPlus:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount.A:N0}", } + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]:N0}", }, + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]:N0}", }, + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.A]:N0}", } }).ToArray(); protected abstract TableColumn[] CreateUniqueHeaders(); diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index c8874ef891..7b200d4226 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -6,13 +6,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Overlays.Rankings; using osu.Game.Users; using osu.Game.Rulesets; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using System.Threading; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Rankings.Tables; @@ -20,14 +19,15 @@ namespace osu.Game.Overlays { public class RankingsOverlay : FullscreenOverlay { - protected readonly Bindable Country = new Bindable(); - protected readonly Bindable Scope = new Bindable(); - private readonly Bindable ruleset = new Bindable(); + protected Bindable Country => header.Country; - private readonly BasicScrollContainer scrollFlow; + protected Bindable Scope => header.Current; + + private readonly OverlayScrollContainer scrollFlow; + private readonly Container contentContainer; + private readonly LoadingLayer loading; private readonly Box background; - private readonly Container tableContainer; - private readonly DimmedLoadingLayer loading; + private readonly RankingsOverlayHeader header; private APIRequest lastRequest; private CancellationTokenSource cancellationToken; @@ -36,14 +36,15 @@ namespace osu.Game.Overlays private IAPIProvider api { get; set; } public RankingsOverlay() + : base(OverlayColourScheme.Green) { Children = new Drawable[] { background = new Box { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Both }, - scrollFlow = new BasicScrollContainer + scrollFlow = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -54,13 +55,11 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, Children = new Drawable[] { - new RankingsHeader + header = new RankingsOverlayHeader { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Country = { BindTarget = Country }, - Scope = { BindTarget = Scope }, - Ruleset = { BindTarget = ruleset } + Depth = -float.MaxValue }, new Container { @@ -68,15 +67,15 @@ namespace osu.Game.Overlays AutoSizeAxes = Axes.Y, Children = new Drawable[] { - tableContainer = new Container + contentContainer = new Container { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Vertical = 10 } + Margin = new MarginPadding { Bottom = 10 } }, - loading = new DimmedLoadingLayer(), + loading = new LoadingLayer(contentContainer), } } } @@ -86,18 +85,20 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OsuColour colour) + private void load() { - Waves.FirstWaveColour = colour.Green; - Waves.SecondWaveColour = colour.GreenLight; - Waves.ThirdWaveColour = colour.GreenDark; - Waves.FourthWaveColour = colour.GreenDarker; - - background.Colour = OsuColour.Gray(0.1f); + background.Colour = ColourProvider.Background5; } + [Resolved] + private Bindable ruleset { get; set; } + protected override void LoadComplete() { + base.LoadComplete(); + + header.Ruleset.BindTo(ruleset); + Country.BindValueChanged(_ => { // if a country is requested, force performance scope. @@ -105,7 +106,7 @@ namespace osu.Game.Overlays Scope.Value = RankingsScope.Performance; Scheduler.AddOnce(loadNewContent); - }, true); + }); Scope.BindValueChanged(_ => { @@ -114,11 +115,17 @@ namespace osu.Game.Overlays Country.Value = null; Scheduler.AddOnce(loadNewContent); - }, true); + }); - ruleset.BindValueChanged(_ => Scheduler.AddOnce(loadNewContent), true); + ruleset.BindValueChanged(_ => + { + if (Scope.Value == RankingsScope.Spotlights) + return; - base.LoadComplete(); + Scheduler.AddOnce(loadNewContent); + }); + + Scheduler.AddOnce(loadNewContent); } public void ShowCountry(Country requested) @@ -131,6 +138,12 @@ namespace osu.Game.Overlays Country.Value = requested; } + public void ShowSpotlights() + { + Scope.Value = RankingsScope.Spotlights; + Show(); + } + private void loadNewContent() { loading.Show(); @@ -138,17 +151,26 @@ namespace osu.Game.Overlays cancellationToken?.Cancel(); lastRequest?.Cancel(); + if (Scope.Value == RankingsScope.Spotlights) + { + loadContent(new SpotlightsLayout + { + Ruleset = { BindTarget = ruleset } + }); + return; + } + var request = createScopedRequest(); lastRequest = request; if (request == null) { - loadTable(null); + loadContent(null); return; } - request.Success += () => loadTable(createTableFromResponse(request)); - request.Failure += _ => loadTable(null); + request.Success += () => Schedule(() => loadContent(createTableFromResponse(request))); + request.Failure += _ => Schedule(() => loadContent(null)); api.Queue(request); } @@ -193,22 +215,30 @@ namespace osu.Game.Overlays return null; } - private void loadTable(Drawable table) + private void loadContent(Drawable content) { scrollFlow.ScrollToStart(); - if (table == null) + if (content == null) { - tableContainer.Clear(); + contentContainer.Clear(); loading.Hide(); return; } - LoadComponentAsync(table, t => + LoadComponentAsync(content, loaded => { loading.Hide(); - tableContainer.Child = table; + contentContainer.Child = loaded; }, (cancellationToken = new CancellationTokenSource()).Token); } + + protected override void Dispose(bool isDisposing) + { + lastRequest?.Cancel(); + cancellationToken?.Cancel(); + + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs b/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs index a33f4eb30d..ffbc1c9586 100644 --- a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs +++ b/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Bindables; using osuTK; using osu.Framework.Graphics; @@ -11,44 +10,23 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.SearchableList { - public class DisplayStyleControl : Container - where T : struct, Enum + public class DisplayStyleControl : CompositeDrawable { - public readonly SlimEnumDropdown Dropdown; public readonly Bindable DisplayStyle = new Bindable(); public DisplayStyleControl() { AutoSizeAxes = Axes.Both; - Children = new[] + InternalChild = new FillFlowContainer { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5f, 0f), + Direction = FillDirection.Horizontal, + Children = new[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Spacing = new Vector2(10f, 0f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f, 0f), - Direction = FillDirection.Horizontal, - Children = new[] - { - new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), - new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), - }, - }, - Dropdown = new SlimEnumDropdown - { - RelativeSizeAxes = Axes.None, - Width = 160f, - }, - }, + new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), + new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), }, }; @@ -91,6 +69,8 @@ namespace osu.Game.Overlays.SearchableList protected override void Dispose(bool isDisposing) { + base.Dispose(isDisposing); + bindable.ValueChanged -= Bindable_ValueChanged; } } diff --git a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs index 117f905de4..e0163b5b0c 100644 --- a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs +++ b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs @@ -19,12 +19,14 @@ namespace osu.Game.Overlays.SearchableList { private const float padding = 10; - private readonly Container filterContainer; + private readonly Drawable filterContainer; + private readonly Drawable rightFilterContainer; private readonly Box tabStrip; public readonly SearchTextBox Search; public readonly PageTabControl Tabs; - public readonly DisplayStyleControl DisplayStyleControl; + public readonly SlimEnumDropdown Dropdown; + public readonly DisplayStyleControl DisplayStyleControl; protected abstract Color4 BackgroundColour { get; } protected abstract TTab DefaultTab { get; } @@ -42,7 +44,7 @@ namespace osu.Game.Overlays.SearchableList var controls = CreateSupplementaryControls(); Container controlsContainer; - Children = new Drawable[] + Children = new[] { filterContainer = new Container { @@ -94,7 +96,7 @@ namespace osu.Game.Overlays.SearchableList RelativeSizeAxes = Axes.X, }, }, - new Box //keep the tab strip part of autosize, but don't put it in the flow container + new Box // keep the tab strip part of autosize, but don't put it in the flow container { RelativeSizeAxes = Axes.X, Height = 1, @@ -104,11 +106,27 @@ namespace osu.Game.Overlays.SearchableList }, }, }, - DisplayStyleControl = new DisplayStyleControl + rightFilterContainer = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + Dropdown = new SlimEnumDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.None, + Width = 160f, + }, + DisplayStyleControl = new DisplayStyleControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + } }; if (controls != null) controlsContainer.Children = new[] { controls }; @@ -116,8 +134,8 @@ namespace osu.Game.Overlays.SearchableList Tabs.Current.Value = DefaultTab; Tabs.Current.TriggerChange(); - DisplayStyleControl.Dropdown.Current.Value = DefaultCategory; - DisplayStyleControl.Dropdown.Current.TriggerChange(); + Dropdown.Current.Value = DefaultCategory; + Dropdown.Current.TriggerChange(); } [BackgroundDependencyLoader] @@ -131,13 +149,11 @@ namespace osu.Game.Overlays.SearchableList base.Update(); Height = filterContainer.Height; - DisplayStyleControl.Margin = new MarginPadding { Top = filterContainer.Height - 35, Right = SearchableListOverlay.WIDTH_PADDING }; + rightFilterContainer.Margin = new MarginPadding { Top = filterContainer.Height - 30, Right = ContentHorizontalPadding }; } private class FilterSearchTextBox : SearchTextBox { - protected override bool AllowCommit => true; - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs b/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs index 5975e94ffc..4ab2de06b6 100644 --- a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs +++ b/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; namespace osu.Game.Overlays.SearchableList @@ -16,6 +15,11 @@ namespace osu.Game.Overlays.SearchableList public abstract class SearchableListOverlay : FullscreenOverlay { public const float WIDTH_PADDING = 80; + + protected SearchableListOverlay(OverlayColourScheme colourScheme) + : base(colourScheme) + { + } } public abstract class SearchableListOverlay : SearchableListOverlay @@ -35,7 +39,8 @@ namespace osu.Game.Overlays.SearchableList protected abstract SearchableListHeader CreateHeader(); protected abstract SearchableListFilterControl CreateFilterControl(); - protected SearchableListOverlay() + protected SearchableListOverlay(OverlayColourScheme colourScheme) + : base(colourScheme) { Children = new Drawable[] { @@ -66,7 +71,7 @@ namespace osu.Game.Overlays.SearchableList { RelativeSizeAxes = Axes.Both, Masking = true, - Child = new OsuScrollContainer + Child = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -74,7 +79,7 @@ namespace osu.Game.Overlays.SearchableList { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = WIDTH_PADDING, Bottom = 50 }, + Padding = new MarginPadding { Horizontal = 10, Bottom = 50 }, Direction = FillDirection.Vertical, }, }, diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index 0612f028bc..3da64e0de4 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -14,14 +14,10 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { protected override string Header => "Devices"; - private AudioManager audio; - private SettingsDropdown dropdown; + [Resolved] + private AudioManager audio { get; set; } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - this.audio = audio; - } + private SettingsDropdown dropdown; protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index 0124f7090e..bda677ecd6 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -17,10 +17,34 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { Children = new Drawable[] { - new SettingsSlider { LabelText = "Master", Bindable = audio.Volume, KeyboardStep = 0.01f }, - new SettingsSlider { LabelText = "Master (window inactive)", Bindable = config.GetBindable(OsuSetting.VolumeInactive), KeyboardStep = 0.01f }, - new SettingsSlider { LabelText = "Effect", Bindable = audio.VolumeSample, KeyboardStep = 0.01f }, - new SettingsSlider { LabelText = "Music", Bindable = audio.VolumeTrack, KeyboardStep = 0.01f }, + new SettingsSlider + { + LabelText = "Master", + Bindable = audio.Volume, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = "Master (window inactive)", + Bindable = config.GetBindable(OsuSetting.VolumeInactive), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = "Effect", + Bindable = audio.VolumeSample, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = "Music", + Bindable = audio.VolumeTrack, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index b18488b616..69538358f1 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -13,9 +13,12 @@ namespace osu.Game.Overlays.Settings.Sections { public override string Header => "Audio"; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "sound" }); + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.VolumeUp + }; - public override IconUsage Icon => FontAwesome.Solid.VolumeUp; + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "sound" }); public AudioSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs index 457f064f89..9edb18e065 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs @@ -22,11 +22,6 @@ namespace osu.Game.Overlays.Settings.Sections.Debug Bindable = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) }, new SettingsCheckbox - { - LabelText = "Performance logging", - Bindable = config.GetBindable(DebugSetting.PerformanceLogging) - }, - new SettingsCheckbox { LabelText = "Bypass front-to-back render pass", Bindable = config.GetBindable(DebugSetting.BypassFrontToBackPass) diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index f62de0b243..44d4088972 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class DebugSection : SettingsSection { public override string Header => "Debug"; - public override IconUsage Icon => FontAwesome.Solid.Bug; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Bug + }; public DebugSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 3f8bc2b0c7..93a02ea0e4 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -21,13 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { LabelText = "Background dim", Bindable = config.GetBindable(OsuSetting.DimLevel), - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider { LabelText = "Background blur", Bindable = config.GetBindable(OsuSetting.BlurLevel), - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsCheckbox { @@ -40,16 +42,31 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Bindable = config.GetBindable(OsuSetting.ShowInterface) }, new SettingsCheckbox + { + LabelText = "Show difficulty graph on progress bar", + Bindable = config.GetBindable(OsuSetting.ShowProgressGraph) + }, + new SettingsCheckbox { LabelText = "Show health display even when you can't fail", Bindable = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), Keywords = new[] { "hp", "bar" } }, new SettingsCheckbox + { + LabelText = "Fade playfield to red when health is low", + Bindable = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), + }, + new SettingsCheckbox { LabelText = "Always show key overlay", Bindable = config.GetBindable(OsuSetting.KeyOverlay) }, + new SettingsCheckbox + { + LabelText = "Positional hitsounds", + Bindable = config.GetBindable(OsuSetting.PositionalHitSounds) + }, new SettingsEnumDropdown { LabelText = "Score meter type", diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs index a5f56ae76e..0c42247993 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.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 osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -10,11 +12,20 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class SongSelectSettings : SettingsSubsection { + private Bindable minStars; + private Bindable maxStars; + protected override string Header => "Song Select"; [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 @@ -27,19 +38,19 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = "Show converted beatmaps", Bindable = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), }, - new SettingsSlider + new SettingsSlider { LabelText = "Display beatmaps from", Bindable = config.GetBindable(OsuSetting.DisplayStarsMinimum), KeyboardStep = 0.1f, - Keywords = new[] { "star", "difficulty" } + Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, - new SettingsSlider + new SettingsSlider { LabelText = "up to", Bindable = config.GetBindable(OsuSetting.DisplayStarsMaximum), KeyboardStep = 0.1f, - Keywords = new[] { "star", "difficulty" } + Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, new SettingsEnumDropdown { @@ -49,7 +60,12 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay }; } - private class StarSlider : OsuSliderBar + private class MaximumStarsSlider : StarsSlider + { + public override string TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; + } + + private class StarsSlider : OsuSliderBar { public override string TooltipText => Current.Value.ToString(@"0.## stars"); } diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index 97d9d3c697..aca507f20a 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -13,7 +13,11 @@ namespace osu.Game.Overlays.Settings.Sections public class GameplaySection : SettingsSection { public override string Header => "Gameplay"; - public override IconUsage Icon => FontAwesome.Regular.Circle; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Regular.Circle + }; public GameplaySection() { diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs index 27796c1e32..52b712a40e 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs @@ -29,7 +29,9 @@ namespace osu.Game.Overlays.Settings.Sections.General { private bool bounding = true; private LoginForm form; - private OsuColour colours; + + [Resolved] + private OsuColour colours { get; set; } private UserPanel panel; private UserDropdown dropdown; @@ -60,10 +62,8 @@ namespace osu.Game.Overlays.Settings.Sections.General } [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, IAPIProvider api) + private void load(IAPIProvider api) { - this.colours = colours; - api?.Register(this); } @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { Text = "ACCOUNT", Margin = new MarginPadding { Bottom = 5 }, - Font = OsuFont.GetFont(weight: FontWeight.Black), + Font = OsuFont.GetFont(weight: FontWeight.Bold), }, form = new LoginForm { @@ -95,7 +95,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Children = new Drawable[] { - new LoadingAnimation + new LoadingSpinner { State = { Value = Visibility.Visible }, Anchor = Anchor.TopCentre, @@ -143,7 +143,7 @@ namespace osu.Game.Overlays.Settings.Sections.General }, }, }, - panel = new UserPanel(api.LocalUser.Value) + panel = new UserGridPanel(api.LocalUser.Value) { RelativeSizeAxes = Axes.X, Action = RequestHide @@ -201,7 +201,9 @@ namespace osu.Game.Overlays.Settings.Sections.General private TextBox username; private TextBox password; private ShakeContainer shakeSignIn; - private IAPIProvider api; + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } public Action RequestHide; @@ -214,9 +216,8 @@ namespace osu.Game.Overlays.Settings.Sections.General } [BackgroundDependencyLoader(permitNulls: true)] - private void load(IAPIProvider api, OsuConfigManager config, AccountCreationOverlay accountCreation) + private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) { - this.api = api; Direction = FillDirection.Vertical; Spacing = new Vector2(0, 5); AutoSizeAxes = Axes.Y; @@ -323,8 +324,6 @@ namespace osu.Game.Overlays.Settings.Sections.General Colour = Color4.Black.Opacity(0.25f), Radius = 4, }; - - ItemsContainer.Padding = new MarginPadding(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 188c9c05ef..9c7d0b0be4 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -1,19 +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.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Game.Configuration; +using osu.Game.Overlays.Settings.Sections.Maintenance; +using osu.Game.Updater; namespace osu.Game.Overlays.Settings.Sections.General { public class UpdateSettings : SettingsSubsection { + [Resolved(CanBeNull = true)] + private UpdateManager updateManager { get; set; } + protected override string Header => "Updates"; - [BackgroundDependencyLoader] - private void load(Storage storage, OsuConfigManager config) + private SettingsButton checkForUpdatesButton; + + [BackgroundDependencyLoader(true)] + private void load(Storage storage, OsuConfigManager config, OsuGame game) { Add(new SettingsEnumDropdown { @@ -21,6 +30,19 @@ namespace osu.Game.Overlays.Settings.Sections.General Bindable = config.GetBindable(OsuSetting.ReleaseStream), }); + if (updateManager?.CanCheckForUpdate == true) + { + Add(checkForUpdatesButton = new SettingsButton + { + Text = "Check for updates", + Action = () => + { + checkForUpdatesButton.Enabled.Value = false; + Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(t => Schedule(() => checkForUpdatesButton.Enabled.Value = true)); + } + }); + } + if (RuntimeInfo.IsDesktop) { Add(new SettingsButton @@ -28,6 +50,12 @@ namespace osu.Game.Overlays.Settings.Sections.General Text = "Open osu! folder", Action = storage.OpenInNativeExplorer, }); + + Add(new SettingsButton + { + Text = "Change folder location...", + Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index d9947f16cc..fefc3fe6a7 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class GeneralSection : SettingsSection { public override string Header => "General"; - public override IconUsage Icon => FontAwesome.Solid.Cog; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Cog + }; public GeneralSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs index ea2811e5cd..3089040f96 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs @@ -18,15 +18,10 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { new SettingsCheckbox { - LabelText = "Storyboards", + LabelText = "Storyboard / Video", Bindable = config.GetBindable(OsuSetting.ShowStoryboard) }, new SettingsCheckbox - { - LabelText = "Video", - Bindable = config.GetBindable(OsuSetting.ShowVideoBackground) - }, - new SettingsCheckbox { LabelText = "Hit Lighting", Bindable = config.GetBindable(OsuSetting.HitLighting) diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 02b9edd975..00b7643332 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -29,7 +29,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private Bindable sizeFullscreen; private readonly IBindableList windowModes = new BindableList(); - private OsuGameBase game; + [Resolved] + private OsuGameBase game { get; set; } + private SettingsDropdown resolutionDropdown; private SettingsDropdown windowModeDropdown; @@ -41,10 +43,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private const int transition_duration = 400; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, OsuGameBase game, GameHost host) + private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) { - this.game = game; - scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); @@ -98,25 +98,29 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { LabelText = "Horizontal position", Bindable = scalingPositionX, - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider { LabelText = "Vertical position", Bindable = scalingPositionY, - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider { LabelText = "Horizontal scale", Bindable = scalingSizeX, - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider { LabelText = "Vertical scale", Bindable = scalingSizeY, - KeyboardStep = 0.01f + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, } }, @@ -205,15 +209,16 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private IReadOnlyList getResolutions() { var resolutions = new List { new Size(9999, 9999) }; + var currentDisplay = game.Window?.CurrentDisplay.Value; - if (game.Window != null) + if (currentDisplay != null) { - resolutions.AddRange(game.Window.AvailableResolutions - .Where(r => r.Width >= 800 && r.Height >= 600) - .OrderByDescending(r => r.Width) - .ThenByDescending(r => r.Height) - .Select(res => new Size(res.Width, res.Height)) - .Distinct()); + resolutions.AddRange(currentDisplay.DisplayModes + .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) + .OrderByDescending(m => m.Size.Width) + .ThenByDescending(m => m.Size.Height) + .Select(m => m.Size) + .Distinct()); } return resolutions; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 7317076c54..69ff9b43e5 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Graphics @@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics LabelText = "Frame limiter", Bindable = config.GetBindable(FrameworkSetting.FrameSync) }, + new SettingsEnumDropdown + { + LabelText = "Threading mode", + Bindable = config.GetBindable(FrameworkSetting.ExecutionMode) + }, new SettingsCheckbox { LabelText = "Show FPS", diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index 89caa3dc8f..c1b4b0bbcb 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class GraphicsSection : SettingsSection { public override string Header => "Graphics"; - public override IconUsage Icon => FontAwesome.Solid.Laptop; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Laptop + }; public GraphicsSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs index 55c7210d6c..db6f24a954 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input new SettingsButton { Text = "Key configuration", - TooltipText = "Change global shortcut keys and gameplay bindings", + TooltipText = "change global shortcut keys and gameplay bindings", Action = keyConfig.ToggleVisibility }, }; diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 4f2f3dfd1a..d27ab63fb7 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -16,12 +17,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override string Header => "Mouse"; private readonly BindableBool rawInputToggle = new BindableBool(); + private Bindable sensitivityBindable = new BindableDouble(); private Bindable ignoredInputHandler; - private SensitivitySetting sensitivity; [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) { + var configSensitivity = config.GetBindable(FrameworkSetting.CursorSensitivity); + + // use local bindable to avoid changing enabled state of game host's bindable. + sensitivityBindable = configSensitivity.GetUnboundCopy(); + configSensitivity.BindValueChanged(val => sensitivityBindable.Value = val.NewValue); + sensitivityBindable.BindValueChanged(val => configSensitivity.Value = val.NewValue); + Children = new Drawable[] { new SettingsCheckbox @@ -29,10 +37,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input LabelText = "Raw input", Bindable = rawInputToggle }, - sensitivity = new SensitivitySetting + new SensitivitySetting { LabelText = "Cursor sensitivity", - Bindable = config.GetBindable(FrameworkSetting.CursorSensitivity) + Bindable = sensitivityBindable }, new SettingsCheckbox { @@ -56,24 +64,32 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, }; - rawInputToggle.ValueChanged += enabled => + if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) { - // this is temporary until we support per-handler settings. - const string raw_mouse_handler = @"OsuTKRawMouseHandler"; - const string standard_mouse_handler = @"OsuTKMouseHandler"; - - ignoredInputHandler.Value = enabled.NewValue ? standard_mouse_handler : raw_mouse_handler; - }; - - ignoredInputHandler = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); - ignoredInputHandler.ValueChanged += handler => + rawInputToggle.Disabled = true; + sensitivityBindable.Disabled = true; + } + else { - bool raw = !handler.NewValue.Contains("Raw"); - rawInputToggle.Value = raw; - sensitivity.Bindable.Disabled = !raw; - }; + rawInputToggle.ValueChanged += enabled => + { + // this is temporary until we support per-handler settings. + const string raw_mouse_handler = @"OsuTKRawMouseHandler"; + const string standard_mouse_handler = @"OsuTKMouseHandler"; - ignoredInputHandler.TriggerChange(); + ignoredInputHandler.Value = enabled.NewValue ? standard_mouse_handler : raw_mouse_handler; + }; + + ignoredInputHandler = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); + ignoredInputHandler.ValueChanged += handler => + { + bool raw = !handler.NewValue.Contains("Raw"); + rawInputToggle.Value = raw; + sensitivityBindable.Disabled = !raw; + }; + + ignoredInputHandler.TriggerChange(); + } } private class SensitivitySetting : SettingsSlider @@ -87,7 +103,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override string TooltipText => Current.Disabled ? "Enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; + public override string TooltipText => Current.Disabled ? "enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 2a348b4e03..b43453f53d 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class InputSection : SettingsSection { public override string Header => "Input"; - public override IconUsage Icon => FontAwesome.Regular.Keyboard; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Keyboard + }; public InputSection(KeyBindingPanel keyConfig) { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs new file mode 100644 index 0000000000..b0b61554eb --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.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.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class MigrationRunScreen : OsuScreen + { + private readonly DirectoryInfo destination; + + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + public override bool AllowBackButton => false; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + private Task migrationTask; + + public MigrationRunScreen(DirectoryInfo destination) + { + this.destination = destination; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + 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 = "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 LoadingSpinner(true) + { + State = { Value = Visibility.Visible } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please avoid interacting with the game!", + Font = OsuFont.Default.With(size: 30) + }, + } + }, + }; + + Beatmap.Value = Beatmap.Default; + + migrationTask = Task.Run(PerformMigration) + .ContinueWith(t => + { + if (t.IsFaulted) + Logger.Log($"Error during migration: {t.Exception?.Message}", level: LogLevel.Error); + + Schedule(this.Exit); + }); + } + + protected virtual void PerformMigration() => game?.Migrate(destination.FullName); + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + this.FadeOut().Delay(250).Then().FadeIn(250); + } + + public override bool OnExiting(IScreen next) + { + // block until migration is finished + if (migrationTask?.IsCompleted == false) + return true; + + return base.OnExiting(next); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs new file mode 100644 index 0000000000..79d842a617 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.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.Overlays.Settings.Sections.Maintenance +{ + public class MigrationSelectScreen : OsuScreen + { + private DirectorySelector directorySelector; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, Storage storage, OsuColour colours) + { + game?.Toolbar.Hide(); + + // begin selection in the parent directory of the current storage location + var initialPath = new DirectoryInfo(storage.GetFullPath(string.Empty)).Parent?.FullName; + + InternalChild = new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.5f, 0.8f), + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.8f), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please select a new location", + Font = OsuFont.Default.With(size: 40) + }, + }, + new Drawable[] + { + directorySelector = new DirectorySelector(initialPath) + { + RelativeSizeAxes = Axes.Both, + } + }, + new Drawable[] + { + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Begin folder migration", + Action = start + }, + } + } + } + } + }; + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + + this.FadeOut(250); + } + + private void start() + { + var target = directorySelector.CurrentDirectory.Value; + + try + { + if (target.GetDirectories().Length > 0 || target.GetFiles().Length > 0) + target = target.CreateSubdirectory("osu-lazer"); + } + catch (Exception e) + { + Logger.Log($"Error during migration: {e.Message}", level: LogLevel.Error); + return; + } + + ValidForResume = false; + BeginMigration(target); + } + + protected virtual void BeginMigration(DirectoryInfo target) => this.Push(new MigrationRunScreen(target)); + } +} diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index 0f3acd5b7f..73c88b8e71 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -11,7 +11,11 @@ namespace osu.Game.Overlays.Settings.Sections public class MaintenanceSection : SettingsSection { public override string Header => "Maintenance"; - public override IconUsage Icon => FontAwesome.Solid.Wrench; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Wrench + }; public MaintenanceSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index a8b3e45a83..23513eade8 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -21,6 +21,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online LabelText = "Warn about opening external links", Bindable = config.GetBindable(OsuSetting.ExternalLinkWarning) }, + new SettingsCheckbox + { + LabelText = "Prefer downloads without video", + Keywords = new[] { "no-video" }, + Bindable = config.GetBindable(OsuSetting.PreferNoVideo) + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 80295690c0..150cddb388 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class OnlineSection : SettingsSection { public override string Header => "Online"; - public override IconUsage Icon => FontAwesome.Solid.GlobeAsia; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.GlobeAsia + }; public OnlineSection() { diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index d3029d8ab9..04390a1193 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.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; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Skinning; @@ -19,22 +21,29 @@ namespace osu.Game.Overlays.Settings.Sections public override string Header => "Skin"; - public override IconUsage Icon => FontAwesome.Solid.PaintBrush; + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.PaintBrush + }; private readonly Bindable dropdownBindable = new Bindable { Default = SkinInfo.Default }; private readonly Bindable configBindable = new Bindable(); - private SkinManager skins; + [Resolved] + private SkinManager skins { get; set; } + + private IBindable> managerUpdated; + private IBindable> managerRemoved; [BackgroundDependencyLoader] - private void load(OsuConfigManager config, SkinManager skins) + private void load(OsuConfigManager config) { - this.skins = skins; - FlowContent.Spacing = new Vector2(0, 5); + Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown(), + new ExportSkinButton(), new SettingsSlider { LabelText = "Menu cursor size", @@ -64,8 +73,11 @@ namespace osu.Game.Overlays.Settings.Sections }, }; - skins.ItemAdded += itemAdded; - skins.ItemRemoved += itemRemoved; + managerUpdated = skins.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(itemUpdated); + + managerRemoved = skins.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(itemRemoved); config.BindWith(OsuSetting.Skin, configBindable); @@ -80,19 +92,16 @@ namespace osu.Game.Overlays.Settings.Sections dropdownBindable.BindValueChanged(skin => configBindable.Value = skin.NewValue.ID); } - private void itemRemoved(SkinInfo s) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => i.ID != s.ID).ToArray()); - - private void itemAdded(SkinInfo s) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Append(s).ToArray()); - - protected override void Dispose(bool isDisposing) + private void itemUpdated(ValueChangedEvent> weakItem) { - base.Dispose(isDisposing); + if (weakItem.NewValue.TryGetTarget(out var item)) + Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToArray()); + } - if (skins != null) - { - skins.ItemAdded -= itemAdded; - skins.ItemRemoved -= itemRemoved; - } + private void itemRemoved(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => i.ID != item.ID).ToArray()); } private class SizeSlider : OsuSliderBar @@ -111,5 +120,35 @@ namespace osu.Game.Overlays.Settings.Sections protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 200); } } + + private class ExportSkinButton : SettingsButton + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = "Export selected skin"; + Action = export; + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true); + } + + private void export() + { + try + { + skins.Export(currentSkin.Value.SkinInfo); + } + catch (Exception e) + { + Logger.Log($"Could not export current skin: {e.Message}", level: LogLevel.Error); + } + } + } } } diff --git a/osu.Game/Overlays/Settings/SettingsCheckbox.cs b/osu.Game/Overlays/Settings/SettingsCheckbox.cs index a554159fd7..437b2e45b3 100644 --- a/osu.Game/Overlays/Settings/SettingsCheckbox.cs +++ b/osu.Game/Overlays/Settings/SettingsCheckbox.cs @@ -8,16 +8,14 @@ namespace osu.Game.Overlays.Settings { public class SettingsCheckbox : SettingsItem { - private OsuCheckbox checkbox; - private string labelText; - protected override Drawable CreateControl() => checkbox = new OsuCheckbox(); + protected override Drawable CreateControl() => new OsuCheckbox(); public override string LabelText { get => labelText; - set => checkbox.LabelText = labelText = value; + set => ((OsuCheckbox)Control).LabelText = labelText = value; } } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 35f28ab1b2..c2dd40d2a6 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -33,22 +33,24 @@ namespace osu.Game.Overlays.Settings protected readonly FillFlowContainer FlowContent; - private SpriteText text; + private SpriteText labelText; public bool ShowsDefaultIndicator = true; public virtual string LabelText { - get => text?.Text ?? string.Empty; + get => labelText?.Text ?? string.Empty; set { - if (text == null) + if (labelText == null) { // construct lazily for cases where the label is not needed (may be provided by the Control). - FlowContent.Insert(-1, text = new OsuSpriteText()); + FlowContent.Insert(-1, labelText = new OsuSpriteText()); + + updateDisabled(); } - text.Text = value; + labelText.Text = value; } } @@ -96,13 +98,19 @@ namespace osu.Game.Overlays.Settings if (controlWithCurrent != null) { controlWithCurrent.Current.ValueChanged += _ => SettingChanged?.Invoke(); - controlWithCurrent.Current.DisabledChanged += disabled => { Colour = disabled ? Color4.Gray : Color4.White; }; + controlWithCurrent.Current.DisabledChanged += _ => updateDisabled(); if (ShowsDefaultIndicator) restoreDefaultButton.Bindable = controlWithCurrent.Current; } } + private void updateDisabled() + { + if (labelText != null) + labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; + } + private class RestoreDefaultValueButton : Container, IHasTooltip { private Bindable bindable; @@ -161,11 +169,7 @@ namespace osu.Game.Overlays.Settings UpdateState(); } - public string TooltipText => "Revert to default"; - - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnMouseUp(MouseUpEvent e) => true; + public string TooltipText => "revert to default"; protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index be3696029e..97e4ba9da7 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Settings { @@ -20,7 +19,7 @@ namespace osu.Game.Overlays.Settings protected FillFlowContainer FlowContent; protected override Container Content => FlowContent; - public abstract IconUsage Icon { get; } + public abstract Drawable CreateIcon(); public abstract string Header { get; } public IEnumerable FilterableChildren => Children.OfType(); diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index 96c0279a7b..9fc3379b94 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings @@ -22,16 +23,32 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X }; + /// + /// When set, value changes based on user input are only transferred to any bound control's Current on commit. + /// This is useful if the UI interaction could be adversely affected by the value changing, such as the position of the on the screen. + /// public bool TransferValueOnCommit { get => ((TSlider)Control).TransferValueOnCommit; set => ((TSlider)Control).TransferValueOnCommit = value; } + /// + /// A custom step value for each key press which actuates a change on this control. + /// public float KeyboardStep { get => ((TSlider)Control).KeyboardStep; set => ((TSlider)Control).KeyboardStep = value; } + + /// + /// Whether to format the tooltip as a percentage or the actual value. + /// + public bool DisplayAsPercentage + { + get => ((TSlider)Control).DisplayAsPercentage; + set => ((TSlider)Control).DisplayAsPercentage = value; + } } } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 9b3b2f570c..b096c146a6 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Settings { Text = Header.ToUpperInvariant(), Margin = new MarginPadding { Bottom = 10, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, - Font = OsuFont.GetFont(weight: FontWeight.Black), + Font = OsuFont.GetFont(weight: FontWeight.Bold), }, FlowContent }); diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index 68836bc6b3..30a53b351d 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -11,12 +11,13 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { public class SidebarButton : OsuButton { - private readonly SpriteIcon drawableIcon; + private readonly ConstrainedIconContainer iconContainer; private readonly SpriteText headerText; private readonly Box selectionIndicator; private readonly Container text; @@ -30,7 +31,7 @@ namespace osu.Game.Overlays.Settings { section = value; headerText.Text = value.Header; - drawableIcon.Icon = value.Icon; + iconContainer.Icon = value.CreateIcon(); } } @@ -78,7 +79,7 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - drawableIcon = new SpriteIcon + iconContainer = new ConstrainedIconContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Overlays/Social/FilterControl.cs b/osu.Game/Overlays/Social/FilterControl.cs deleted file mode 100644 index 1c2cb95dfe..0000000000 --- a/osu.Game/Overlays/Social/FilterControl.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 osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Overlays.SearchableList; - -namespace osu.Game.Overlays.Social -{ - public class FilterControl : SearchableListFilterControl - { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"47253a"); - protected override SocialSortCriteria DefaultTab => SocialSortCriteria.Rank; - protected override SortDirection DefaultCategory => SortDirection.Ascending; - - public FilterControl() - { - Tabs.Margin = new MarginPadding { Top = 10 }; - } - } - - public enum SocialSortCriteria - { - Rank, - Name, - Location, - //[Description("Time Zone")] - //TimeZone, - //[Description("World Map")] - //WorldMap, - } -} diff --git a/osu.Game/Overlays/Social/Header.cs b/osu.Game/Overlays/Social/Header.cs deleted file mode 100644 index 22bca9b421..0000000000 --- a/osu.Game/Overlays/Social/Header.cs +++ /dev/null @@ -1,66 +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.Overlays.SearchableList; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Framework.Allocation; -using System.ComponentModel; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Overlays.Social -{ - public class Header : SearchableListHeader - { - private OsuSpriteText browser; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"38202e"); - - protected override SocialTab DefaultTab => SocialTab.AllPlayers; - protected override IconUsage Icon => FontAwesome.Solid.Users; - - protected override Drawable CreateHeaderText() - { - return new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Text = "social ", - Font = OsuFont.GetFont(size: 25), - }, - browser = new OsuSpriteText - { - Text = "browser", - Font = OsuFont.GetFont(size: 25, weight: FontWeight.Light), - }, - }, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - browser.Colour = colours.Pink; - } - } - - public enum SocialTab - { - [Description("All Players")] - AllPlayers, - - [Description("Friends")] - Friends, - //[Description("Team Members")] - //TeamMembers, - //[Description("Chat Channels")] - //ChatChannels, - } -} diff --git a/osu.Game/Overlays/Social/SocialGridPanel.cs b/osu.Game/Overlays/Social/SocialGridPanel.cs deleted file mode 100644 index 6f707d640b..0000000000 --- a/osu.Game/Overlays/Social/SocialGridPanel.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. - -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialGridPanel : SocialPanel - { - public SocialGridPanel(User user) - : base(user) - { - Width = 300; - } - } -} diff --git a/osu.Game/Overlays/Social/SocialListPanel.cs b/osu.Game/Overlays/Social/SocialListPanel.cs deleted file mode 100644 index 1ba91e9204..0000000000 --- a/osu.Game/Overlays/Social/SocialListPanel.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialListPanel : SocialPanel - { - public SocialListPanel(User user) - : base(user) - { - RelativeSizeAxes = Axes.X; - } - } -} diff --git a/osu.Game/Overlays/Social/SocialPanel.cs b/osu.Game/Overlays/Social/SocialPanel.cs deleted file mode 100644 index 555527670a..0000000000 --- a/osu.Game/Overlays/Social/SocialPanel.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 osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; -using osu.Framework.Input.Events; -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialPanel : UserPanel - { - private const double hover_transition_time = 400; - - public SocialPanel(User user) - : base(user) - { - } - - private readonly EdgeEffectParameters edgeEffectNormal = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 1f), - Radius = 2f, - Colour = Color4.Black.Opacity(0.25f), - }; - - private readonly EdgeEffectParameters edgeEffectHovered = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 5f), - Radius = 10f, - Colour = Color4.Black.Opacity(0.3f), - }; - - protected override bool OnHover(HoverEvent e) - { - Content.TweenEdgeEffectTo(edgeEffectHovered, hover_transition_time, Easing.OutQuint); - Content.MoveToY(-4, hover_transition_time, Easing.OutQuint); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - Content.TweenEdgeEffectTo(edgeEffectNormal, hover_transition_time, Easing.OutQuint); - Content.MoveToY(0, hover_transition_time, Easing.OutQuint); - - base.OnHoverLost(e); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - this.FadeInFromZero(200, Easing.Out); - } - } -} diff --git a/osu.Game/Overlays/SocialOverlay.cs b/osu.Game/Overlays/SocialOverlay.cs deleted file mode 100644 index 0c99962def..0000000000 --- a/osu.Game/Overlays/SocialOverlay.cs +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.SearchableList; -using osu.Game.Overlays.Social; -using osu.Game.Users; -using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Threading; - -namespace osu.Game.Overlays -{ - public class SocialOverlay : SearchableListOverlay - { - private readonly LoadingAnimation loading; - private FillFlowContainer panels; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"60284b"); - protected override Color4 TrianglesColourLight => OsuColour.FromHex(@"672b51"); - protected override Color4 TrianglesColourDark => OsuColour.FromHex(@"5c2648"); - - protected override SearchableListHeader CreateHeader() => new Header(); - protected override SearchableListFilterControl CreateFilterControl() => new FilterControl(); - - private User[] users = Array.Empty(); - - public User[] Users - { - get => users; - set - { - if (users == value) - return; - - users = value ?? Array.Empty(); - - if (LoadState >= LoadState.Ready) - recreatePanels(); - } - } - - public SocialOverlay() - { - Waves.FirstWaveColour = OsuColour.FromHex(@"cb5fa0"); - Waves.SecondWaveColour = OsuColour.FromHex(@"b04384"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"9b2b6e"); - Waves.FourthWaveColour = OsuColour.FromHex(@"6d214d"); - - Add(loading = new LoadingAnimation()); - - Filter.Search.Current.ValueChanged += text => - { - if (!string.IsNullOrEmpty(text.NewValue)) - { - // force searching in players until searching for friends is supported - Header.Tabs.Current.Value = SocialTab.AllPlayers; - - if (Filter.Tabs.Current.Value != SocialSortCriteria.Rank) - Filter.Tabs.Current.Value = SocialSortCriteria.Rank; - } - }; - - Header.Tabs.Current.ValueChanged += _ => queueUpdate(); - Filter.Tabs.Current.ValueChanged += _ => onFilterUpdate(); - - Filter.DisplayStyleControl.DisplayStyle.ValueChanged += _ => recreatePanels(); - Filter.DisplayStyleControl.Dropdown.Current.ValueChanged += _ => recreatePanels(); - - currentQuery.BindTo(Filter.Search.Current); - currentQuery.ValueChanged += query => - { - queryChangedDebounce?.Cancel(); - - if (string.IsNullOrEmpty(query.NewValue)) - queueUpdate(); - else - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, 500); - }; - } - - [BackgroundDependencyLoader] - private void load() - { - recreatePanels(); - } - - private APIRequest getUsersRequest; - - private readonly Bindable currentQuery = new Bindable(); - - private ScheduledDelegate queryChangedDebounce; - - private void queueUpdate() => Scheduler.AddOnce(updateSearch); - - private CancellationTokenSource loadCancellation; - - private void updateSearch() - { - queryChangedDebounce?.Cancel(); - - if (!IsLoaded) - return; - - Users = null; - clearPanels(); - getUsersRequest?.Cancel(); - - if (API?.IsLoggedIn != true) - return; - - switch (Header.Tabs.Current.Value) - { - case SocialTab.Friends: - var friendRequest = new GetFriendsRequest(); // TODO filter arguments? - friendRequest.Success += users => Users = users.ToArray(); - API.Queue(getUsersRequest = friendRequest); - break; - - default: - var userRequest = new GetUsersRequest(); // TODO filter arguments! - userRequest.Success += res => Users = res.Users.Select(r => r.User).ToArray(); - API.Queue(getUsersRequest = userRequest); - break; - } - } - - private void recreatePanels() - { - clearPanels(); - - if (Users == null) - { - loading.Hide(); - return; - } - - IEnumerable sortedUsers = Users; - - switch (Filter.Tabs.Current.Value) - { - case SocialSortCriteria.Location: - sortedUsers = sortedUsers.OrderBy(u => u.Country.FullName); - break; - - case SocialSortCriteria.Name: - sortedUsers = sortedUsers.OrderBy(u => u.Username); - break; - } - - if (Filter.DisplayStyleControl.Dropdown.Current.Value == SortDirection.Descending) - sortedUsers = sortedUsers.Reverse(); - - var newPanels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10f), - Margin = new MarginPadding { Top = 10 }, - ChildrenEnumerable = sortedUsers.Select(u => - { - SocialPanel panel; - - switch (Filter.DisplayStyleControl.DisplayStyle.Value) - { - case PanelDisplayStyle.Grid: - panel = new SocialGridPanel(u) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }; - break; - - default: - panel = new SocialListPanel(u); - break; - } - - panel.Status.BindTo(u.Status); - panel.Activity.BindTo(u.Activity); - return panel; - }) - }; - - LoadComponentAsync(newPanels, f => - { - if (panels != null) - ScrollFlow.Remove(panels); - - loading.Hide(); - ScrollFlow.Add(panels = newPanels); - }, (loadCancellation = new CancellationTokenSource()).Token); - } - - private void onFilterUpdate() - { - if (Filter.Tabs.Current.Value == SocialSortCriteria.Rank) - { - queueUpdate(); - return; - } - - recreatePanels(); - } - - private void clearPanels() - { - loading.Show(); - - loadCancellation?.Cancel(); - - if (panels != null) - { - panels.Expire(); - panels = null; - } - } - - public override void APIStateChanged(IAPIProvider api, APIState state) - { - switch (state) - { - case APIState.Online: - queueUpdate(); - break; - - default: - Users = null; - clearPanels(); - break; - } - } - } - - public enum SortDirection - { - Ascending, - Descending - } -} diff --git a/osu.Game/Overlays/SortDirection.cs b/osu.Game/Overlays/SortDirection.cs new file mode 100644 index 0000000000..3af9614972 --- /dev/null +++ b/osu.Game/Overlays/SortDirection.cs @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Overlays +{ + public enum SortDirection + { + Ascending, + Descending + } +} diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs index f3521b66c8..e8e000f441 100644 --- a/osu.Game/Overlays/TabControlOverlayHeader.cs +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -1,53 +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 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.Graphics.UserInterface; using osu.Game.Graphics; -using osuTK; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays { - public abstract class TabControlOverlayHeader : OverlayHeader + /// + /// An overlay header which contains a . + /// + /// The type of item to be represented by tabs. + public abstract class TabControlOverlayHeader : OverlayHeader, IHasCurrentValue { - protected OverlayHeaderTabControl TabControl; + protected OsuTabControl TabControl; - protected override TabControl CreateTabControl() => TabControl = new OverlayHeaderTabControl(); + private readonly Box controlBackground; + private readonly BindableWithCurrent current = new BindableWithCurrent(); - public class OverlayHeaderTabControl : OverlayTabControl + public Bindable Current { + get => current.Current; + set => current.Current = value; + } + + protected TabControlOverlayHeader() + { + HeaderInfo.Add(new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + controlBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + TabControl = CreateTabControl().With(control => + { + control.Margin = new MarginPadding { Left = CONTENT_X_MARGIN }; + control.Current = Current; + }) + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + controlBackground.Colour = colourProvider.Dark4; + } + + [NotNull] + protected virtual OsuTabControl CreateTabControl() => new OverlayHeaderTabControl(); + + public class OverlayHeaderTabControl : OverlayTabControl + { + private const float bar_height = 1; + public OverlayHeaderTabControl() { - BarHeight = 1; RelativeSizeAxes = Axes.None; AutoSizeAxes = Axes.X; Anchor = Anchor.BottomLeft; Origin = Anchor.BottomLeft; - Height = 35; + Height = 47; + BarHeight = bar_height; } - protected override TabItem CreateTabItem(string value) => new OverlayHeaderTabItem(value) - { - AccentColour = AccentColour, - }; + protected override TabItem CreateTabItem(T value) => new OverlayHeaderTabItem(value); protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), }; private class OverlayHeaderTabItem : OverlayTabItem { - public OverlayHeaderTabItem(string value) + public OverlayHeaderTabItem(T value) : base(value) { - Text.Text = value; + Text.Text = value.ToString().ToLower(); Text.Font = OsuFont.GetFont(size: 14); - Bar.ExpandedSize = 5; + Text.Margin = new MarginPadding { Vertical = 16.5f }; // 15px padding + 1.5px line-height difference compensation + Bar.Margin = new MarginPadding { Bottom = bar_height }; } } } diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index b044bc4de0..de08b79f57 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -28,11 +28,11 @@ namespace osu.Game.Overlays.Toolbar private const double transition_time = 500; - private const float alpha_hovering = 0.8f; - private const float alpha_normal = 0.6f; - private readonly Bindable overlayActivationMode = new Bindable(OverlayActivation.All); + // Toolbar components like RulesetSelector should receive keyboard input events even when the toolbar is hidden. + public override bool PropagateNonPositionalInputSubTree => true; + public Toolbar() { RelativeSizeAxes = Axes.X; @@ -70,7 +70,8 @@ namespace osu.Game.Overlays.Toolbar Children = new Drawable[] { new ToolbarChangelogButton(), - new ToolbarDirectButton(), + new ToolbarRankingsButton(), + new ToolbarBeatmapListingButton(), new ToolbarChatButton(), new ToolbarSocialButton(), new ToolbarMusicButton(), @@ -99,7 +100,6 @@ namespace osu.Game.Overlays.Toolbar public class ToolbarBackground : Container { - private readonly Box solidBackground; private readonly Box gradientBackground; public ToolbarBackground() @@ -107,11 +107,10 @@ namespace osu.Game.Overlays.Toolbar RelativeSizeAxes = Axes.Both; Children = new Drawable[] { - solidBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.1f), - Alpha = alpha_normal, }, gradientBackground = new Box { @@ -127,14 +126,12 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnHover(HoverEvent e) { - solidBackground.FadeTo(alpha_hovering, transition_time, Easing.OutQuint); gradientBackground.FadeIn(transition_time, Easing.OutQuint); return true; } protected override void OnHoverLost(HoverLostEvent e) { - solidBackground.FadeTo(alpha_normal, transition_time, Easing.OutQuint); gradientBackground.FadeOut(transition_time, Easing.OutQuint); } } @@ -142,15 +139,15 @@ namespace osu.Game.Overlays.Toolbar protected override void PopIn() { this.MoveToY(0, transition_time, Easing.OutQuint); - this.FadeIn(transition_time / 2, Easing.OutQuint); + this.FadeIn(transition_time / 4, Easing.OutQuint); } protected override void PopOut() { - userButton?.StateContainer.Hide(); + userButton.StateContainer?.Hide(); this.MoveToY(-DrawSize.Y, transition_time, Easing.OutQuint); - this.FadeOut(transition_time); + this.FadeOut(transition_time, Easing.InQuint); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs similarity index 63% rename from osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs rename to osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs index 1d07a3ae70..eecb368ee9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs @@ -6,17 +6,17 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarDirectButton : ToolbarOverlayToggleButton + public class ToolbarBeatmapListingButton : ToolbarOverlayToggleButton { - public ToolbarDirectButton() + public ToolbarBeatmapListingButton() { SetIcon(OsuIcon.ChevronDownCircle); } [BackgroundDependencyLoader(true)] - private void load(DirectOverlay direct) + private void load(BeatmapListingOverlay beatmapListing) { - StateContainer = direct; + StateContainer = beatmapListing; } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index d6b810366d..86a3f5d8aa 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -62,6 +62,7 @@ namespace osu.Game.Overlays.Toolbar protected ConstrainedIconContainer IconContainer; protected SpriteText DrawableText; protected Box HoverBackground; + private readonly Box flashBackground; private readonly FillFlowContainer tooltipContainer; private readonly SpriteText tooltip1; private readonly SpriteText tooltip2; @@ -82,6 +83,13 @@ namespace osu.Game.Overlays.Toolbar Blending = BlendingParameters.Additive, Alpha = 0, }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, Flow = new FillFlowContainer { Direction = FillDirection.Horizontal, @@ -110,7 +118,7 @@ namespace osu.Game.Overlays.Toolbar tooltipContainer = new FillFlowContainer { Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.Both, //stops us being considered in parent's autosize + RelativeSizeAxes = Axes.Both, // stops us being considered in parent's autosize Anchor = TooltipAnchor.HasFlag(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, Origin = TooltipAnchor, Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), @@ -139,7 +147,7 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnClick(ClickEvent e) { - HoverBackground.FlashColour(Color4.White.Opacity(100), 500, Easing.OutQuint); + flashBackground.FadeOutFromOne(800, Easing.OutQuint); tooltipContainer.FadeOut(100); return base.OnClick(e); } diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index 6f5e703a66..e642f0c453 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarHomeButton : ToolbarButton + public class ToolbarHomeButton : ToolbarButton, IKeyBindingHandler { public ToolbarHomeButton() { @@ -13,5 +15,20 @@ namespace osu.Game.Overlays.Toolbar TooltipMain = "Home"; TooltipSub = "Return to the main menu"; } + + public bool OnPressed(GlobalAction action) + { + if (action == GlobalAction.Home) + { + Click(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs new file mode 100644 index 0000000000..cbd097696d --- /dev/null +++ b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.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; + +namespace osu.Game.Overlays.Toolbar +{ + public class ToolbarRankingsButton : ToolbarOverlayToggleButton + { + public ToolbarRankingsButton() + { + SetIcon(FontAwesome.Regular.ChartBar); + } + + [BackgroundDependencyLoader(true)] + private void load(RankingsOverlay rankings) + { + StateContainer = rankings; + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 8f2dbce6f7..422bf00c6d 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -99,7 +99,7 @@ namespace osu.Game.Overlays.Toolbar { int requested = e.Key - Key.Number1; - RulesetInfo found = Rulesets.AvailableRulesets.Skip(requested).FirstOrDefault(); + RulesetInfo found = Rulesets.AvailableRulesets.ElementAtOrDefault(requested); if (found != null) Current.Value = found; return true; diff --git a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs index 5e353d3319..f6646eb81d 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs @@ -14,9 +14,9 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader(true)] - private void load(SocialOverlay chat) + private void load(DashboardOverlay dashboard) { - StateContainer = chat; + StateContainer = dashboard; } } } diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index a34fc619a8..b4c8a2d3ca 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile; @@ -25,10 +24,15 @@ namespace osu.Game.Overlays private GetUserRequest userReq; protected ProfileHeader Header; private ProfileSectionsContainer sectionsContainer; - private ProfileTabControl tabs; + private ProfileSectionTabControl tabs; public const float CONTENT_X_MARGIN = 70; + public UserProfileOverlay() + : base(OverlayColourScheme.Pink) + { + } + public void ShowUser(long userId) => ShowUser(new User { Id = userId }); public void ShowUser(User user, bool fetchOnline = true) @@ -58,18 +62,17 @@ namespace osu.Game.Overlays } : Array.Empty(); - tabs = new ProfileTabControl + tabs = new ProfileSectionTabControl { RelativeSizeAxes = Axes.X, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Height = 30 }; Add(new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.1f) + Colour = ColourProvider.Background6 }); Add(sectionsContainer = new ProfileSectionsContainer @@ -78,7 +81,8 @@ namespace osu.Game.Overlays FixedHeader = tabs, HeaderBackground = new Box { - Colour = OsuColour.Gray(34), + // this is only visible as the ProfileTabControl background + Colour = ColourProvider.Background5, RelativeSizeAxes = Axes.Both }, }); @@ -144,33 +148,42 @@ namespace osu.Game.Overlays } } - private class ProfileTabControl : OverlayTabControl + private class ProfileSectionTabControl : OverlayTabControl { - public ProfileTabControl() + 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; } - protected override TabItem CreateTabItem(ProfileSection value) => new ProfileTabItem(value) + protected override TabItem CreateTabItem(ProfileSection value) => new ProfileSectionTabItem(value) { - AccentColour = AccentColour + AccentColour = AccentColour, }; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - AccentColour = colours.Seafoam; + AccentColour = colourProvider.Highlight1; } - private class ProfileTabItem : OverlayTabItem + private class ProfileSectionTabItem : OverlayTabItem { - public ProfileTabItem(ProfileSection value) + 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 }; } } } @@ -182,6 +195,8 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both; } + protected override OsuScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); + protected override FlowContainer CreateScrollContentContainer() => new FillFlowContainer { Direction = FillDirection.Vertical, diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index 9cd3aac2cb..3478f18a40 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -14,8 +14,14 @@ namespace osu.Game.Overlays.Volume public Func ActionRequested; public Func ScrollActionRequested; - public bool OnPressed(GlobalAction action) => ActionRequested?.Invoke(action) ?? false; - public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; - public bool OnReleased(GlobalAction action) => false; + public bool OnPressed(GlobalAction action) => + ActionRequested?.Invoke(action) ?? false; + + public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => + ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index da696e0fdd..07accf8820 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -11,16 +11,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class VolumeMeter : Container + public class VolumeMeter : Container, IKeyBindingHandler { private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; @@ -260,5 +262,28 @@ namespace osu.Game.Overlays.Volume { this.ScaleTo(1f, transition_length, Easing.OutExpo); } + + public bool OnPressed(GlobalAction action) + { + if (!IsHovered) + return false; + + switch (action) + { + case GlobalAction.SelectPrevious: + adjust(1, false); + return true; + + case GlobalAction.SelectNext: + adjust(-1, false); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index b484921cce..eb639431ae 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -46,6 +46,13 @@ namespace osu.Game.Overlays Width = 300, Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.75f), Color4.Black.Opacity(0)) }, + muteButton = new MuteButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding(10), + Current = { BindTarget = IsMuted } + }, new FillFlowContainer { Direction = FillDirection.Vertical, @@ -56,19 +63,11 @@ namespace osu.Game.Overlays Margin = new MarginPadding { Left = offset }, Children = new Drawable[] { - volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker) - { - Margin = new MarginPadding { Top = 100 + MuteButton.HEIGHT } //to counter the mute button and re-center the volume meters - }, + volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), volumeMeterMaster = new VolumeMeter("MASTER", 150, colours.PinkDarker), volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), - muteButton = new MuteButton - { - Margin = new MarginPadding { Top = 100 }, - Current = { BindTarget = IsMuted } - } } - }, + } }); volumeMeterMaster.Bindable.BindTo(audio.Volume); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index bfaa7e8872..c25fb03fd0 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -3,15 +3,14 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Framework.Logging; -using osu.Framework.Threading; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Configuration; @@ -20,11 +19,13 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using Key = osuTK.Input.Key; namespace osu.Game.Rulesets.Edit { @@ -37,49 +38,39 @@ namespace osu.Game.Rulesets.Edit protected readonly Ruleset Ruleset; [Resolved] - protected IFrameBasedClock EditorClock { get; private set; } + protected EditorClock EditorClock { get; private set; } [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } [Resolved] - private IAdjustableClock adjustableClock { get; set; } + protected IBeatSnapProvider BeatSnapProvider { get; private set; } - [Resolved] - private BindableBeatDivisor beatDivisor { get; set; } - - private IBeatmapProcessor beatmapProcessor; + protected ComposeBlueprintContainer BlueprintContainer { get; private set; } private DrawableEditRulesetWrapper drawableRulesetWrapper; - private BlueprintContainer blueprintContainer; - private Container distanceSnapGridContainer; - private DistanceSnapGrid distanceSnapGrid; - private readonly List layerContainers = new List(); + + protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; private InputManager inputManager; + private RadioButtonCollection toolboxCollection; + protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; - RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(IFrameBasedClock framedClock) + private void load() { - beatmapProcessor = Ruleset.CreateBeatmapProcessor(EditorBeatmap.PlayableBeatmap); - - EditorBeatmap.HitObjectAdded += addHitObject; - EditorBeatmap.HitObjectRemoved += removeHitObject; - EditorBeatmap.StartTimeChanged += UpdateHitObject; - Config = Dependencies.Get().GetConfigFor(Ruleset); try { drawableRulesetWrapper = new DrawableEditRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap)) { - Clock = framedClock, + Clock = EditorClock, ProcessCustomClock = false }; } @@ -89,18 +80,6 @@ namespace osu.Game.Rulesets.Edit return; } - var layerBelowRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChildren(new Drawable[] - { - distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both }, - new EditorPlayfieldBorder { RelativeSizeAxes = Axes.Both } - }); - - var layerAboveRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChild(blueprintContainer = new BlueprintContainer()); - - layerContainers.Add(layerBelowRuleset); - layerContainers.Add(layerAboveRuleset); - - RadioButtonCollection toolboxCollection; InternalChild = new GridContainer { RelativeSizeAxes = Axes.Both, @@ -122,11 +101,19 @@ namespace osu.Game.Rulesets.Edit { Name = "Content", RelativeSizeAxes = Axes.Both, + Masking = true, Children = new Drawable[] { - layerBelowRuleset, + // layers below playfield + drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChildren(new Drawable[] + { + LayerBelowRuleset, + new EditorPlayfieldBorder { RelativeSizeAxes = Axes.Both } + }), drawableRulesetWrapper, - layerAboveRuleset + // layers above playfield + drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer() + .WithChild(BlueprintContainer = CreateBlueprintContainer(HitObjects)) } } }, @@ -137,14 +124,14 @@ namespace osu.Game.Rulesets.Edit } }; - toolboxCollection.Items = - CompositionTools.Select(t => new RadioButton(t.Name, () => selectTool(t))) - .Prepend(new RadioButton("Select", () => selectTool(null))) - .ToList(); + toolboxCollection.Items = CompositionTools + .Prepend(new SelectTool()) + .Select(t => new RadioButton(t.Name, () => toolSelected(t))) + .ToList(); - toolboxCollection.Items[0].Select(); + setSelectTool(); - blueprintContainer.SelectionChanged += selectionChanged; + EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } protected override void LoadComplete() @@ -154,167 +141,186 @@ namespace osu.Game.Rulesets.Edit inputManager = GetContainingInputManager(); } - private double lastGridUpdateTime; - - protected override void Update() - { - base.Update(); - - if (EditorClock.CurrentTime != lastGridUpdateTime && blueprintContainer.CurrentTool != null) - showGridFor(Enumerable.Empty()); - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - layerContainers.ForEach(l => - { - l.Anchor = drawableRulesetWrapper.Playfield.Anchor; - l.Origin = drawableRulesetWrapper.Playfield.Origin; - l.Position = drawableRulesetWrapper.Playfield.Position; - l.Size = drawableRulesetWrapper.Playfield.Size; - }); - } - - private void selectionChanged(IEnumerable selectedHitObjects) - { - var hitObjects = selectedHitObjects.ToArray(); - - if (!hitObjects.Any()) - distanceSnapGridContainer.Hide(); - else - showGridFor(hitObjects); - } - - private void selectTool(HitObjectCompositionTool tool) - { - blueprintContainer.CurrentTool = tool; - - if (tool == null) - distanceSnapGridContainer.Hide(); - else - showGridFor(Enumerable.Empty()); - } - - private void showGridFor(IEnumerable selectedHitObjects) - { - distanceSnapGridContainer.Clear(); - distanceSnapGrid = CreateDistanceSnapGrid(selectedHitObjects); - - if (distanceSnapGrid != null) - { - distanceSnapGridContainer.Child = distanceSnapGrid; - distanceSnapGridContainer.Show(); - } - - lastGridUpdateTime = EditorClock.CurrentTime; - } - - private ScheduledDelegate scheduledUpdate; - - public override void UpdateHitObject(HitObject hitObject) - { - scheduledUpdate?.Cancel(); - scheduledUpdate = Schedule(() => - { - beatmapProcessor?.PreProcess(); - hitObject?.ApplyDefaults(EditorBeatmap.ControlPointInfo, EditorBeatmap.BeatmapInfo.BaseDifficulty); - beatmapProcessor?.PostProcess(); - }); - } - - private void addHitObject(HitObject hitObject) => UpdateHitObject(hitObject); - - private void removeHitObject(HitObject hitObject) => UpdateHitObject(null); + public override Playfield Playfield => drawableRulesetWrapper.Playfield; public override IEnumerable HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; + public override bool CursorInPlacementArea => drawableRulesetWrapper.Playfield.ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position); + /// + /// Defines all available composition tools, listed on the left side of the editor screen as button controls. + /// This should usually define one tool for each type used in the target ruleset. + /// + /// + /// A "select" tool is automatically added as the first tool. + /// protected abstract IReadOnlyList CompositionTools { get; } - protected abstract DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null); + /// + /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. + /// + /// A live collection of all s in the editor beatmap. + protected virtual ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new ComposeBlueprintContainer(hitObjects); + + /// + /// Construct a drawable ruleset for the provided ruleset. + /// + /// + /// Can be overridden to add editor-specific logical changes to a 's standard . + /// For example, hit animations or judgement logic may be changed to give a better editor user experience. + /// + /// The ruleset used to construct its drawable counterpart. + /// The loaded beatmap. + /// The mods to be applied. + /// An editor-relevant . + protected virtual DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + => (DrawableRuleset)ruleset.CreateDrawableRulesetWith(beatmap, mods); + + #region Tool selection logic + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key >= Key.Number1 && e.Key <= Key.Number9) + { + var item = toolboxCollection.Items.ElementAtOrDefault(e.Key - Key.Number1); + + if (item != null) + { + item.Select(); + return true; + } + } + + return base.OnKeyDown(e); + } + + private void selectionChanged(object sender, NotifyCollectionChangedEventArgs changedArgs) + { + if (EditorBeatmap.SelectedHitObjects.Any()) + { + // ensure in selection mode if a selection is made. + setSelectTool(); + } + } + + private void setSelectTool() => toolboxCollection.Items.First().Select(); + + private void toolSelected(HitObjectCompositionTool tool) + { + BlueprintContainer.CurrentTool = tool; + + if (!(tool is SelectTool)) + EditorBeatmap.SelectedHitObjects.Clear(); + } + + #endregion + + #region IPlacementHandler public void BeginPlacement(HitObject hitObject) { - if (distanceSnapGrid != null) - hitObject.StartTime = GetSnappedPosition(distanceSnapGrid.ToLocalSpace(inputManager.CurrentState.Mouse.Position), hitObject.StartTime).time; + EditorBeatmap.PlacementObject.Value = hitObject; } - public void EndPlacement(HitObject hitObject) + public void EndPlacement(HitObject hitObject, bool commit) { - EditorBeatmap.Add(hitObject); + EditorBeatmap.PlacementObject.Value = null; - adjustableClock.Seek(hitObject.StartTime); + if (commit) + { + EditorBeatmap.Add(hitObject); - showGridFor(Enumerable.Empty()); + if (EditorClock.CurrentTime < hitObject.StartTime) + EditorClock.SeekTo(hitObject.StartTime); + } } public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject); - public override (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) => distanceSnapGrid?.GetSnappedPosition(position) ?? (position, time); + #endregion + + #region IPositionSnapProvider + + /// + /// Retrieve the relevant at a specified screen-space position. + /// In cases where a ruleset doesn't require custom logic (due to nested playfields, for example) + /// this will return the ruleset's main playfield. + /// + /// The screen-space position to query. + /// The most relevant . + protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); + double? targetTime = null; + + if (playfield is ScrollingPlayfield scrollingPlayfield) + { + targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); + + // apply beat snapping + targetTime = BeatSnapProvider.SnapTime(targetTime.Value); + + // convert back to screen space + screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + } + + return new SnapResult(screenSpacePosition, targetTime, playfield); + } public override float GetBeatSnapDistanceAt(double referenceTime) { DifficultyControlPoint difficultyPoint = EditorBeatmap.ControlPointInfo.DifficultyPointAt(referenceTime); - return (float)(100 * EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / beatDivisor.Value); + return (float)(100 * EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / BeatSnapProvider.BeatDivisor); } public override float DurationToDistance(double referenceTime, double duration) { - double beatLength = EditorBeatmap.ControlPointInfo.TimingPointAt(referenceTime).BeatLength / beatDivisor.Value; + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceTime)); } public override double DistanceToDuration(double referenceTime, float distance) { - double beatLength = EditorBeatmap.ControlPointInfo.TimingPointAt(referenceTime).BeatLength / beatDivisor.Value; + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); return distance / GetBeatSnapDistanceAt(referenceTime) * beatLength; } public override double GetSnappedDurationFromDistance(double referenceTime, float distance) - => beatSnap(referenceTime, DistanceToDuration(referenceTime, distance)); + => BeatSnapProvider.SnapTime(referenceTime + DistanceToDuration(referenceTime, distance), referenceTime) - referenceTime; public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) - => DurationToDistance(referenceTime, beatSnap(referenceTime, DistanceToDuration(referenceTime, distance))); - - /// - /// Snaps a duration to the closest beat of a timing point applicable at the reference time. - /// - /// The time of the timing point which resides in. - /// The duration to snap. - /// A value that represents snapped to the closest beat of the timing point. - private double beatSnap(double referenceTime, double duration) { - double beatLength = EditorBeatmap.ControlPointInfo.TimingPointAt(referenceTime).BeatLength / beatDivisor.Value; + var snappedEndTime = BeatSnapProvider.SnapTime(referenceTime + DistanceToDuration(referenceTime, distance), referenceTime); - // A 1ms offset prevents rounding errors due to minute variations in duration - return (int)((duration + 1) / beatLength) * beatLength; + return DurationToDistance(referenceTime, snappedEndTime - referenceTime); } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (EditorBeatmap != null) - { - EditorBeatmap.HitObjectAdded -= addHitObject; - EditorBeatmap.HitObjectRemoved -= removeHitObject; - } - } + #endregion } + /// + /// A non-generic definition of a HitObject composer class. + /// Generally used to access certain methods without requiring a generic type for . + /// [Cached(typeof(HitObjectComposer))] - [Cached(typeof(IDistanceSnapProvider))] - public abstract class HitObjectComposer : CompositeDrawable, IDistanceSnapProvider + [Cached(typeof(IPositionSnapProvider))] + public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider { - internal HitObjectComposer() + protected HitObjectComposer() { RelativeSizeAxes = Axes.Both; } /// - /// All the s. + /// The target ruleset's playfield. + /// + public abstract Playfield Playfield { get; } + + /// + /// All s in currently loaded beatmap. /// public abstract IEnumerable HitObjects { get; } @@ -323,32 +329,9 @@ namespace osu.Game.Rulesets.Edit /// public abstract bool CursorInPlacementArea { get; } - /// - /// Creates a for a specific . - /// - /// The to create the overlay for. - public virtual SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; + #region IPositionSnapProvider - /// - /// Creates a which outlines s and handles movement of selections. - /// - public virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); - - /// - /// Creates the applicable for a selection. - /// - /// The selection. - /// The for . - [CanBeNull] - protected virtual DistanceSnapGrid CreateDistanceSnapGrid([NotNull] IEnumerable selectedHitObjects) => null; - - /// - /// Updates a , invoking and re-processing the beatmap. - /// - /// The to update. - public abstract void UpdateHitObject([CanBeNull] HitObject hitObject); - - public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time); + public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); public abstract float GetBeatSnapDistanceAt(double referenceTime); @@ -359,5 +342,7 @@ namespace osu.Game.Rulesets.Edit public abstract double GetSnappedDurationFromDistance(double referenceTime, float distance); public abstract float GetSnappedDistanceFromDistance(double referenceTime, float distance); + + #endregion } } diff --git a/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs b/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs new file mode 100644 index 0000000000..616f854cd7 --- /dev/null +++ b/osu.Game/Rulesets/Edit/IBeatSnapProvider.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. + +namespace osu.Game.Rulesets.Edit +{ + public interface IBeatSnapProvider + { + /// + /// Snaps a duration to the closest beat of a timing point applicable at the reference time. + /// + /// The time to snap. + /// An optional reference point to use for timing point lookup. + /// A value that represents snapped to the closest beat of the timing point. + double SnapTime(double time, double? referenceTime = null); + + /// + /// Get the most appropriate beat length at a given time. + /// + /// A reference time used for lookup. + /// The most appropriate beat length. + double GetBeatLengthAtTime(double referenceTime); + + /// + /// Returns the current beat divisor. + /// + int BeatDivisor { get; } + } +} diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs similarity index 87% rename from osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs rename to osu.Game/Rulesets/Edit/IPositionSnapProvider.cs index c6e61f68da..c854c06031 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs @@ -5,9 +5,14 @@ using osuTK; namespace osu.Game.Rulesets.Edit { - public interface IDistanceSnapProvider + public interface IPositionSnapProvider { - (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time); + /// + /// Given a position, find a valid time snap. + /// + /// The screen-space position to be snapped. + /// The time and position post-snapping. + SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); /// /// Retrieves the distance between two points within a timing point that are one beat length apart. diff --git a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs new file mode 100644 index 0000000000..75200e3027 --- /dev/null +++ b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.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.Primitives; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + public abstract class OverlaySelectionBlueprint : SelectionBlueprint + { + /// + /// The which this applies to. + /// + public readonly DrawableHitObject DrawableObject; + + /// + /// Whether the blueprint should be shown even when the is not alive. + /// + protected virtual bool AlwaysShowWhenSelected => false; + + protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); + + protected OverlaySelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject.HitObject) + { + DrawableObject = drawableObject; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); + + public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; + + public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; + + public override Vector2 GetInstantDelta(Vector2 screenSpacePosition) => DrawableObject.Parent.ToLocalSpace(screenSpacePosition) - DrawableObject.Position; + } +} diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 07283d2245..02d5955ae6 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.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. -using System; -using osu.Framework; +using System.Threading; 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.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; @@ -20,27 +19,25 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint which governs the creation of a new to actualisation. /// - public abstract class PlacementBlueprint : CompositeDrawable, IStateful + public abstract class PlacementBlueprint : CompositeDrawable { /// - /// Invoked when has changed. + /// Whether the is currently mid-placement, but has not necessarily finished being placed. /// - public event Action StateChanged; - - /// - /// Whether the is currently being placed, but has not necessarily finished being placed. - /// - public bool PlacementBegun { get; private set; } + public bool PlacementActive { get; private set; } /// /// The that is being placed. /// protected readonly HitObject HitObject; - protected IClock EditorClock { get; private set; } + [Resolved(canBeNull: true)] + protected EditorClock EditorClock { get; private set; } private readonly IBindable beatmap = new Bindable(); + private Bindable startTimeBindable; + [Resolved] private IPlacementHandler placementHandler { get; set; } @@ -53,71 +50,52 @@ namespace osu.Game.Rulesets.Edit // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle // on the same frame it is made visible via a PlacementState change. AlwaysPresent = true; - - Alpha = 0; } [BackgroundDependencyLoader] - private void load(IBindable beatmap, IAdjustableClock clock) + private void load(IBindable beatmap) { this.beatmap.BindTo(beatmap); - EditorClock = clock; - - ApplyDefaultsToHitObject(); - } - - private PlacementState state; - - public PlacementState State - { - get => state; - set - { - if (state == value) - return; - - state = value; - - if (state == PlacementState.Shown) - Show(); - else - Hide(); - - StateChanged?.Invoke(value); - } + startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true); } /// /// Signals that the placement of has started. /// - /// The start time of at the placement point. If null, the current clock time is used. - protected void BeginPlacement(double? startTime = null) + /// Whether this call is committing a value for HitObject.StartTime and continuing with further adjustments. + protected void BeginPlacement(bool commitStart = false) { - HitObject.StartTime = startTime ?? EditorClock.CurrentTime; placementHandler.BeginPlacement(HitObject); - PlacementBegun = true; + PlacementActive |= commitStart; } /// /// Signals that the placement of has finished. - /// This will destroy this , and add the to the . + /// This will destroy this , and add the HitObject.StartTime to the . /// - protected void EndPlacement() + /// Whether the object should be committed. + public void EndPlacement(bool commit) { - if (!PlacementBegun) + if (!PlacementActive) BeginPlacement(); - placementHandler.EndPlacement(HitObject); + placementHandler.EndPlacement(HitObject, commit); + PlacementActive = false; } /// /// Updates the position of this to a new screen-space position. /// - /// The screen-space position. - public abstract void UpdatePosition(Vector2 screenSpacePosition); + /// The snap result information. + public virtual void UpdatePosition(SnapResult result) + { + if (!PlacementActive) + HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; + } /// - /// Invokes , + /// Invokes , /// refreshing and parameters for the . /// protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty); @@ -133,6 +111,9 @@ namespace osu.Game.Rulesets.Edit case ScrollEvent _: return false; + case DoubleClickEvent _: + return false; + case MouseButtonEvent _: return true; @@ -141,10 +122,4 @@ namespace osu.Game.Rulesets.Edit } } } - - public enum PlacementState - { - Hidden, - Shown, - } } diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index bf99f83e0b..71256093d5 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.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 System; @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Edit /// public abstract class SelectionBlueprint : CompositeDrawable, IStateful { + public readonly HitObject HitObject; + /// /// Invoked when this has been selected. /// @@ -30,26 +32,24 @@ namespace osu.Game.Rulesets.Edit /// public event Action Deselected; - /// - /// The which this applies to. - /// - public readonly DrawableHitObject DrawableObject; - - protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || State == SelectionState.Selected; public override bool HandlePositionalInput => ShouldBeAlive; public override bool RemoveWhenNotAlive => false; [Resolved(CanBeNull = true)] private HitObjectComposer composer { get; set; } - protected SelectionBlueprint(DrawableHitObject drawableObject) + protected SelectionBlueprint(HitObject hitObject) { - DrawableObject = drawableObject; + HitObject = hitObject; RelativeSizeAxes = Axes.Both; - AlwaysPresent = true; - Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); } private SelectionState state; @@ -66,58 +66,63 @@ namespace osu.Game.Rulesets.Edit state = value; - switch (state) - { - case SelectionState.Selected: - Show(); - Selected?.Invoke(this); - break; - - case SelectionState.NotSelected: - Hide(); - Deselected?.Invoke(this); - break; - } + if (IsLoaded) + updateState(); StateChanged?.Invoke(state); } } + private void updateState() + { + switch (state) + { + case SelectionState.Selected: + OnSelected(); + Selected?.Invoke(this); + break; + + case SelectionState.NotSelected: + OnDeselected(); + Deselected?.Invoke(this); + break; + } + } + + protected virtual void OnDeselected() => Hide(); + + protected virtual void OnSelected() => Show(); + // When not selected, input is only required for the blueprint itself to receive IsHovering protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected; /// - /// Selects this , causing it to become visible. + /// Selects this , causing it to become visible. /// public void Select() => State = SelectionState.Selected; /// - /// Deselects this , causing it to become invisible. + /// Deselects this , causing it to become invisible. /// public void Deselect() => State = SelectionState.NotSelected; public bool IsSelected => State == SelectionState.Selected; /// - /// Updates the , invoking and re-processing the beatmap. - /// - protected void UpdateHitObject() => composer?.UpdateHitObject(DrawableObject.HitObject); - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); - - /// - /// The s to be displayed in the context menu for this . + /// The s to be displayed in the context menu for this . /// public virtual MenuItem[] ContextMenuItems => Array.Empty(); /// - /// The screen-space point that causes this to be selected. + /// The screen-space point that causes this to be selected. /// - public virtual Vector2 SelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; + public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; /// - /// The screen-space quad that outlines this for selections. + /// The screen-space quad that outlines this for selections. /// - public virtual Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; + public virtual Quad SelectionQuad => ScreenSpaceDrawQuad; + + public virtual Vector2 GetInstantDelta(Vector2 screenSpacePosition) => Parent.ToLocalSpace(screenSpacePosition) - Position; } } diff --git a/osu.Game/Rulesets/Edit/SnapResult.cs b/osu.Game/Rulesets/Edit/SnapResult.cs new file mode 100644 index 0000000000..31dd2b9496 --- /dev/null +++ b/osu.Game/Rulesets/Edit/SnapResult.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.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + /// + /// The result of a position/time snapping process. + /// + public class SnapResult + { + /// + /// The screen space position, potentially altered for snapping. + /// + public Vector2 ScreenSpacePosition; + + /// + /// The resultant time for snapping, if a value could be attained. + /// + public double? Time; + + public readonly Playfield Playfield; + + public SnapResult(Vector2 screenSpacePosition, double? time, Playfield playfield = null) + { + ScreenSpacePosition = screenSpacePosition; + Time = time; + Playfield = playfield; + } + } +} diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs index 825c63e6ee..0631031302 100644 --- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs @@ -13,5 +13,7 @@ namespace osu.Game.Rulesets.Edit.Tools } public abstract PlacementBlueprint CreatePlacementBlueprint(); + + public override string ToString() => Name; } } diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs new file mode 100644 index 0000000000..b96eeb0790 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.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. + +namespace osu.Game.Rulesets.Edit.Tools +{ + public class SelectTool : HitObjectCompositionTool + { + public SelectTool() + : base("Select") + { + } + + public override PlacementBlueprint CreatePlacementBlueprint() => null; + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 8289ca175d..052aaa3c65 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.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. +using System.Diagnostics; +using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; -using osuTK.Graphics; namespace osu.Game.Rulesets.Judgements { /// /// A drawable object which visualises the hit result of a . /// - public class DrawableJudgement : CompositeDrawable + public class DrawableJudgement : PoolableDrawable { private const float judgement_size = 128; - private OsuColour colours; + [Resolved] + private OsuColour colours { get; set; } - protected readonly JudgementResult Result; + public JudgementResult Result { get; private set; } + public DrawableHitObject JudgedObject { get; private set; } - public readonly DrawableHitObject JudgedObject; + protected Container JudgementBody { get; private set; } + protected SpriteText JudgementText { get; private set; } - protected Container JudgementBody; - protected SpriteText JudgementText; + private SkinnableDrawable bodyDrawable; /// /// Duration of initial fade in. @@ -48,31 +52,21 @@ namespace osu.Game.Rulesets.Judgements /// The judgement to visualise. /// The object which was judged. public DrawableJudgement(JudgementResult result, DrawableHitObject judgedObject) + : this() { - Result = result; - JudgedObject = judgedObject; + Apply(result, judgedObject); + } + public DrawableJudgement() + { Size = new Vector2(judgement_size); + Origin = Anchor.Centre; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - this.colours = colours; - - InternalChild = JudgementBody = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = new SkinnableDrawable(new GameplaySkinComponent(Result.Type), _ => JudgementText = new OsuSpriteText - { - Text = Result.Type.GetDescription().ToUpperInvariant(), - Font = OsuFont.Numeric.With(size: 20), - Colour = judgementColour(Result.Type), - Scale = new Vector2(0.85f, 1), - }, confineMode: ConfineMode.NoScaling) - }; + prepareDrawables(); } protected virtual void ApplyHitAnimations() @@ -83,11 +77,26 @@ namespace osu.Game.Rulesets.Judgements this.Delay(FadeOutDelay).FadeOut(400); } - protected override void LoadComplete() + public virtual void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) { - base.LoadComplete(); + Result = result; + JudgedObject = judgedObject; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Result != null); + + prepareDrawables(); + + bodyDrawable.ResetAnimation(); this.FadeInFromZero(FadeInDuration, Easing.OutQuint); + JudgementBody.ScaleTo(1); + JudgementBody.RotateTo(0); + JudgementBody.MoveTo(Vector2.Zero); switch (Result.Type) { @@ -112,27 +121,30 @@ namespace osu.Game.Rulesets.Judgements Expire(true); } - private Color4 judgementColour(HitResult judgement) + private HitResult? currentDrawableType; + + private void prepareDrawables() { - switch (judgement) + var type = Result?.Type ?? HitResult.Perfect; //TODO: better default type from ruleset + + if (type == currentDrawableType) + return; + + InternalChild = JudgementBody = new Container { - case HitResult.Perfect: - case HitResult.Great: - return colours.Blue; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = bodyDrawable = new SkinnableDrawable(new GameplaySkinComponent(type), _ => JudgementText = new OsuSpriteText + { + Text = type.GetDescription().ToUpperInvariant(), + Font = OsuFont.Numeric.With(size: 20), + Colour = colours.ForHitResult(type), + Scale = new Vector2(0.85f, 1), + }, confineMode: ConfineMode.NoScaling) + }; - case HitResult.Ok: - case HitResult.Good: - return colours.Green; - - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Miss: - return colours.Red; - - default: - return Color4.White; - } + currentDrawableType = type; } } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs similarity index 50% rename from osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs rename to osu.Game/Rulesets/Judgements/IgnoreJudgement.cs index 5104d9494b..1871249c94 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs +++ b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs @@ -1,14 +1,16 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Osu.Judgements +namespace osu.Game.Rulesets.Judgements { - public class OsuSliderTailJudgement : OsuJudgement + public class IgnoreJudgement : Judgement { public override bool AffectsCombo => false; protected override int NumericResultFor(HitResult result) => 0; + + protected override double HealthIncreaseFor(HitResult result) => 0; } } diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index 599135ba54..9105b920ca 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Judgements return -DEFAULT_MAX_HEALTH_INCREASE * 0.01; case HitResult.Good: - return DEFAULT_MAX_HEALTH_INCREASE * 0.3; + return DEFAULT_MAX_HEALTH_INCREASE * 0.5; case HitResult.Great: return DEFAULT_MAX_HEALTH_INCREASE; diff --git a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs index 120bfc9a23..8c99d739cb 100644 --- a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs +++ b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs @@ -11,10 +11,11 @@ namespace osu.Game.Rulesets.Mods /// /// Whether we should allow failing at the current point in time. /// - bool AllowFail { get; } + /// Whether the fail should be allowed to proceed. Return false to block. + bool PerformFail(); /// - /// Whether we want to restart on fail. Only used if is true. + /// Whether we want to restart on fail. Only used if returns true. /// bool RestartOnFail { get; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/CentreHit.cs b/osu.Game/Rulesets/Mods/IApplicableToAudio.cs similarity index 59% rename from osu.Game.Rulesets.Taiko/Objects/CentreHit.cs rename to osu.Game/Rulesets/Mods/IApplicableToAudio.cs index a6354b16ed..901da7af55 100644 --- a/osu.Game.Rulesets.Taiko/Objects/CentreHit.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToAudio.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. -namespace osu.Game.Rulesets.Taiko.Objects +namespace osu.Game.Rulesets.Mods { - public class CentreHit : Hit + public interface IApplicableToAudio : IApplicableToTrack, IApplicableToSample { } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToSample.cs b/osu.Game/Rulesets/Mods/IApplicableToSample.cs new file mode 100644 index 0000000000..559d127cfc --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToSample.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.Audio.Sample; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// An interface for mods that make adjustments to a sample. + /// + public interface IApplicableToSample : IApplicableMod + { + void ApplyToSample(SampleChannel sample); + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index b780ec9e76..0e5fe3fc9c 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -2,9 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.IO.Serialization; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { @@ -28,7 +34,7 @@ namespace osu.Game.Rulesets.Mods /// The icon of this mod. /// [JsonIgnore] - public virtual IconUsage Icon => FontAwesome.Solid.Question; + public virtual IconUsage? Icon => null; /// /// The type of this mod. @@ -42,6 +48,51 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual string Description => string.Empty; + /// + /// The tooltip to display for this mod when used in a . + /// + /// + /// Differs from , as the value of attributes (AR, CS, etc) changeable via the mod + /// are displayed in the tooltip. + /// + [JsonIgnore] + public string IconTooltip + { + get + { + string description = SettingDescription; + + return string.IsNullOrEmpty(description) ? Name : $"{Name} ({description})"; + } + } + + /// + /// The description of editable settings of a mod to use in the . + /// + /// + /// Parentheses are added to the tooltip, surrounding the value of this property. If this property is string.Empty, + /// the tooltip will not have parentheses. + /// + public virtual string SettingDescription + { + get + { + var tooltipTexts = new List(); + + foreach ((SettingSourceAttribute attr, PropertyInfo property) in this.GetOrderedSettingsSourceProperties()) + { + object bindableObj = property.GetValue(this); + + if ((bindableObj as IHasDefaultValue)?.IsDefault == true) + continue; + + tooltipTexts.Add($"{attr.Label} {bindableObj}"); + } + + return string.Join(", ", tooltipTexts.Where(s => !string.IsNullOrEmpty(s))); + } + } + /// /// The score multiplier of this mod. /// @@ -60,6 +111,12 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool Ranked => false; + /// + /// Whether this mod requires configuration to apply changes to the game. + /// + [JsonIgnore] + public virtual bool RequiresConfiguration => false; + /// /// The mods this mod cannot be enabled with. /// diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 070a10b1c8..945dd444be 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -22,12 +22,13 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Autoplay"; public override string Acronym => "AT"; - public override IconUsage Icon => OsuIcon.ModAuto; + public override IconUsage? Icon => OsuIcon.ModAuto; public override ModType Type => ModType.Automation; public override string Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; - public bool AllowFail => false; + public bool PerformFail() => false; + public bool RestartOnFail => false; public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail) }; diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index 7d7ecfa416..1fde5abad4 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods /// /// We never fail, 'yo. /// - public bool AllowFail => false; + public bool PerformFail() => false; public bool RestartOnFail => false; diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 3487d49e08..cf8128301c 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Cinema"; public override string Acronym => "CN"; - public override IconUsage Icon => OsuIcon.ModCinema; + public override IconUsage? Icon => OsuIcon.ModCinema; public override string Description => "Watch the video without visual distractions."; public void ApplyToHUD(HUDOverlay overlay) @@ -39,7 +39,6 @@ namespace osu.Game.Rulesets.Mods { player.Background.EnableUserDim.Value = false; - player.DimmableVideo.IgnoreUserSettings.Value = true; player.DimmableStoryboard.IgnoreUserSettings.Value = true; player.BreakOverlay.Hide(); diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 71a666414f..bd98e735e5 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Daycore"; public override string Acronym => "DC"; - public override IconUsage Icon => FontAwesome.Solid.Question; + public override IconUsage? Icon => null; public override string Description => "Whoaaaaa..."; private readonly BindableNumber tempoAdjust = new BindableDouble(1); diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index c5b8a1bc73..165644edbe 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Sprites; using System; using System.Collections.Generic; using osu.Game.Configuration; +using System.Linq; namespace osu.Game.Rulesets.Mods { @@ -20,32 +21,53 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.Conversion; - public override IconUsage Icon => FontAwesome.Solid.Hammer; + public override IconUsage? Icon => FontAwesome.Solid.Hammer; public override double ScoreMultiplier => 1.0; + public override bool RequiresConfiguration => true; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; - [SettingSource("Drain Rate", "Override a beatmap's set HP.")] + protected const int FIRST_SETTING_ORDER = 1; + + protected const int LAST_SETTING_ORDER = 2; + + [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)] public BindableNumber DrainRate { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, }; - [SettingSource("Overall Difficulty", "Override a beatmap's set OD.")] + [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)] public BindableNumber OverallDifficulty { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, }; + public override string SettingDescription + { + get + { + string drainRate = DrainRate.IsDefault ? string.Empty : $"HP {DrainRate.Value:N1}"; + string overallDifficulty = OverallDifficulty.IsDefault ? string.Empty : $"OD {OverallDifficulty.Value:N1}"; + + return string.Join(", ", new[] + { + drainRate, + overallDifficulty + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + private BeatmapDifficulty difficulty; public void ReadFromDifficulty(BeatmapDifficulty difficulty) @@ -73,7 +95,7 @@ namespace osu.Game.Rulesets.Mods /// /// Transfer a setting from to a configuration bindable. - /// Only performs the transfer if the user it not currently overriding.. + /// Only performs the transfer if the user is not currently overriding. /// protected void TransferSetting(BindableNumber bindable, T beatmapDefault) where T : struct, IComparable, IConvertible, IEquatable diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 7015460c51..152657da33 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Double Time"; public override string Acronym => "DT"; - public override IconUsage Icon => OsuIcon.ModDoubletime; + public override IconUsage? Icon => OsuIcon.ModDoubletime; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Zoooooooooom..."; public override bool Ranked => true; diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index ec0f50c0be..c6f3930029 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using Humanizer; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Easy"; public override string Acronym => "EZ"; - public override IconUsage Icon => OsuIcon.ModEasy; + public override IconUsage? Icon => OsuIcon.ModEasy; public override ModType Type => ModType.DifficultyReduction; public override double ScoreMultiplier => 0.5; public override bool Ranked => true; @@ -28,11 +29,15 @@ namespace osu.Game.Rulesets.Mods MaxValue = 10 }; + public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; + private int retries; private BindableNumber health; - public void ReadFromDifficulty(BeatmapDifficulty difficulty) { } + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } public void ApplyToDifficulty(BeatmapDifficulty difficulty) { @@ -45,17 +50,14 @@ namespace osu.Game.Rulesets.Mods retries = Retries.Value; } - public bool AllowFail + public bool PerformFail() { - get - { - if (retries == 0) return true; + if (retries == 0) return true; - health.Value = health.MaxValue; - retries--; + health.Value = health.MaxValue; + retries--; - return false; - } + return false; } public bool RestartOnFail => false; diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 4f939362bb..6e94a84e7d 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Flashlight"; public override string Acronym => "FL"; - public override IconUsage Icon => OsuIcon.ModFlashlight; + public override IconUsage? Icon => OsuIcon.ModFlashlight; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Restricted view area."; public override bool Ranked => true; @@ -47,9 +47,25 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { Combo.BindTo(scoreProcessor.Combo); + + // Default value of ScoreProcessor's Rank in Flashlight Mod should be SS+ + scoreProcessor.Rank.Value = ScoreRank.XH; } - public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + switch (rank) + { + case ScoreRank.X: + return ScoreRank.XH; + + case ScoreRank.S: + return ScoreRank.SH; + + default: + return rank; + } + } public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 15f7afa312..203b88951c 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Half Time"; public override string Acronym => "HT"; - public override IconUsage Icon => OsuIcon.ModHalftime; + public override IconUsage? Icon => OsuIcon.ModHalftime; public override ModType Type => ModType.DifficultyReduction; public override string Description => "Less zoom..."; public override bool Ranked => true; diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index a613d41cf4..0e589735c1 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -12,12 +12,14 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Hard Rock"; public override string Acronym => "HR"; - public override IconUsage Icon => OsuIcon.ModHardrock; + public override IconUsage? Icon => OsuIcon.ModHardrock; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; - public void ReadFromDifficulty(BeatmapDifficulty difficulty) { } + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } public void ApplyToDifficulty(BeatmapDifficulty difficulty) { diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 0934992f55..a1915b974c 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -17,12 +17,19 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Hidden"; public override string Acronym => "HD"; - public override IconUsage Icon => OsuIcon.ModHidden; + public override IconUsage? Icon => OsuIcon.ModHidden; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => true; protected Bindable IncreaseFirstObjectVisibility = new Bindable(); + /// + /// Check whether the provided hitobject should be considered the "first" hideable object. + /// Can be used to skip spinners, for instance. + /// + /// The hitobject to check. + protected virtual bool IsFirstHideableObject(DrawableHitObject hitObject) => true; + public void ReadFromConfig(OsuConfigManager config) { IncreaseFirstObjectVisibility = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility); @@ -30,8 +37,11 @@ namespace osu.Game.Rulesets.Mods public virtual void ApplyToDrawableHitObjects(IEnumerable drawables) { - foreach (var d in drawables.Skip(IncreaseFirstObjectVisibility.Value ? 1 : 0)) - d.ApplyCustomUpdateState += ApplyHiddenState; + if (IncreaseFirstObjectVisibility.Value) + drawables = drawables.SkipWhile(h => !IsFirstHideableObject(h)).Skip(1); + + foreach (var dho in drawables) + dho.ApplyCustomUpdateState += ApplyHiddenState; } public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index a8c79bb896..ed8eb2fb66 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Nightcore"; public override string Acronym => "NC"; - public override IconUsage Icon => OsuIcon.ModNightcore; + public override IconUsage? Icon => OsuIcon.ModNightcore; public override string Description => "Uguuuuuuuu..."; private readonly BindableNumber tempoAdjust = new BindableDouble(1); @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Mods private const int bars_per_segment = 4; - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 49ee3354c3..b95ec7490e 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Fail"; public override string Acronym => "NF"; - public override IconUsage Icon => OsuIcon.ModNofail; + 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 double ScoreMultiplier => 0.5; diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 487985b2b3..379a2122f2 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "No Mod"; public override string Acronym => "NM"; public override double ScoreMultiplier => 1; - public override IconUsage Icon => FontAwesome.Solid.Ban; + public override IconUsage? Icon => FontAwesome.Solid.Ban; public override ModType Type => ModType.System; } } diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index afa263f1c9..7fe606d584 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -12,9 +12,11 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Perfect"; public override string Acronym => "PF"; - public override IconUsage Icon => OsuIcon.ModPerfect; + public override IconUsage? Icon => OsuIcon.ModPerfect; public override string Description => "SS or quit."; - protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type != result.Judgement.MaxResult; + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + => !(result.Judgement is IgnoreJudgement) + && result.Type != result.Judgement.MaxResult; } } diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs new file mode 100644 index 0000000000..da55ab3fbf --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModRandom : Mod + { + public override string Name => "Random"; + public override string Acronym => "RD"; + public override ModType Type => ModType.Conversion; + public override IconUsage? Icon => OsuIcon.Dice; + public override double ScoreMultiplier => 1; + } +} diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 1739524bcd..874384686f 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -2,12 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; namespace osu.Game.Rulesets.Mods { - public abstract class ModRateAdjust : Mod, IApplicableToTrack + public abstract class ModRateAdjust : Mod, IApplicableToAudio { public abstract BindableNumber SpeedChange { get; } @@ -15,5 +16,12 @@ namespace osu.Game.Rulesets.Mods { track.AddAdjustment(AdjustableProperty.Tempo, SpeedChange); } + + public virtual void ApplyToSample(SampleChannel sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } } diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index 7c355577d4..b6fec42f43 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Relax"; public override string Acronym => "RX"; - public override IconUsage Icon => OsuIcon.ModRelax; + public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModSuddenDeath) }; diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index a4d0631d8c..df10262845 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -13,14 +13,15 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Sudden Death"; public override string Acronym => "SD"; - public override IconUsage Icon => OsuIcon.ModSuddendeath; + public override IconUsage? Icon => OsuIcon.ModSuddendeath; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; - public bool AllowFail => true; + public bool PerformFail() => true; + public bool RestartOnFail => true; public void ApplyToHealthProcessor(HealthProcessor healthProcessor) diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 133f9ceb39..839d97f04e 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -10,19 +10,28 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.Objects; +using osu.Framework.Audio.Sample; namespace osu.Game.Rulesets.Mods { - public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToTrack + public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToAudio { /// /// The point in the beatmap at which the final ramping rate should be reached. /// private const double final_rate_progress = 0.75f; + [SettingSource("Initial rate", "The starting speed of the track")] + public abstract BindableNumber InitialRate { get; } + [SettingSource("Final rate", "The final speed to ramp to")] public abstract BindableNumber FinalRate { get; } + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public abstract BindableBool AdjustPitch { get; } + + public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"; + private double finalRateTime; private double beginRampTime; @@ -38,15 +47,21 @@ namespace osu.Game.Rulesets.Mods protected ModTimeRamp() { // for preview purpose at song select. eventually we'll want to be able to update every frame. - FinalRate.BindValueChanged(val => applyAdjustment(1), true); + FinalRate.BindValueChanged(val => applyRateAdjustment(1), true); + AdjustPitch.BindValueChanged(applyPitchAdjustment); } public void ApplyToTrack(Track track) { this.track = track; - track.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); FinalRate.TriggerChange(); + AdjustPitch.TriggerChange(); + } + + public void ApplyToSample(SampleChannel sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); } public virtual void ApplyToBeatmap(IBeatmap beatmap) @@ -61,14 +76,25 @@ namespace osu.Game.Rulesets.Mods public virtual void Update(Playfield playfield) { - applyAdjustment((track.CurrentTime - beginRampTime) / finalRateTime); + applyRateAdjustment((track.CurrentTime - beginRampTime) / finalRateTime); } /// /// Adjust the rate along the specified ramp /// /// The amount of adjustment to apply (from 0..1). - private void applyAdjustment(double amount) => - SpeedChange.Value = 1 + (FinalRate.Value - 1) * Math.Clamp(amount, 0, 1); + private void applyRateAdjustment(double amount) => + SpeedChange.Value = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + + private void applyPitchAdjustment(ValueChangedEvent adjustPitchSetting) + { + // remove existing old adjustment + track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); + + track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); + } + + private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) + => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; } } diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 5416f1ac22..679b50057b 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -14,9 +14,19 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Wind Down"; public override string Acronym => "WD"; public override string Description => "Sloooow doooown..."; - public override IconUsage Icon => FontAwesome.Solid.ChevronCircleDown; + 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 + { + MinValue = 1, + 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 { @@ -27,6 +37,13 @@ namespace osu.Game.Rulesets.Mods 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 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 3cf584f3dd..b733bf423e 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -14,9 +14,19 @@ 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 IconUsage Icon => FontAwesome.Solid.ChevronCircleUp; + 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 + { + MinValue = 0.5, + MaxValue = 1, + Default = 1, + Value = 1, + Precision = 0.01, + }; + [SettingSource("Final rate", "The speed increase to ramp towards")] public override BindableNumber FinalRate { get; } = new BindableDouble { @@ -27,6 +37,13 @@ namespace osu.Game.Rulesets.Mods 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 Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); } } diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs index 99672240e2..9556b52735 100644 --- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs +++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -46,6 +47,16 @@ namespace osu.Game.Rulesets.Objects for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) { + var roundedTime = Math.Round(t, MidpointRounding.AwayFromZero); + + // in the case of some bar lengths, rounding errors can cause t to be slightly less than + // the expected whole number value due to floating point inaccuracies. + // if this is the case, apply rounding. + if (Precision.AlmostEquals(t, roundedTime)) + { + t = roundedTime; + } + BarLines.Add(new TBarLine { StartTime = t, diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 4ac30fe7fb..b633cb0860 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,6 +16,8 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; +using osu.Game.Configuration; +using osu.Game.Screens.Play; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables @@ -24,6 +25,8 @@ namespace osu.Game.Rulesets.Objects.Drawables [Cached(typeof(DrawableHitObject))] public abstract class DrawableHitObject : SkinReloadableDrawable { + public event Action DefaultsApplied; + public readonly HitObject HitObject; /// @@ -33,11 +36,20 @@ namespace osu.Game.Rulesets.Objects.Drawables protected SkinnableSound Samples { get; private set; } - protected virtual IEnumerable GetSamples() => HitObject.Samples; + public virtual IEnumerable GetSamples() => HitObject.Samples; private readonly Lazy> nestedHitObjects = new Lazy>(); public IReadOnlyList NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : (IReadOnlyList)Array.Empty(); + /// + /// Whether this object should handle any user input events. + /// + public bool HandleUserInput { get; set; } = true; + + public override bool PropagatePositionalInputSubTree => HandleUserInput; + + public override bool PropagateNonPositionalInputSubTree => HandleUserInput; + /// /// Invoked when a has been applied by this or a nested . /// @@ -75,8 +87,18 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public JudgementResult Result { get; private set; } + /// + /// The relative X position of this hit object for sample playback balance adjustment. + /// + /// + /// This is a range of 0..1 (0 for far-left, 0.5 for centre, 1 for far-right). + /// Dampening is post-applied to ensure the effect is not too intense. + /// + protected virtual float SamplePlaybackPosition => 0.5f; + private BindableList samplesBindable; private Bindable startTimeBindable; + private Bindable userPositionalHitSounds; private Bindable comboIndexBindable; public override bool RemoveWhenNotAlive => false; @@ -95,16 +117,14 @@ namespace osu.Game.Rulesets.Objects.Drawables } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + userPositionalHitSounds = config.GetBindable(OsuSetting.PositionalHitSounds); var judgement = HitObject.CreateJudgement(); - if (judgement != null) - { - Result = CreateResult(judgement); - if (Result == null) - throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - } + Result = CreateResult(judgement); + if (Result == null) + throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); loadSamples(); } @@ -116,20 +136,20 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied += onDefaultsApplied; startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); - startTimeBindable.BindValueChanged(_ => updateState(ArmedState.Idle, true)); + startTimeBindable.BindValueChanged(_ => updateState(State.Value, true)); if (HitObject is IHasComboInformation combo) { comboIndexBindable = combo.ComboIndexBindable.GetBoundCopy(); - comboIndexBindable.BindValueChanged(_ => updateAccentColour(), true); + comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); } samplesBindable = HitObject.SamplesBindable.GetBoundCopy(); - samplesBindable.ItemsAdded += _ => loadSamples(); - samplesBindable.ItemsRemoved += _ => loadSamples(); + samplesBindable.CollectionChanged += (_, __) => loadSamples(); + + apply(HitObject); updateState(ArmedState.Idle, true); - onDefaultsApplied(); } private void loadSamples() @@ -151,18 +171,18 @@ namespace osu.Game.Rulesets.Objects.Drawables + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); } - AddInternal(Samples = new SkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)))); + Samples = new SkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s))); + AddInternal(Samples); } - private void onDefaultsApplied() => apply(HitObject); + private void onDefaultsApplied(HitObject hitObject) + { + apply(hitObject); + DefaultsApplied?.Invoke(this); + } private void apply(HitObject hitObject) { -#pragma warning disable 618 // can be removed 20200417 - if (GetType().GetMethod(nameof(AddNested), BindingFlags.NonPublic | BindingFlags.Instance)?.DeclaringType != typeof(DrawableHitObject)) - return; -#pragma warning restore 618 - if (nestedHitObjects.IsValueCreated) { nestedHitObjects.Value.Clear(); @@ -173,7 +193,11 @@ namespace osu.Game.Rulesets.Objects.Drawables { var drawableNested = CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); - addNested(drawableNested); + drawableNested.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); + drawableNested.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); + drawableNested.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); + + nestedHitObjects.Value.Add(drawableNested); AddNestedHitObject(drawableNested); } } @@ -186,13 +210,6 @@ namespace osu.Game.Rulesets.Objects.Drawables { } - /// - /// Adds a nested . This should not be used except for legacy nested usages. - /// - /// - [Obsolete("Use AddNestedHitObject() / ClearNestedHitObjects() / CreateNestedHitObject() instead.")] // can be removed 20200417 - protected virtual void AddNested(DrawableHitObject h) => addNested(h); - /// /// Invoked by the base to remove all previously-added nested s. /// @@ -207,17 +224,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// The drawable representation for . protected virtual DrawableHitObject CreateNestedHitObject(HitObject hitObject) => null; - private void addNested(DrawableHitObject hitObject) - { - // Todo: Exists for legacy purposes, can be removed 20200417 - - hitObject.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); - hitObject.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); - hitObject.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); - - nestedHitObjects.Value.Add(hitObject); - } - #region State / Transform Management /// @@ -225,18 +231,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public event Action ApplyCustomUpdateState; -#pragma warning disable 618 // (legacy state management) - can be removed 20200227 - - /// - /// Enables automatic transform management of this hitobject. Implementation of transforms should be done in and only. Rewinding and removing previous states is done automatically. - /// - /// - /// Going forward, this is the preferred way of implementing s. Previous functionality - /// is offered as a compatibility layer until all rulesets have been migrated across. - /// - [Obsolete("Use UpdateInitialTransforms()/UpdateStateTransforms() instead")] // can be removed 20200227 - protected virtual bool UseTransformStateManagement => true; - protected override void ClearInternal(bool disposeChildren = true) => throw new InvalidOperationException($"Should never clear a {nameof(DrawableHitObject)}"); private void updateState(ArmedState newState, bool force = false) @@ -244,35 +238,28 @@ namespace osu.Game.Rulesets.Objects.Drawables if (State.Value == newState && !force) return; - if (UseTransformStateManagement) + LifetimeEnd = double.MaxValue; + + double transformTime = HitObject.StartTime - InitialLifetimeOffset; + + base.ApplyTransformsAt(double.MinValue, true); + base.ClearTransformsAfter(double.MinValue, true); + + using (BeginAbsoluteSequence(transformTime, true)) { - LifetimeEnd = double.MaxValue; + UpdateInitialTransforms(); - double transformTime = HitObject.StartTime - InitialLifetimeOffset; + var judgementOffset = Result?.TimeOffset ?? 0; - base.ApplyTransformsAt(transformTime, true); - base.ClearTransformsAfter(transformTime, true); - - using (BeginAbsoluteSequence(transformTime, true)) + using (BeginDelayedSequence(InitialLifetimeOffset + judgementOffset, true)) { - UpdateInitialTransforms(); - - var judgementOffset = Result?.TimeOffset ?? 0; - - using (BeginDelayedSequence(InitialLifetimeOffset + judgementOffset, true)) - { - UpdateStateTransforms(newState); - state.Value = newState; - } + UpdateStateTransforms(newState); + state.Value = newState; } - - if (state.Value != ArmedState.Idle && LifetimeEnd == double.MaxValue) - Expire(); } - else - state.Value = newState; - UpdateState(newState); + if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null)) + Expire(); // apply any custom state overrides ApplyCustomUpdateState?.Invoke(this, newState); @@ -307,37 +294,21 @@ namespace osu.Game.Rulesets.Objects.Drawables public override void ClearTransformsAfter(double time, bool propagateChildren = false, string targetMember = null) { - // When we are using automatic state management, parent calls to this should be blocked for safety. - if (!UseTransformStateManagement) - base.ClearTransformsAfter(time, propagateChildren, targetMember); + // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } public override void ApplyTransformsAt(double time, bool propagateChildren = false) { - // When we are using automatic state management, parent calls to this should be blocked for safety. - if (!UseTransformStateManagement) - base.ApplyTransformsAt(time, propagateChildren); + // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } - /// - /// Legacy method to handle state changes. - /// Should generally not be used when is true; use instead. - /// - /// The new armed state. - [Obsolete("Use UpdateInitialTransforms()/UpdateStateTransforms() instead")] // can be removed 20200227 - protected virtual void UpdateState(ArmedState state) - { - } - -#pragma warning restore 618 - #endregion protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); - updateAccentColour(); + updateComboColour(); ApplySkin(skin, allowFallback); @@ -345,13 +316,29 @@ namespace osu.Game.Rulesets.Objects.Drawables updateState(State.Value, true); } - private void updateAccentColour() + private void updateComboColour() { - if (HitObject is IHasComboInformation combo) - { - var comboColours = CurrentSkin.GetConfig>(GlobalSkinConfiguration.ComboColours)?.Value; - AccentColour.Value = comboColours?.Count > 0 ? comboColours[combo.ComboIndex % comboColours.Count] : Color4.White; - } + if (!(HitObject is IHasComboInformation)) return; + + var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value; + + AccentColour.Value = GetComboColour(comboColours); + } + + /// + /// Called to retrieve the combo colour. Automatically assigned to . + /// Defaults to using to decide on a colour. + /// + /// + /// This will only be called if the implements . + /// + /// A list of combo colours provided by the beatmap or skin. Can be null if not available. + protected virtual Color4 GetComboColour(IReadOnlyList comboColours) + { + if (!(HitObject is IHasComboInformation combo)) + throw new InvalidOperationException($"{nameof(HitObject)} must implement {nameof(IHasComboInformation)}"); + + return comboColours?.Count > 0 ? comboColours[combo.ComboIndex % comboColours.Count] : Color4.White; } /// @@ -363,11 +350,23 @@ namespace osu.Game.Rulesets.Objects.Drawables { } + [Resolved(canBeNull: true)] + private GameplayClock gameplayClock { get; set; } + /// /// Plays all the hit sounds for this . /// This is invoked automatically when this is hit. /// - public void PlaySamples() => Samples?.Play(); + public virtual void PlaySamples() + { + const float balance_adjust_amount = 0.4f; + + if (Samples != null && gameplayClock?.IsSeeking != true) + { + Samples.Balance.Value = balance_adjust_amount * (userPositionalHitSounds.Value ? SamplePlaybackPosition - 0.5f : 0); + Samples.Play(); + } + } protected override void Update() { @@ -389,7 +388,7 @@ namespace osu.Game.Rulesets.Objects.Drawables } } - protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => AllJudged && base.ComputeIsMaskedAway(maskingBounds); + public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; protected override void UpdateAfterChildren() { diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index bd96441ebb..1d60b266e3 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; @@ -32,9 +33,9 @@ namespace osu.Game.Rulesets.Objects /// /// Invoked after has completed on this . /// - public event Action DefaultsApplied; + public event Action DefaultsApplied; - public readonly Bindable StartTimeBindable = new Bindable(); + public readonly Bindable StartTimeBindable = new BindableDouble(); /// /// The time at which the HitObject starts. @@ -99,7 +100,8 @@ namespace osu.Game.Rulesets.Objects /// /// The control points. /// The difficulty settings to use. - public void ApplyDefaults(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + /// The cancellation token. + public void ApplyDefaults(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty, CancellationToken cancellationToken = default) { ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -108,7 +110,7 @@ namespace osu.Game.Rulesets.Objects nestedHitObjects.Clear(); - CreateNestedHitObjects(); + CreateNestedHitObjects(cancellationToken); if (this is IHasComboInformation hasCombo) { @@ -122,20 +124,27 @@ namespace osu.Game.Rulesets.Objects nestedHitObjects.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); foreach (var h in nestedHitObjects) - h.ApplyDefaults(controlPointInfo, difficulty); + h.ApplyDefaults(controlPointInfo, difficulty, cancellationToken); - DefaultsApplied?.Invoke(); + DefaultsApplied?.Invoke(this); } protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { Kiai = controlPointInfo.EffectPointAt(StartTime + control_point_leniency).KiaiMode; - if (HitWindows == null) - HitWindows = CreateHitWindows(); + HitWindows ??= CreateHitWindows(); HitWindows?.SetDifficulty(difficulty.OverallDifficulty); } + protected virtual void CreateNestedHitObjects(CancellationToken cancellationToken) + { + // ReSharper disable once MethodSupportsCancellation (https://youtrack.jetbrains.com/issue/RIDER-44520) +#pragma warning disable 618 + CreateNestedHitObjects(); +#pragma warning restore 618 + } + protected virtual void CreateNestedHitObjects() { } @@ -144,9 +153,9 @@ namespace osu.Game.Rulesets.Objects /// /// Creates the that represents the scoring information for this . - /// May be null. /// - public virtual Judgement CreateJudgement() => null; + [NotNull] + public virtual Judgement CreateJudgement() => new Judgement(); /// /// Creates the for this . @@ -165,10 +174,10 @@ namespace osu.Game.Rulesets.Objects /// Returns the end time of this object. /// /// - /// This returns the where available, falling back to otherwise. + /// This returns the where available, falling back to otherwise. /// /// The object. /// The end time of this object. - public static double GetEndTime(this HitObject hitObject) => (hitObject as IHasEndTime)?.EndTime ?? hitObject.StartTime; + public static double GetEndTime(this HitObject hitObject) => (hitObject as IHasDuration)?.EndTime ?? hitObject.StartTime; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs index febfd3696c..19722fb796 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch /// /// Legacy osu!catch Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : HitObject, IHasCombo, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasCombo, IHasXPosition { public float X { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index 43e8d01297..c10c8dc30f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { // Convert spinners don't create the new combo themselves, but force the next non-spinner hitobject to create a new combo // Their combo offset is still added to that next hitobject's combo index @@ -65,11 +65,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertSpinner { - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs index 0089d1eb88..014494ec54 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs @@ -8,11 +8,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch /// /// Legacy osu!catch Spinner-type, used for parsing Beatmaps. /// - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasXPosition, IHasCombo + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition, IHasCombo { - public double EndTime { get; set; } + public double EndTime => StartTime + Duration; - public double Duration => EndTime - StartTime; + public double Duration { get; set; } public float X => 256; // Required for CatchBeatmapConverter diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs new file mode 100644 index 0000000000..e3b0d8a498 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.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.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// + /// A hit object only used for conversion, not actual gameplay. + /// + internal abstract class ConvertHitObject : HitObject + { + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 3eab4555d1..77075b2abe 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -10,8 +10,9 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; using JetBrains.Annotations; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps.Legacy; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Objects.Legacy { @@ -126,7 +127,7 @@ namespace osu.Game.Rulesets.Objects.Legacy if (split.Length > 7) { - length = Math.Max(0, Parsing.ParseDouble(split[7])); + length = Math.Max(0, Parsing.ParseDouble(split[7], Parsing.MAX_COORDINATE_VALUE)); if (length == 0) length = null; } @@ -189,9 +190,9 @@ namespace osu.Game.Rulesets.Objects.Legacy } else if (type.HasFlag(LegacyHitObjectType.Spinner)) { - double endTime = Math.Max(startTime, Parsing.ParseDouble(split[5]) + Offset); + double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); - result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, endTime); + result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, duration); if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); @@ -209,7 +210,7 @@ namespace osu.Game.Rulesets.Objects.Legacy readCustomSampleBanks(string.Join(":", ss.Skip(1)), bankInfo); } - result = CreateHold(pos, combo, comboOffset, endTime + Offset); + result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime); } if (result == null) @@ -321,9 +322,9 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The position of the hit object. /// Whether the hit object creates a new combo. /// When starting a new combo, the offset of the new combo relative to the current one. - /// The spinner end time. + /// The spinner duration. /// The hit object. - protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime); + protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration); /// /// Creates a legacy Hold-type hit object. @@ -331,8 +332,8 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The position of the hit object. /// Whether the hit object creates a new combo. /// When starting a new combo, the offset of the new combo relative to the current one. - /// The hold end time. - protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime); + /// The hold duration. + protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration); private List convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { @@ -356,7 +357,10 @@ namespace osu.Game.Rulesets.Objects.Legacy Bank = bankInfo.Normal, Name = HitSampleInfo.HIT_NORMAL, Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank + CustomSampleBank = 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 + IsLayered = type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal) } }; @@ -409,22 +413,43 @@ namespace osu.Game.Rulesets.Objects.Legacy public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } - private class LegacyHitSampleInfo : HitSampleInfo + public class LegacyHitSampleInfo : HitSampleInfo { + private int customSampleBank; + public int CustomSampleBank { + get => customSampleBank; set { - if (value > 1) + customSampleBank = value; + + if (value >= 2) Suffix = value.ToString(); } } + + /// + /// Whether this hit sample is layered. + /// + /// + /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled + /// using the skin config option. + /// + public bool IsLayered { get; set; } } - private class FileHitSampleInfo : HitSampleInfo + private class FileHitSampleInfo : LegacyHitSampleInfo { public string Filename; + public FileHitSampleInfo() + { + // Make sure that the LegacyBeatmapSkin does not fall back to the user skin. + // Note that this does not change the lookup names, as they are overridden locally. + CustomSampleBank = 1; + } + public override IEnumerable LookupNames => new[] { Filename, diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index 8d523022d6..c522dc623c 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -9,7 +9,10 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : HitObject, IHasCurve, IHasLegacyLastTickOffset + internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, +#pragma warning disable 618 + IHasCurve +#pragma warning restore 618 { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -26,8 +29,13 @@ namespace osu.Game.Rulesets.Objects.Legacy public List> NodeSamples { get; set; } public int RepeatCount { get; set; } - public double EndTime => StartTime + this.SpanCount() * Distance / Velocity; - public double Duration => EndTime - StartTime; + public double Duration + { + get => this.SpanCount() * Distance / Velocity; + set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } + + public double EndTime => StartTime + Duration; public double Velocity = 1; diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs index 883ef55df0..0b69817c13 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs @@ -2,17 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects.Legacy.Mania { /// /// Legacy osu!mania Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : HitObject, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasXPosition { public float X { get; set; } - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs index f94c4aaa75..bc64518f40 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs @@ -37,21 +37,21 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertSpinner { X = position.X, - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertHold { X = position.X, - EndTime = endTime + Duration = duration }; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs index 69e6f8379d..2fa4766c1d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs @@ -2,18 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects.Legacy.Mania { - internal sealed class ConvertHold : HitObject, IHasXPosition, IHasEndTime + internal sealed class ConvertHold : ConvertHitObject, IHasXPosition, IHasDuration { public float X { get; set; } - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; + public double EndTime => StartTime + Duration; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs index 4486c5d906..84cde5fa95 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects.Legacy.Mania { @@ -12,7 +11,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition { public float X { get; set; } - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs index c6d1f1922c..c05aaceb9c 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs @@ -2,21 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects.Legacy.Mania { /// /// Legacy osu!mania Spinner-type, used for parsing Beatmaps. /// - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasXPosition + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; public float X { get; set; } - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs index e40b5b4505..069366bad3 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Osu @@ -10,7 +9,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu /// /// Legacy osu! Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : HitObject, IHasPosition, IHasCombo + internal sealed class ConvertHit : ConvertHitObject, IHasPosition, IHasCombo { public Vector2 Position { get; set; } @@ -21,7 +20,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public bool NewCombo { get; set; } public int ComboOffset { get; set; } - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs index b95ec703b6..75ecab0b8f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { // Convert spinners don't create the new combo themselves, but force the next non-spinner hitobject to create a new combo // Their combo offset is still added to that next hitobject's combo index @@ -66,11 +66,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu return new ConvertSpinner { Position = position, - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs index a163329d47..e947690668 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Osu @@ -21,7 +20,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public bool NewCombo { get; set; } public int ComboOffset { get; set; } - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs index 5d96a61633..e9e5ca8c94 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Osu @@ -10,11 +9,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu /// /// Legacy osu! Spinner-type, used for parsing Beatmaps. /// - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasPosition, IHasCombo + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasPosition, IHasCombo { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; public Vector2 Position { get; set; } @@ -22,8 +21,6 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public float Y => Position.Y; - protected override HitWindows CreateHitWindows() => HitWindows.Empty; - public bool NewCombo { get; set; } public int ComboOffset { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs index efb9810927..cb5178ce48 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs @@ -1,15 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Scoring; - namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// /// Legacy osu!taiko Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : HitObject + internal sealed class ConvertHit : ConvertHitObject { - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs index db65a61c90..13e3e84c6a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs @@ -33,15 +33,15 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertSpinner { - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs index b365fd34ae..821554f7ee 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.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 osu.Game.Rulesets.Scoring; - namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// @@ -10,6 +8,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko /// internal sealed class ConvertSlider : Legacy.ConvertSlider { - protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs index 840ba51ac2..1d5ecb1ef3 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs @@ -2,19 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// /// Legacy osu!taiko Spinner-type, used for parsing Beatmaps. /// - internal sealed class ConvertSpinner : HitObject, IHasEndTime + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; - - protected override HitWindows CreateHitWindows() => HitWindows.Empty; + public double EndTime => StartTime + Duration; } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index e9ee3833b7..d8c6da86f9 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -4,13 +4,23 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { + [Obsolete("Use the overload with cancellation support instead.")] // can be removed 20201115 + // ReSharper disable once RedundantOverload.Global public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, double? legacyLastTickOffset) + { + return Generate(startTime, spanDuration, velocity, tickDistance, totalDistance, spanCount, legacyLastTickOffset, default); + } + + // ReSharper disable once MethodOverloadWithOptionalParameter + public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, + double? legacyLastTickOffset, CancellationToken cancellationToken = default) { // A very lenient maximum length of a slider for ticks to be generated. // This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. @@ -37,7 +47,7 @@ namespace osu.Game.Rulesets.Objects var spanStartTime = startTime + span * spanDuration; var reversed = span % 2 == 1; - var ticks = generateTicks(span, spanStartTime, spanDuration, reversed, length, tickDistance, minDistanceFromEnd); + var ticks = generateTicks(span, spanStartTime, spanDuration, reversed, length, tickDistance, minDistanceFromEnd, cancellationToken); if (reversed) { @@ -108,12 +118,15 @@ namespace osu.Game.Rulesets.Objects /// The length of the path. /// The distance between each tick. /// The distance from the end of the path at which ticks are not allowed to be added. + /// The cancellation token. /// A for each tick. If is true, the ticks will be returned in reverse-StartTime order. private static IEnumerable generateTicks(int spanIndex, double spanStartTime, double spanDuration, bool reversed, double length, double tickDistance, - double minDistanceFromEnd) + double minDistanceFromEnd, CancellationToken cancellationToken = default) { for (var d = tickDistance; d <= length; d += tickDistance) { + cancellationToken.ThrowIfCancellationRequested(); + if (d >= length - minDistanceFromEnd) break; diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 293138097f..d577e8fdda 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -3,11 +3,12 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Caching; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -47,18 +48,20 @@ namespace osu.Game.Rulesets.Objects { ExpectedDistance.ValueChanged += _ => invalidate(); - ControlPoints.ItemsAdded += items => + ControlPoints.CollectionChanged += (_, args) => { - foreach (var c in items) - c.Changed += invalidate; + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var c in args.NewItems.Cast()) + c.Changed += invalidate; + break; - invalidate(); - }; - - ControlPoints.ItemsRemoved += items => - { - foreach (var c in items) - c.Changed -= invalidate; + case NotifyCollectionChangedAction.Remove: + foreach (var c in args.OldItems.Cast()) + c.Changed -= invalidate; + break; + } invalidate(); }; diff --git a/osu.Game/Rulesets/Objects/Types/IHasCurve.cs b/osu.Game/Rulesets/Objects/Types/IHasCurve.cs index e98a888bd7..26f50ffa31 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasCurve.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasCurve.cs @@ -1,13 +1,12 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osuTK; namespace osu.Game.Rulesets.Objects.Types { - /// - /// A HitObject that has a curve. - /// + [Obsolete("Use IHasPathWithRepeats instead.")] // can be removed 20201126 public interface IHasCurve : IHasDistance, IHasRepeats { /// @@ -16,6 +15,8 @@ namespace osu.Game.Rulesets.Objects.Types SliderPath Path { get; } } +#pragma warning disable 618 + [Obsolete("Use IHasPathWithRepeats instead.")] // can be removed 20201126 public static class HasCurveExtensions { /// @@ -50,4 +51,5 @@ namespace osu.Game.Rulesets.Objects.Types public static int SpanAt(this IHasCurve obj, double progress) => (int)(progress * obj.SpanCount()); } +#pragma warning restore 618 } diff --git a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs index e7f552115e..b497ca5da3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs @@ -6,7 +6,7 @@ namespace osu.Game.Rulesets.Objects.Types /// /// A HitObject that has a positional length. /// - public interface IHasDistance : IHasEndTime + public interface IHasDistance : IHasDuration { /// /// The positional length of the HitObject. diff --git a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs new file mode 100644 index 0000000000..185fd5977b --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasDuration.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 Newtonsoft.Json; + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A HitObject that ends at a different time than its start time. + /// +#pragma warning disable 618 + public interface IHasDuration : IHasEndTime +#pragma warning restore 618 + { + double IHasEndTime.EndTime + { + get => EndTime; + set => Duration = (Duration - EndTime) + value; + } + + double IHasEndTime.Duration => Duration; + + /// + /// The time at which the HitObject ends. + /// + new double EndTime { get; } + + /// + /// The duration of the HitObject. + /// + [JsonIgnore] + new double Duration { get; set; } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs b/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs index 516f1002a4..c3769c5909 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs @@ -1,17 +1,22 @@ -// 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 Newtonsoft.Json; + namespace osu.Game.Rulesets.Objects.Types { /// /// A HitObject that ends at a different time than its start time. /// + [Obsolete("Use IHasDuration instead.")] // can be removed 20201126 public interface IHasEndTime { /// /// The time at which the HitObject ends. /// - double EndTime { get; } + [JsonIgnore] + double EndTime { get; set; } /// /// The duration of the HitObject. diff --git a/osu.Game/Rulesets/Objects/Types/IHasPath.cs b/osu.Game/Rulesets/Objects/Types/IHasPath.cs new file mode 100644 index 0000000000..567c24a4a2 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasPath.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Objects.Types +{ + public interface IHasPath : IHasDistance + { + /// + /// The curve. + /// + SliderPath Path { get; } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs new file mode 100644 index 0000000000..279946b44e --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.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 osuTK; + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A HitObject that has a curve. + /// + // ReSharper disable once RedundantExtendsListEntry + public interface IHasPathWithRepeats : IHasPath, IHasRepeats + { + } + + public static class HasPathWithRepeatsExtensions + { + /// + /// Computes the position on the curve relative to how much of the has been completed. + /// + /// The curve. + /// [0, 1] where 0 is the start time of the and 1 is the end time of the . + /// The position on the curve. + public static Vector2 CurvePositionAt(this IHasPathWithRepeats obj, double progress) + => obj.Path.PositionAt(obj.ProgressAt(progress)); + + /// + /// Computes the progress along the curve relative to how much of the has been completed. + /// + /// The curve. + /// [0, 1] where 0 is the start time of the and 1 is the end time of the . + /// [0, 1] where 0 is the beginning of the curve and 1 is the end of the curve. + public static double ProgressAt(this IHasPathWithRepeats obj, double progress) + { + double p = progress * obj.SpanCount() % 1; + if (obj.SpanAt(progress) % 2 == 1) + p = 1 - p; + return p; + } + + /// + /// Determines which span of the curve the progress point is on. + /// + /// The curve. + /// [0, 1] where 0 is the beginning of the curve and 1 is the end of the curve. + /// [0, SpanCount) where 0 is the first run. + public static int SpanAt(this IHasPathWithRepeats obj, double progress) + => (int)(progress * obj.SpanCount()); + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index b22752e902..7a3fb16196 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -9,12 +9,12 @@ namespace osu.Game.Rulesets.Objects.Types /// /// A HitObject that spans some length. /// - public interface IHasRepeats : IHasEndTime + public interface IHasRepeats : IHasDuration { /// /// The amount of times the HitObject repeats. /// - int RepeatCount { get; } + int RepeatCount { get; set; } /// /// The samples to be played when each node of the is hit.
diff --git a/osu.Game/Rulesets/Replays/AutoGenerator.cs b/osu.Game/Rulesets/Replays/AutoGenerator.cs index 3319f30a6f..b3c609f2f4 100644 --- a/osu.Game/Rulesets/Replays/AutoGenerator.cs +++ b/osu.Game/Rulesets/Replays/AutoGenerator.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Replays #region Constants // Shared amongst all modes - protected const double KEY_UP_DELAY = 50; + public const double KEY_UP_DELAY = 50; #endregion diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 7e17396fde..55d82c4083 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Replays { int newFrame = nextFrameIndex; - //ensure we aren't at an extent. + // ensure we aren't at an extent. if (newFrame == currentFrameIndex) return false; currentFrameIndex = newFrame; @@ -99,8 +99,8 @@ namespace osu.Game.Rulesets.Replays if (frame == null) return false; - return IsImportant(frame) && //a button is in a pressed state - Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; //the next frame is within an allowable time span + return IsImportant(frame) && // a button is in a pressed state + Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span } } diff --git a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs index c2947c0aca..d9aa615c6e 100644 --- a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs @@ -17,6 +17,12 @@ 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 ConvertFrom(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null); + void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null); + + /// + /// Populates this using values from a . + /// + /// The beatmap. + LegacyReplayFrame ToLegacy(IBeatmap beatmap); } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 67ec6d15ea..3a7f433a37 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -21,6 +21,9 @@ 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.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets { @@ -41,13 +44,68 @@ namespace osu.Game.Rulesets /// /// Converts mods from legacy enum values. Do not override if you're not a legacy ruleset. /// - /// The legacy enum which will be converted - /// An enumerable of constructed s - public virtual IEnumerable ConvertLegacyMods(LegacyMods mods) => Array.Empty(); + /// The legacy enum which will be converted. + /// An enumerable of constructed s. + public virtual IEnumerable ConvertFromLegacyMods(LegacyMods mods) => Array.Empty(); - public ModAutoplay GetAutoplayMod() => GetAllMods().OfType().First(); + /// + /// Converts mods to legacy enum values. Do not override if you're not a legacy ruleset. + /// + /// The mods which will be converted. + /// A single bitwise enumerable value representing (to the best of our ability) the mods. + public virtual LegacyMods ConvertToLegacyMods(Mod[] mods) + { + var value = LegacyMods.None; - public virtual ISkin CreateLegacySkinProvider(ISkinSource source) => null; + foreach (var mod in mods) + { + switch (mod) + { + case ModNoFail _: + value |= LegacyMods.NoFail; + break; + + case ModEasy _: + value |= LegacyMods.Easy; + break; + + case ModHidden _: + value |= LegacyMods.Hidden; + break; + + case ModHardRock _: + value |= LegacyMods.HardRock; + break; + + case ModSuddenDeath _: + value |= LegacyMods.SuddenDeath; + break; + + case ModDoubleTime _: + value |= LegacyMods.DoubleTime; + break; + + case ModRelax _: + value |= LegacyMods.Relax; + break; + + case ModHalfTime _: + value |= LegacyMods.HalfTime; + break; + + case ModFlashlight _: + value |= LegacyMods.Flashlight; + break; + } + } + + return value; + } + + [CanBeNull] + public ModAutoplay GetAutoplayMod() => GetAllMods().OfType().FirstOrDefault(); + + public virtual ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => null; protected Ruleset() { @@ -57,7 +115,7 @@ namespace osu.Game.Rulesets ShortName = ShortName, ID = (this as ILegacyRuleset)?.LegacyID, InstantiationInfo = GetType().AssemblyQualifiedName, - Available = true + Available = true, }; } @@ -104,7 +162,7 @@ namespace osu.Game.Rulesets public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle }; - public virtual IResourceStore CreateResourceStore() => new NamespacedResourceStore(new DllResourceStore(GetType().Assembly.Location), @"Resources"); + public virtual IResourceStore CreateResourceStore() => new NamespacedResourceStore(new DllResourceStore(GetType().Assembly), @"Resources"); public abstract string Description { get; } @@ -121,6 +179,11 @@ namespace osu.Game.Rulesets ///
public abstract string ShortName { get; } + /// + /// The playing verb to be shown in the . + /// + public virtual string PlayingVerb => "Playing solo"; + /// /// A list of available variant ids. /// @@ -146,5 +209,14 @@ namespace osu.Game.Rulesets ///
/// An empty frame for the current ruleset, or null if unsupported. public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null; + + /// + /// Creates the statistics for a to be displayed in the results screen. + /// + /// 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(); } } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index ececc18c96..2e32b96084 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Newtonsoft.Json; namespace osu.Game.Rulesets @@ -15,7 +16,20 @@ namespace osu.Game.Rulesets public string ShortName { get; set; } - public string InstantiationInfo { get; set; } + private string instantiationInfo; + + public string InstantiationInfo + { + get => instantiationInfo; + set => instantiationInfo = abbreviateInstantiationInfo(value); + } + + private string abbreviateInstantiationInfo(string value) + { + // exclude version onwards, matching only on namespace and type. + // this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old. + return string.Join(',', value.Split(',').Take(2)); + } [JsonIgnore] public bool Available { get; set; } @@ -50,6 +64,6 @@ namespace osu.Game.Rulesets } } - public override string ToString() => $"{Name} ({ShortName}) ID: {ID}"; + public override string ToString() => Name ?? $"{Name} ({ShortName}) ID: {ID}"; } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index a389d4ff75..58a2ba056e 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -6,7 +6,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using osu.Framework; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Game.Database; namespace osu.Game.Rulesets @@ -17,16 +19,24 @@ namespace osu.Game.Rulesets private readonly Dictionary loadedAssemblies = new Dictionary(); - public RulesetStore(IDatabaseContextFactory factory) + private readonly Storage rulesetStorage; + + public RulesetStore(IDatabaseContextFactory factory, Storage storage = null) : base(factory) { + rulesetStorage = storage?.GetStorageForDirectory("rulesets"); + // On android in release configuration assemblies are loaded from the apk directly into memory. // We cannot read assemblies from cwd, so should check loaded assemblies instead. loadFromAppDomain(); loadFromDisk(); - addMissingRulesets(); - AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetAssembly; + // the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory. + // It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail + // to load as unable to locate the game core assembly. + AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetDependencyAssembly; + loadUserRulesets(); + addMissingRulesets(); } /// @@ -48,7 +58,21 @@ namespace osu.Game.Rulesets /// public IEnumerable AvailableRulesets { get; private set; } - private Assembly resolveRulesetAssembly(object sender, ResolveEventArgs args) => loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == args.Name); + private Assembly resolveRulesetDependencyAssembly(object sender, ResolveEventArgs args) + { + var asm = new AssemblyName(args.Name); + + // the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies. + // this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name + // already loaded in the AppDomain. + foreach (var curAsm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.Name.Equals(curAsm.GetName().Name, StringComparison.Ordinal)) + return curAsm; + } + + return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName); + } private void addMissingRulesets() { @@ -58,7 +82,7 @@ namespace osu.Game.Rulesets var instances = loadedAssemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r)).ToList(); - //add all legacy rulesets first to ensure they have exclusive choice of primary key. + // add all legacy rulesets first to ensure they have exclusive choice of primary key. foreach (var r in instances.Where(r => r is ILegacyRuleset)) { if (context.RulesetInfo.SingleOrDefault(dbRuleset => dbRuleset.ID == r.RulesetInfo.ID) == null) @@ -67,27 +91,23 @@ namespace osu.Game.Rulesets context.SaveChanges(); - //add any other modes + // add any other modes foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) { - if (context.RulesetInfo.FirstOrDefault(ri => ri.InstantiationInfo == r.RulesetInfo.InstantiationInfo) == null) + // todo: StartsWith can be changed to Equals on 2020-11-08 + // This is to give users enough time to have their database use new abbreviated info). + if (context.RulesetInfo.FirstOrDefault(ri => ri.InstantiationInfo.StartsWith(r.RulesetInfo.InstantiationInfo)) == null) context.RulesetInfo.Add(r.RulesetInfo); } context.SaveChanges(); - //perform a consistency check + // perform a consistency check foreach (var r in context.RulesetInfo) { try { - var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo, asm => - { - // for the time being, let's ignore the version being loaded. - // this allows for debug builds to successfully load rulesets (even though debug rulesets have a 0.0.0 version). - asm.Version = null; - return Assembly.Load(asm); - }, null))).RulesetInfo; + var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo))).RulesetInfo; r.Name = instanceInfo.Name; r.ShortName = instanceInfo.ShortName; @@ -120,18 +140,28 @@ namespace osu.Game.Rulesets } } + private void loadUserRulesets() + { + if (rulesetStorage == null) return; + + var rulesets = rulesetStorage.GetFiles(".", $"{ruleset_library_prefix}.*.dll"); + + foreach (var ruleset in rulesets.Where(f => !f.Contains("Tests"))) + loadRulesetFromFile(rulesetStorage.GetFullPath(ruleset)); + } + private void loadFromDisk() { try { - string[] files = Directory.GetFiles(Environment.CurrentDirectory, $"{ruleset_library_prefix}.*.dll"); + var files = Directory.GetFiles(RuntimeInfo.StartupDirectory, $"{ruleset_library_prefix}.*.dll"); foreach (string file in files.Where(f => !Path.GetFileName(f).Contains("Tests"))) loadRulesetFromFile(file); } catch (Exception e) { - Logger.Error(e, $"Could not load rulesets from directory {Environment.CurrentDirectory}"); + Logger.Error(e, $"Could not load rulesets from directory {RuntimeInfo.StartupDirectory}"); } } @@ -175,7 +205,7 @@ namespace osu.Game.Rulesets protected virtual void Dispose(bool disposing) { - AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetAssembly; + AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } } } diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index fffcbb3c9f..130907b242 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; +using osu.Game.Utils; namespace osu.Game.Rulesets.Scoring { @@ -42,32 +44,40 @@ namespace osu.Game.Rulesets.Scoring private double gameplayEndTime; private readonly double drainStartTime; + private readonly double drainLenience; private readonly List<(double time, double health)> healthIncreases = new List<(double, double)>(); private double targetMinimumHealth; private double drainRate = 1; + private PeriodTracker noDrainPeriodTracker; + /// /// Creates a new . /// /// The time after which draining should begin. - public DrainingHealthProcessor(double drainStartTime) + /// A lenience to apply to the default drain rate.
+ /// A value of 0 uses the default drain rate.
+ /// A value of 0.5 halves the drain rate.
+ /// A value of 1 completely removes drain. + public DrainingHealthProcessor(double drainStartTime, double drainLenience = 0) { this.drainStartTime = drainStartTime; + this.drainLenience = drainLenience; } protected override void Update() { base.Update(); - if (!IsBreakTime.Value) - { - // When jumping in and out of gameplay time within a single frame, health should only be drained for the period within the gameplay time - double lastGameplayTime = Math.Clamp(Time.Current - Time.Elapsed, drainStartTime, gameplayEndTime); - double currentGameplayTime = Math.Clamp(Time.Current, drainStartTime, gameplayEndTime); + if (noDrainPeriodTracker?.IsInAny(Time.Current) == true) + return; - Health.Value -= drainRate * (currentGameplayTime - lastGameplayTime); - } + // When jumping in and out of gameplay time within a single frame, health should only be drained for the period within the gameplay time + double lastGameplayTime = Math.Clamp(Time.Current - Time.Elapsed, drainStartTime, gameplayEndTime); + double currentGameplayTime = Math.Clamp(Time.Current, drainStartTime, gameplayEndTime); + + Health.Value -= drainRate * (currentGameplayTime - lastGameplayTime); } public override void ApplyBeatmap(IBeatmap beatmap) @@ -77,15 +87,36 @@ namespace osu.Game.Rulesets.Scoring if (beatmap.HitObjects.Count > 0) gameplayEndTime = beatmap.HitObjects[^1].GetEndTime(); + noDrainPeriodTracker = new PeriodTracker(beatmap.Breaks.Select(breakPeriod => new Period( + beatmap.HitObjects + .Select(hitObject => hitObject.GetEndTime()) + .Where(endTime => endTime <= breakPeriod.StartTime) + .DefaultIfEmpty(double.MinValue) + .Last(), + beatmap.HitObjects + .Select(hitObject => hitObject.StartTime) + .Where(startTime => startTime >= breakPeriod.EndTime) + .DefaultIfEmpty(double.MaxValue) + .First() + ))); + targetMinimumHealth = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, min_health_target, mid_health_target, max_health_target); + // Add back a portion of the amount of HP to be drained, depending on the lenience requested. + targetMinimumHealth += drainLenience * (1 - targetMinimumHealth); + + // Ensure the target HP is within an acceptable range. + targetMinimumHealth = Math.Clamp(targetMinimumHealth, 0, 1); + base.ApplyBeatmap(beatmap); } protected override void ApplyResultInternal(JudgementResult result) { base.ApplyResultInternal(result); - healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); + + if (!result.Judgement.IsBonus) + healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); } protected override void Reset(bool storeResults) diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 0c6b3f67b4..1535fe4d00 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -3,7 +3,7 @@ using System; using osu.Framework.Bindables; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Scoring @@ -26,11 +26,6 @@ namespace osu.Game.Rulesets.Scoring ///
public readonly BindableDouble Health = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; - /// - /// Whether gameplay is currently in a break. - /// - public readonly IBindable IsBreakTime = new Bindable(); - /// /// Whether this ScoreProcessor has already triggered the failed state. /// diff --git a/osu.Game/Rulesets/Scoring/HitEvent.cs b/osu.Game/Rulesets/Scoring/HitEvent.cs new file mode 100644 index 0000000000..0ebbec62ba --- /dev/null +++ b/osu.Game/Rulesets/Scoring/HitEvent.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 JetBrains.Annotations; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// A generated by the containing extra statistics around a . + /// + public readonly struct HitEvent + { + /// + /// The time offset from the end of at which the event occurred. + /// + public readonly double TimeOffset; + + /// + /// The hit result. + /// + public readonly HitResult Result; + + /// + /// The on which the result occurred. + /// + public readonly HitObject HitObject; + + /// + /// The occurring prior to . + /// + [CanBeNull] + public readonly HitObject LastHitObject; + + /// + /// A position, if available, at the time of the event. + /// + [CanBeNull] + public readonly Vector2? Position; + + /// + /// Creates a new . + /// + /// The time offset from the end of at which the event occurs. + /// The . + /// The that triggered the event. + /// The previous . + /// A position corresponding to the event. + public HitEvent(double timeOffset, HitResult result, HitObject hitObject, [CanBeNull] HitObject lastHitObject, [CanBeNull] Vector2? position) + { + TimeOffset = timeOffset; + Result = result; + HitObject = hitObject; + LastHitObject = lastHitObject; + Position = position; + } + + /// + /// Creates a new with an optional positional offset. + /// + /// The positional offset. + /// The new . + public HitEvent With(Vector2? positionOffset) => new HitEvent(TimeOffset, Result, HitObject, LastHitObject, positionOffset); + } +} diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 7ba88d3df8..b057af2a50 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -43,5 +43,25 @@ namespace osu.Game.Rulesets.Scoring ///
[Description(@"Perfect")] Perfect, + + /// + /// Indicates small tick miss. + /// + SmallTickMiss, + + /// + /// Indicates a small tick hit. + /// + SmallTickHit, + + /// + /// Indicates a large tick miss. + /// + LargeTickMiss, + + /// + /// Indicates a large tick hit. + /// + LargeTickHit } } diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 3016007f98..8aef615b5f 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Game.Beatmaps; @@ -12,11 +13,6 @@ namespace osu.Game.Rulesets.Scoring { public abstract class JudgementProcessor : Component { - /// - /// Invoked when all s have been judged by this . - /// - public event Action AllJudged; - /// /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by this . /// @@ -32,10 +28,12 @@ namespace osu.Game.Rulesets.Scoring ///
public int JudgedHits { get; private set; } + private readonly BindableBool hasCompleted = new BindableBool(); + /// /// Whether all s have been processed. /// - public bool HasCompleted => JudgedHits == MaxHits; + public IBindable HasCompleted => hasCompleted; /// /// Applies a to this . @@ -60,8 +58,7 @@ namespace osu.Game.Rulesets.Scoring NewJudgement?.Invoke(result); - if (HasCompleted) - AllJudged?.Invoke(); + updateHasCompleted(); } /// @@ -72,6 +69,8 @@ namespace osu.Game.Rulesets.Scoring { JudgedHits--; + updateHasCompleted(); + RevertResultInternal(result); } @@ -125,8 +124,6 @@ namespace osu.Game.Rulesets.Scoring simulate(nested); var judgement = obj.CreateJudgement(); - if (judgement == null) - return; var result = CreateResult(obj, judgement); if (result == null) @@ -136,5 +133,7 @@ namespace osu.Game.Rulesets.Scoring ApplyResult(result); } } + + private void updateHasCompleted() => hasCompleted.Value = JudgedHits == MaxHits; } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 8ccc2af93b..f1cdfd93c8 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -7,16 +7,16 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public class ScoreProcessor : JudgementProcessor { - private const double base_portion = 0.3; - private const double combo_portion = 0.7; private const double max_score = 1000000; /// @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Scoring public readonly Bindable Rank = new Bindable(ScoreRank.X); /// - /// THe highest combo achieved by this score. + /// The highest combo achieved by this score. /// public readonly BindableInt HighestCombo = new BindableInt(); @@ -54,18 +54,37 @@ namespace osu.Game.Rulesets.Scoring /// public readonly Bindable Mode = new Bindable(); - private double maxHighestCombo; + /// + /// The default portion of awarded for hitting s accurately. Defaults to 30%. + /// + protected virtual double DefaultAccuracyPortion => 0.3; + /// + /// The default portion of awarded for achieving a high combo. Default to 70%. + /// + protected virtual double DefaultComboPortion => 0.7; + + private readonly double accuracyPortion; + private readonly double comboPortion; + + private double maxHighestCombo; private double maxBaseScore; private double rollingMaxBaseScore; private double baseScore; private double bonusScore; + private readonly List hitEvents = new List(); + private HitObject lastHitObject; + private double scoreMultiplier = 1; public ScoreProcessor() { - Debug.Assert(base_portion + combo_portion == 1.0); + accuracyPortion = DefaultAccuracyPortion; + comboPortion = DefaultComboPortion; + + if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) + throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => @@ -128,9 +147,20 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore += result.Judgement.MaxNumericResult; } + hitEvents.Add(CreateHitEvent(result)); + lastHitObject = result.HitObject; + updateScore(); } + /// + /// Creates the that describes a . + /// + /// The to describe. + /// The . + protected virtual HitEvent CreateHitEvent(JudgementResult result) + => new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null); + protected sealed override void RevertResultInternal(JudgementResult result) { Combo.Value = result.ComboAtJudgement; @@ -153,6 +183,10 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } + Debug.Assert(hitEvents.Count > 0); + lastHitObject = hitEvents[^1].LastHitObject; + hitEvents.RemoveAt(hitEvents.Count - 1); + updateScore(); } @@ -170,11 +204,14 @@ namespace osu.Game.Rulesets.Scoring { default: case ScoringMode.Standardised: - return (max_score * (base_portion * baseScore / maxBaseScore + combo_portion * HighestCombo.Value / maxHighestCombo) + bonusScore) * scoreMultiplier; + double accuracyScore = accuracyPortion * baseScore / maxBaseScore; + double comboScore = comboPortion * HighestCombo.Value / maxHighestCombo; + + return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; case ScoringMode.Classic: // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) - return bonusScore + baseScore * ((1 + Math.Max(0, HighestCombo.Value - 1) * scoreMultiplier) / 25); + return bonusScore + baseScore * (1 + Math.Max(0, HighestCombo.Value - 1) * scoreMultiplier / 25); } } @@ -207,6 +244,8 @@ namespace osu.Game.Rulesets.Scoring base.Reset(storeResults); scoreResultCounts.Clear(); + hitEvents.Clear(); + lastHitObject = null; if (storeResults) { @@ -231,6 +270,12 @@ namespace osu.Game.Rulesets.Scoring HighestCombo.Value = 0; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + hitEvents.Clear(); + } + /// /// Retrieve a score populated with data for the current play this processor is responsible for. /// @@ -247,6 +292,8 @@ namespace osu.Game.Rulesets.Scoring foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) score.Statistics[result] = GetStatistic(result); + + score.HitEvents = hitEvents; } /// diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index e624fb80fa..fbb9acfe90 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -11,20 +11,13 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Framework.IO.Stores; using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Input.Handlers; @@ -59,22 +52,18 @@ namespace osu.Game.Rulesets.UI /// public PassThroughInputManager KeyBindingInputManager; - public override double GameplayStartTime => Objects.First().StartTime - 2000; + public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; private readonly Lazy playfield; - private TextureStore textureStore; - - private ISampleStore localSampleStore; - /// /// The playfield. /// public override Playfield Playfield => playfield.Value; - private Container overlays; + public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; - public override Container Overlays => overlays; + public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override GameplayClock FrameStableClock => frameStabilityContainer.GameplayClock; @@ -107,12 +96,14 @@ namespace osu.Game.Rulesets.UI /// The mods which are to be applied. /// [Cached(typeof(IReadOnlyList))] - private readonly IReadOnlyList mods; + protected readonly IReadOnlyList Mods; private FrameStabilityContainer frameStabilityContainer; private OnScreenDisplay onScreenDisplay; + private DrawableRulesetDependencies dependencies; + /// /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// @@ -129,7 +120,7 @@ namespace osu.Game.Rulesets.UI throw new ArgumentException($"{GetType()} expected the beatmap to contain hitobjects of type {typeof(TObject)}.", nameof(beatmap)); Beatmap = tBeatmap; - this.mods = mods?.ToArray() ?? Array.Empty(); + Mods = mods?.ToArray() ?? Array.Empty(); RelativeSizeAxes = Axes.Both; @@ -147,29 +138,13 @@ namespace osu.Game.Rulesets.UI protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies = new DrawableRulesetDependencies(Ruleset, base.CreateChildDependencies(parent)); - var resources = Ruleset.CreateResourceStore(); - - if (resources != null) - { - textureStore = new TextureStore(new TextureLoaderStore(new NamespacedResourceStore(resources, "Textures"))); - textureStore.AddStore(dependencies.Get()); - dependencies.Cache(textureStore); - - localSampleStore = dependencies.Get().GetSampleStore(new NamespacedResourceStore(resources, "Samples")); - dependencies.CacheAs(new FallbackSampleStore(localSampleStore, dependencies.Get())); - } + Config = dependencies.RulesetConfigManager; onScreenDisplay = dependencies.Get(); - - Config = dependencies.Get().GetConfigFor(Ruleset); - if (Config != null) - { - dependencies.Cache(Config); onScreenDisplay?.BeginTracking(this, Config); - } return dependencies; } @@ -186,11 +161,12 @@ namespace osu.Game.Rulesets.UI FrameStablePlayback = FrameStablePlayback, Children = new Drawable[] { + FrameStableComponents, KeyBindingInputManager .WithChild(CreatePlayfieldAdjustmentContainer() .WithChild(Playfield) ), - overlays = new Container { RelativeSizeAxes = Axes.Both } + Overlays, } }, }; @@ -202,7 +178,7 @@ namespace osu.Game.Rulesets.UI .WithChild(ResumeOverlay))); } - applyRulesetMods(mods, config); + applyRulesetMods(Mods, config); loadObjects(cancellationToken); } @@ -222,7 +198,7 @@ namespace osu.Game.Rulesets.UI Playfield.PostProcess(); - foreach (var mod in mods.OfType()) + foreach (var mod in Mods.OfType()) mod.ApplyToDrawableHitObjects(Playfield.AllHitObjects); } @@ -261,6 +237,21 @@ namespace osu.Game.Rulesets.UI Playfield.Add(drawableObject); } + public override void SetRecordTarget(Replay recordingReplay) + { + if (!(KeyBindingInputManager is IHasRecordingHandler recordingInputManager)) + throw new InvalidOperationException($"A {nameof(KeyBindingInputManager)} which supports recording is not available"); + + var recorder = CreateReplayRecorder(recordingReplay); + + if (recorder == null) + return; + + recorder.ScreenSpaceToGamefield = Playfield.ScreenSpaceToGamefield; + + recordingInputManager.Recorder = recorder; + } + public override void SetReplayScore(Score replayScore) { if (!(KeyBindingInputManager is IHasReplayHandler replayInputManager)) @@ -301,6 +292,8 @@ namespace osu.Game.Rulesets.UI protected virtual ReplayInputHandler CreateReplayInputHandler(Replay replay) => null; + protected virtual ReplayRecorder CreateReplayRecorder(Replay replay) => null; + /// /// Creates a Playfield. /// @@ -343,13 +336,14 @@ namespace osu.Game.Rulesets.UI { base.Dispose(isDisposing); - localSampleStore?.Dispose(); - if (Config != null) { onScreenDisplay?.StopTracking(this, Config); Config = null; } + + // Dispose the components created by this dependency container. + dependencies?.Dispose(); } } @@ -388,10 +382,15 @@ namespace osu.Game.Rulesets.UI public abstract Playfield Playfield { get; } /// - /// Place to put drawables above hit objects but below UI. + /// Content to be placed above hitobjects. Will be affected by frame stability. /// public abstract Container Overlays { get; } + /// + /// Components to be run potentially multiple times in line with frame-stable gameplay. + /// + public abstract Container FrameStableComponents { get; } + /// /// The frame-stable clock which is being used for playfield display. /// @@ -463,12 +462,23 @@ namespace osu.Game.Rulesets.UI protected virtual ResumeOverlay CreateResumeOverlay() => null; + /// + /// Whether to display gameplay overlays, such as and . + /// + public virtual bool AllowGameplayOverlays => true; + /// /// Sets a replay to be used, overriding local input. /// /// The replay, null for local input. public abstract void SetReplayScore(Score replayScore); + /// + /// Sets a replay to be used to record gameplay. + /// + /// The target to be recorded to. + public abstract void SetRecordTarget(Replay recordingReplay); + /// /// Invoked when the interactive user requests resuming from a paused state. /// Allows potentially delaying the resume process until an interaction is performed. @@ -489,62 +499,4 @@ namespace osu.Game.Rulesets.UI { } } - - /// - /// A sample store which adds a fallback source. - /// - /// - /// This is a temporary implementation to workaround ISampleStore limitations. - /// - public class FallbackSampleStore : ISampleStore - { - private readonly ISampleStore primary; - private readonly ISampleStore secondary; - - public FallbackSampleStore(ISampleStore primary, ISampleStore secondary) - { - this.primary = primary; - this.secondary = secondary; - } - - public SampleChannel Get(string name) => primary.Get(name) ?? secondary.Get(name); - - public Task GetAsync(string name) => primary.GetAsync(name) ?? secondary.GetAsync(name); - - public Stream GetStream(string name) => primary.GetStream(name) ?? secondary.GetStream(name); - - public IEnumerable GetAvailableResources() => throw new NotSupportedException(); - - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); - - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); - - public BindableNumber Volume => throw new NotSupportedException(); - - public BindableNumber Balance => throw new NotSupportedException(); - - public BindableNumber Frequency => throw new NotSupportedException(); - - public BindableNumber Tempo => throw new NotSupportedException(); - - public IBindable GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); - - public IBindable AggregateVolume => throw new NotSupportedException(); - - public IBindable AggregateBalance => throw new NotSupportedException(); - - public IBindable AggregateFrequency => throw new NotSupportedException(); - - public IBindable AggregateTempo => throw new NotSupportedException(); - - public int PlaybackConcurrency - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public void Dispose() - { - } - } } diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs new file mode 100644 index 0000000000..168e937256 --- /dev/null +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Rulesets.Configuration; + +namespace osu.Game.Rulesets.UI +{ + public class DrawableRulesetDependencies : DependencyContainer, IDisposable + { + /// + /// The texture store to be used for the ruleset. + /// + 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. + /// + public ISampleStore SampleStore { get; } + + /// + /// The ruleset config manager. + /// + public IRulesetConfigManager RulesetConfigManager { get; private set; } + + public DrawableRulesetDependencies(Ruleset ruleset, IReadOnlyDependencyContainer parent) + : base(parent) + { + var resources = ruleset.CreateResourceStore(); + + if (resources != null) + { + TextureStore = new TextureStore(new TextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + TextureStore.AddStore(parent.Get()); + Cache(TextureStore); + + SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); + SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + CacheAs(new FallbackSampleStore(SampleStore, parent.Get())); + } + + RulesetConfigManager = parent.Get().GetConfigFor(ruleset); + if (RulesetConfigManager != null) + Cache(RulesetConfigManager); + } + + #region Disposal + + ~DrawableRulesetDependencies() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private bool isDisposed; + + protected void Dispose(bool disposing) + { + if (isDisposed) + return; + + isDisposed = true; + + SampleStore?.Dispose(); + RulesetConfigManager = null; + } + + #endregion + } + + /// + /// A sample store which adds a fallback source. + /// + /// + /// This is a temporary implementation to workaround ISampleStore limitations. + /// + public class FallbackSampleStore : ISampleStore + { + private readonly ISampleStore primary; + private readonly ISampleStore secondary; + + public FallbackSampleStore(ISampleStore primary, ISampleStore secondary) + { + this.primary = primary; + this.secondary = secondary; + } + + public SampleChannel Get(string name) => primary.Get(name) ?? secondary.Get(name); + + public Task GetAsync(string name) => primary.GetAsync(name) ?? secondary.GetAsync(name); + + public Stream GetStream(string name) => primary.GetStream(name) ?? secondary.GetStream(name); + + public IEnumerable GetAvailableResources() => throw new NotSupportedException(); + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + + public BindableNumber Volume => throw new NotSupportedException(); + + public BindableNumber Balance => throw new NotSupportedException(); + + public BindableNumber Frequency => throw new NotSupportedException(); + + public BindableNumber Tempo => throw new NotSupportedException(); + + public IBindable GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); + + public IBindable AggregateVolume => throw new NotSupportedException(); + + public IBindable AggregateBalance => throw new NotSupportedException(); + + public IBindable AggregateFrequency => throw new NotSupportedException(); + + public IBindable AggregateTempo => throw new NotSupportedException(); + + public int PlaybackConcurrency + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public void Dispose() + { + } + } +} diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index e569bb8459..d574991fa0 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.UI { /// /// A container which consumes a parent gameplay clock and standardises frame counts for children. - /// Will ensure a minimum of 40 frames per clock second is maintained, regardless of any system lag or seeks. + /// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks. /// public class FrameStabilityContainer : Container, IHasReplayHandler { @@ -29,14 +29,16 @@ namespace osu.Game.Rulesets.UI /// internal bool FrameStablePlayback = true; - [Cached] - public GameplayClock GameplayClock { get; } + public GameplayClock GameplayClock => stabilityGameplayClock; + + [Cached(typeof(GameplayClock))] + private readonly StabilityGameplayClock stabilityGameplayClock; public FrameStabilityContainer(double gameplayStartTime = double.MinValue) { RelativeSizeAxes = Axes.Both; - GameplayClock = new GameplayClock(framedClock = new FramedClock(manualClock = new ManualClock())); + stabilityGameplayClock = new StabilityGameplayClock(framedClock = new FramedClock(manualClock = new ManualClock())); this.gameplayStartTime = gameplayStartTime; } @@ -57,7 +59,7 @@ namespace osu.Game.Rulesets.UI { if (clock != null) { - parentGameplayClock = clock; + stabilityGameplayClock.ParentGameplayClock = parentGameplayClock = clock; GameplayClock.IsPaused.BindTo(clock.IsPaused); } } @@ -179,13 +181,24 @@ namespace osu.Game.Rulesets.UI private void setClock() { // in case a parent gameplay clock isn't available, just use the parent clock. - if (parentGameplayClock == null) - parentGameplayClock = Clock; + parentGameplayClock ??= Clock; Clock = GameplayClock; ProcessCustomClock = false; } public ReplayInputHandler ReplayInputHandler { get; set; } + + private class StabilityGameplayClock : GameplayClock + { + public IFrameBasedClock ParentGameplayClock; + + public StabilityGameplayClock(FramedClock underlyingClock) + : base(underlyingClock) + { + } + + public override bool IsSeeking => ParentGameplayClock != null && Math.Abs(CurrentTime - ParentGameplayClock.CurrentTime) > 200; + } } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index dea981c3ad..f4f66f1272 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -43,6 +43,15 @@ namespace osu.Game.Rulesets.UI return true; } + public virtual void Clear(bool disposeChildren = true) + { + ClearInternal(disposeChildren); + + foreach (var kvp in startTimeMap) + kvp.Value.bindable.UnbindAll(); + startTimeMap.Clear(); + } + public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject); private void onStartTimeChanged(DrawableHitObject hitObject) diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 945dbe4cc9..8ea6c74349 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; using osu.Framework.Bindables; @@ -20,30 +21,33 @@ namespace osu.Game.Rulesets.UI public readonly BindableBool Selected = new BindableBool(); private readonly SpriteIcon modIcon; + private readonly SpriteText modAcronym; private readonly SpriteIcon background; private const float size = 80; - public IconUsage Icon - { - get => modIcon.Icon; - set => modIcon.Icon = value; - } - private readonly ModType type; - public virtual string TooltipText { get; } + public virtual string TooltipText => mod.IconTooltip; - protected Mod Mod { get; private set; } + private Mod mod; + + public Mod Mod + { + get => mod; + set + { + mod = value; + updateMod(value); + } + } public ModIcon(Mod mod) { - Mod = mod ?? throw new ArgumentNullException(nameof(mod)); + this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); type = mod.Type; - TooltipText = mod.Name; - Size = new Vector2(size); Children = new Drawable[] @@ -56,15 +60,43 @@ namespace osu.Game.Rulesets.UI Icon = OsuIcon.ModBg, Shadow = true, }, + modAcronym = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = OsuColour.Gray(84), + Alpha = 0, + Font = OsuFont.Numeric.With(null, 22f), + UseFullGlyphHeight = false, + Text = mod.Acronym + }, modIcon = new SpriteIcon { Origin = Anchor.Centre, Anchor = Anchor.Centre, Colour = OsuColour.Gray(84), - Size = new Vector2(size - 35), - Icon = mod.Icon + Size = new Vector2(45), + Icon = FontAwesome.Solid.Question }, }; + + updateMod(mod); + } + + private void updateMod(Mod value) + { + modAcronym.Text = value.Acronym; + modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question; + + if (value.Icon is null) + { + modIcon.FadeOut(); + modAcronym.FadeIn(); + return; + } + + modIcon.FadeIn(); + modAcronym.FadeOut(); } private Color4 backgroundColour; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 047047ccfd..c52183f3f2 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -10,7 +10,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osuTK; @@ -30,6 +29,11 @@ namespace osu.Game.Rulesets.UI /// public Func GamefieldToScreenSpace => HitObjectContainer.ToScreenSpace; + /// + /// A function that converts screen space coordinates to gamefield. + /// + public Func ScreenSpaceToGamefield => HitObjectContainer.ToLocalSpace; + /// /// All the s contained in this and all . /// @@ -57,10 +61,7 @@ namespace osu.Game.Rulesets.UI hitObjectContainerLazy = new Lazy(CreateHitObjectContainer); } - [Resolved] - private IBindable beatmap { get; set; } - - [Resolved] + [Resolved(CanBeNull = true)] private IReadOnlyList mods { get; set; } [BackgroundDependencyLoader] @@ -132,7 +133,7 @@ namespace osu.Game.Rulesets.UI { base.Update(); - if (beatmap != null) + if (mods != null) { foreach (var mod in mods) { diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs new file mode 100644 index 0000000000..c977639584 --- /dev/null +++ b/osu.Game/Rulesets/UI/ReplayRecorder.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.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.UI +{ + public abstract class ReplayRecorder : ReplayRecorder, IKeyBindingHandler + where T : struct + { + private readonly Replay target; + + private readonly List pressedActions = new List(); + + private InputManager inputManager; + + public int RecordFrameRate = 60; + + protected ReplayRecorder(Replay target) + { + this.target = target; + + RelativeSizeAxes = Axes.Both; + + Depth = float.MinValue; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + recordFrame(false); + return base.OnMouseMove(e); + } + + public bool OnPressed(T action) + { + pressedActions.Add(action); + recordFrame(true); + return false; + } + + public void OnReleased(T action) + { + pressedActions.Remove(action); + recordFrame(true); + } + + private void recordFrame(bool important) + { + var last = target.Frames.LastOrDefault(); + + if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate)) + return; + + var position = ScreenSpaceToGamefield?.Invoke(inputManager.CurrentState.Mouse.Position) ?? inputManager.CurrentState.Mouse.Position; + + var frame = HandleFrame(position, pressedActions, last); + + if (frame != null) + target.Frames.Add(frame); + } + + protected abstract ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame); + } + + public abstract class ReplayRecorder : Component + { + public Func ScreenSpaceToGamefield; + } +} diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 5cc213be41..f2ac61eaf4 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,21 +18,29 @@ using osu.Game.Input.Handlers; using osu.Game.Screens.Play; using osuTK.Input; using static osu.Game.Input.Handlers.ReplayInputHandler; -using JoystickState = osu.Framework.Input.States.JoystickState; -using KeyboardState = osu.Framework.Input.States.KeyboardState; -using MouseState = osu.Framework.Input.States.MouseState; namespace osu.Game.Rulesets.UI { - public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler + public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler where T : struct { - protected override InputState CreateInitialState() + private ReplayRecorder recorder; + + public ReplayRecorder Recorder { - var state = base.CreateInitialState(); - return new RulesetInputManagerInputState(state.Mouse, state.Keyboard, state.Joystick); + set + { + if (recorder != null) + throw new InvalidOperationException("Cannot attach more than one recorder"); + + recorder = value; + + KeyBindingContainer.Add(recorder); + } } + protected override InputState CreateInitialState() => new RulesetInputManagerInputState(base.CreateInitialState()); + protected readonly KeyBindingContainer KeyBindingContainer; protected override Container Content => content; @@ -139,12 +148,16 @@ namespace osu.Game.Rulesets.UI public bool OnPressed(T action) => Target.Children.OfType>().Any(c => c.OnPressed(action, Clock.Rate >= 0)); - public bool OnReleased(T action) => Target.Children.OfType>().Any(c => c.OnReleased(action, Clock.Rate >= 0)); + public void OnReleased(T action) + { + foreach (var c in Target.Children.OfType>()) + c.OnReleased(action, Clock.Rate >= 0); + } } #endregion - protected virtual RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + protected virtual KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new RulesetKeyBindingContainer(ruleset, variant, unique); public class RulesetKeyBindingContainer : DatabasedKeyBindingContainer @@ -164,6 +177,11 @@ namespace osu.Game.Rulesets.UI ReplayInputHandler ReplayInputHandler { get; set; } } + public interface IHasRecordingHandler + { + public ReplayRecorder Recorder { set; } + } + /// /// Supports attaching a . /// Keys will be populated automatically and a receptor will be injected inside. @@ -178,8 +196,8 @@ namespace osu.Game.Rulesets.UI { public ReplayState LastReplayState; - public RulesetInputManagerInputState(MouseState mouse = null, KeyboardState keyboard = null, JoystickState joystick = null) - : base(mouse, keyboard, joystick) + public RulesetInputManagerInputState(InputState state = null) + : base(state) { } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs index 75ea3efdf2..0d4283e319 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs @@ -5,7 +5,11 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class ConstantScrollAlgorithm : IScrollAlgorithm { - public double GetDisplayStartTime(double time, double timeRange) => time - timeRange; + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + { + var adjustedTime = TimeAt(-offset, originTime, timeRange, scrollLength); + return adjustedTime - timeRange; + } public float GetLength(double startTime, double endTime, 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 5f053975c7..c394a05bcc 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs @@ -6,15 +6,33 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms public interface IScrollAlgorithm { /// - /// Given a point in time, computes the time at which it enters the time range. + /// Given a point in time associated with an object's origin + /// and the spatial distance between the edge and the origin of the object along the scrolling axis, + /// computes the time at which the object initially enters the time range. /// - /// - /// E.g. For a constant time range of 5000ms, the time at which t=7000ms enters the time range is 2000ms. - /// - /// The point in time. + /// + /// Let's assume the following parameters: + /// + /// = 7000ms, + /// = 100px, + /// = 5000ms, + /// = 1000px + /// + /// and a constant scrolling rate. + /// To arrive at the end of the scrolling container, the object's origin has to cover + /// 1000 + 100 = 1100px + /// so that the edge starts at the end of the scrolling container. + /// One scroll length of 1000px covers 5000ms of time, so the time required to cover 1100px is equal to + /// 5000 * (1100 / 1000) = 5500ms, + /// and therefore the object should start being visible at + /// 7000 - 5500 = 1500ms. + /// + /// The time point at which the object origin should enter the time range. + /// The spatial distance between the object's edge and its origin along the scrolling axis. /// The amount of visible time. - /// The time at which enters . - double GetDisplayStartTime(double time, double timeRange); + /// The absolute spatial length through . + /// The time at which the object should enter the time range. + double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength); /// /// Computes the spatial length within a start and end time. diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index fe22a86fad..7b827e0c63 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -20,11 +20,12 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms searchPoint = new MultiplierControlPoint(); } - public double GetDisplayStartTime(double time, double timeRange) + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) { + var controlPoint = controlPointAt(originTime); // The total amount of time that the hitobject will remain visible within the timeRange, which decreases as the speed multiplier increases - double visibleDuration = timeRange / controlPointAt(time).Multiplier; - return time - visibleDuration; + double visibleDuration = (scrollLength + offset) * timeRange / controlPoint.Multiplier / scrollLength; + return originTime - visibleDuration; } public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index 3c9a205412..a1f68d7201 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -3,76 +3,60 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using JetBrains.Annotations; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class SequentialScrollAlgorithm : IScrollAlgorithm { - private readonly Dictionary positionCache; + private static readonly IComparer by_position_comparer = Comparer.Create((c1, c2) => c1.Position.CompareTo(c2.Position)); private readonly IReadOnlyList controlPoints; + /// + /// Stores a mapping of time -> position for each control point. + /// + private readonly List positionMappings = new List(); + public SequentialScrollAlgorithm(IReadOnlyList controlPoints) { this.controlPoints = controlPoints; - - positionCache = new Dictionary(); } - public double GetDisplayStartTime(double time, double timeRange) => time - timeRange - 1000; + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + { + return TimeAt(-(scrollLength + offset), originTime, timeRange, scrollLength); + } public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) { - var objectLength = relativePositionAtCached(endTime, timeRange) - relativePositionAtCached(startTime, timeRange); + var objectLength = relativePositionAt(endTime, timeRange) - relativePositionAt(startTime, timeRange); return (float)(objectLength * scrollLength); } public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) { - // Caching is not used here as currentTime is unlikely to have been previously cached - double timelinePosition = relativePositionAt(currentTime, timeRange); - return (float)((relativePositionAtCached(time, timeRange) - timelinePosition) * scrollLength); + double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); + return (float)(timelineLength * scrollLength); } public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { - // Convert the position to a length relative to time = 0 - double length = position / scrollLength + relativePositionAt(currentTime, timeRange); + if (controlPoints.Count == 0) + return position * timeRange; - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) - { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // Find the position at the current time, and the given length. + double relativePosition = relativePositionAt(currentTime, timeRange) + position / scrollLength; - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; + var positionMapping = findControlPointMapping(timeRange, new PositionMapping(0, null, relativePosition), by_position_comparer); - // Figure out the length of control point - var currentLength = currentDuration / timeRange * current.Multiplier; - - if (currentLength > length) - { - // The point is within this control point - return current.StartTime + length * timeRange / current.Multiplier; - } - - length -= currentLength; - } - - return 0; // Should never occur + // Begin at the control point's time and add the remaining time to reach the given position. + return positionMapping.Time + (relativePosition - positionMapping.Position) * timeRange / positionMapping.ControlPoint.Multiplier; } - private double relativePositionAtCached(double time, double timeRange) - { - if (!positionCache.TryGetValue(time, out double existing)) - positionCache[time] = existing = relativePositionAt(time, timeRange); - return existing; - } - - public void Reset() => positionCache.Clear(); + public void Reset() => positionMappings.Clear(); /// /// Finds the position which corresponds to a point in time. @@ -81,37 +65,100 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The time to find the position at. /// The amount of time visualised by the scrolling area. /// A positive value indicating the position at . - private double relativePositionAt(double time, double timeRange) + private double relativePositionAt(in double time, in double timeRange) { if (controlPoints.Count == 0) return time / timeRange; - double length = 0; + var mapping = findControlPointMapping(timeRange, new PositionMapping(time)); - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) + // Begin at the control point's position and add the remaining distance to reach the given time. + return mapping.Position + (time - mapping.Time) / timeRange * mapping.ControlPoint.Multiplier; + } + + /// + /// Finds a 's that is relevant to a given . + /// + /// + /// This is used to find the last occuring prior to a time value, or prior to a position value (if is used). + /// + /// The time range. + /// The to find the closest to. + /// The comparison. If null, the default comparer is used (by time). + /// The 's that is relevant for . + private PositionMapping findControlPointMapping(in double timeRange, in PositionMapping search, IComparer comparer = null) + { + generatePositionMappings(timeRange); + + var mappingIndex = positionMappings.BinarySearch(search, comparer ?? Comparer.Default); + + if (mappingIndex < 0) { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // If the search value isn't found, the _next_ control point is returned, but we actually want the _previous_ control point. + // In doing so, we must make sure to not underflow the position mapping list (i.e. always use the 0th control point for time < first_control_point_time). + mappingIndex = Math.Max(0, ~mappingIndex - 1); - // We don't need to consider any control points beyond the current time, since it will not yet - // affect any hitobjects - if (i > 0 && current.StartTime > time) - continue; - - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; - - // We want to consider the minimal amount of time that this control point has affected, - // which may be either its duration, or the amount of time that has passed within it - var durationInCurrent = Math.Min(currentDuration, time - current.StartTime); - - // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - length += durationInCurrent / timeRange * current.Multiplier; + Debug.Assert(mappingIndex < positionMappings.Count); } - return length; + var mapping = positionMappings[mappingIndex]; + Debug.Assert(mapping.ControlPoint != null); + + return mapping; + } + + /// + /// Generates the mapping of (and their respective start times) to their relative position from 0. + /// + /// The time range. + private void generatePositionMappings(in double timeRange) + { + if (positionMappings.Count > 0) + return; + + if (controlPoints.Count == 0) + return; + + positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + + for (int i = 0; i < controlPoints.Count - 1; i++) + { + var current = controlPoints[i]; + 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); + + positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + } + } + + private readonly struct PositionMapping : IComparable + { + /// + /// The time corresponding to this position. + /// + public readonly double Time; + + /// + /// The at . + /// + [CanBeNull] + public readonly MultiplierControlPoint ControlPoint; + + /// + /// The relative position from 0 of . + /// + public readonly double Position; + + public PositionMapping(double time, MultiplierControlPoint controlPoint = null, double position = default) + { + Time = time; + ControlPoint = controlPoint; + Position = position; + } + + public int CompareTo(PositionMapping other) => Time.CompareTo(other.Time); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index fda1d7c723..0955f32790 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -9,9 +9,11 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Lists; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -74,11 +76,9 @@ namespace osu.Game.Rulesets.UI.Scrolling protected virtual bool RelativeScaleBeatLengths => false; /// - /// Provides the default s that adjust the scrolling rate of s - /// inside this . + /// The s that adjust the scrolling rate of s inside this . /// - /// - private readonly SortedList controlPoints = new SortedList(Comparer.Default); + protected readonly SortedList ControlPoints = new SortedList(Comparer.Default); protected IScrollingInfo ScrollingInfo => scrollingInfo; @@ -95,11 +95,11 @@ namespace osu.Game.Rulesets.UI.Scrolling switch (VisualisationMethod) { case ScrollVisualisationMethod.Sequential: - scrollingInfo.Algorithm = new SequentialScrollAlgorithm(controlPoints); + scrollingInfo.Algorithm = new SequentialScrollAlgorithm(ControlPoints); break; case ScrollVisualisationMethod.Overlapping: - scrollingInfo.Algorithm = new OverlappingScrollAlgorithm(controlPoints); + scrollingInfo.Algorithm = new OverlappingScrollAlgorithm(ControlPoints); break; case ScrollVisualisationMethod.Constant: @@ -168,29 +168,10 @@ namespace osu.Game.Rulesets.UI.Scrolling // Collapse sections with the same start time .GroupBy(s => s.StartTime).Select(g => g.Last()).OrderBy(s => s.StartTime); - controlPoints.AddRange(timingChanges); + ControlPoints.AddRange(timingChanges); - if (controlPoints.Count == 0) - controlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier }); - } - - public bool OnPressed(GlobalAction action) - { - if (!UserScrollSpeedAdjustment) - return false; - - switch (action) - { - case GlobalAction.IncreaseScrollSpeed: - this.TransformBindableTo(TimeRange, TimeRange.Value - time_span_step, 200, Easing.OutQuint); - return true; - - case GlobalAction.DecreaseScrollSpeed: - this.TransformBindableTo(TimeRange, TimeRange.Value + time_span_step, 200, Easing.OutQuint); - return true; - } - - return false; + if (ControlPoints.Count == 0) + ControlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier }); } protected override void LoadComplete() @@ -201,7 +182,44 @@ namespace osu.Game.Rulesets.UI.Scrolling throw new ArgumentException($"{nameof(Playfield)} must be a {nameof(ScrollingPlayfield)} when using {nameof(DrawableScrollingRuleset)}."); } - public bool OnReleased(GlobalAction action) => false; + /// + /// Adjusts the scroll speed of s. + /// + /// The amount to adjust by. Greater than 0 if the scroll speed should be increased, less than 0 if it should be decreased. + protected virtual void AdjustScrollSpeed(int amount) => this.TransformBindableTo(TimeRange, TimeRange.Value - amount * time_span_step, 200, Easing.OutQuint); + + public bool OnPressed(GlobalAction action) + { + if (!UserScrollSpeedAdjustment) + return false; + + switch (action) + { + case GlobalAction.IncreaseScrollSpeed: + scheduleScrollSpeedAdjustment(1); + return true; + + case GlobalAction.DecreaseScrollSpeed: + scheduleScrollSpeedAdjustment(-1); + return true; + } + + return false; + } + + private ScheduledDelegate scheduledScrollSpeedAdjustment; + + public void OnReleased(GlobalAction action) + { + scheduledScrollSpeedAdjustment?.Cancel(); + scheduledScrollSpeedAdjustment = null; + } + + private void scheduleScrollSpeedAdjustment(int amount) + { + scheduledScrollSpeedAdjustment?.Cancel(); + scheduledScrollSpeedAdjustment = this.BeginKeyRepeat(Scheduler, () => AdjustScrollSpeed(amount)); + } private class LocalScrollingInfo : IScrollingInfo { diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 04b4374fc4..0dc3324559 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -6,8 +6,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.UI.Scrolling { @@ -15,15 +17,23 @@ namespace osu.Game.Rulesets.UI.Scrolling { private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); + private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); [Resolved] private IScrollingInfo scrollingInfo { get; set; } - private readonly Cached initialStateCache = new Cached(); + // Responds to changes in the layout. When the layout changes, all hit object states must be recomputed. + private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); + + // A combined cache across all hit object states to reduce per-update iterations. + // When invalidated, one or more (but not necessarily all) hitobject states must be re-validated. + private readonly Cached combinedObjCache = new Cached(); public ScrollingHitObjectContainer() { RelativeSizeAxes = Axes.Both; + + AddLayout(layoutCache); } [BackgroundDependencyLoader] @@ -32,13 +42,14 @@ namespace osu.Game.Rulesets.UI.Scrolling direction.BindTo(scrollingInfo.Direction); timeRange.BindTo(scrollingInfo.TimeRange); - direction.ValueChanged += _ => initialStateCache.Invalidate(); - timeRange.ValueChanged += _ => initialStateCache.Invalidate(); + direction.ValueChanged += _ => layoutCache.Invalidate(); + timeRange.ValueChanged += _ => layoutCache.Invalidate(); } public override void Add(DrawableHitObject hitObject) { - initialStateCache.Invalidate(); + combinedObjCache.Invalidate(); + hitObject.DefaultsApplied += onDefaultsApplied; base.Add(hitObject); } @@ -48,19 +59,127 @@ namespace osu.Game.Rulesets.UI.Scrolling if (result) { - initialStateCache.Invalidate(); + combinedObjCache.Invalidate(); hitObjectInitialStateCache.Remove(hitObject); + + hitObject.DefaultsApplied -= onDefaultsApplied; } return result; } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) + public override void Clear(bool disposeChildren = true) { - if ((invalidation & (Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo)) > 0) - initialStateCache.Invalidate(); + foreach (var h in Objects) + h.DefaultsApplied -= onDefaultsApplied; - return base.Invalidate(invalidation, source, shallPropagate); + base.Clear(disposeChildren); + + combinedObjCache.Invalidate(); + hitObjectInitialStateCache.Clear(); + } + + /// + /// Given a position in screen space, return the time within this column. + /// + public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) + { + // convert to local space of column so we can snap and fetch correct location. + Vector2 localPosition = ToLocalSpace(screenSpacePosition); + + float position = 0; + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + position = localPosition.Y; + break; + + case ScrollingDirection.Right: + case ScrollingDirection.Left: + position = localPosition.X; + break; + } + + flipPositionIfRequired(ref position); + + return scrollingInfo.Algorithm.TimeAt(position, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + } + + /// + /// Given a time, return the screen space position within this column. + /// + public Vector2 ScreenSpacePositionAtTime(double time) + { + var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + + flipPositionIfRequired(ref pos); + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + return ToScreenSpace(new Vector2(getBreadth() / 2, pos)); + + default: + return ToScreenSpace(new Vector2(pos, getBreadth() / 2)); + } + } + + private float getLength() + { + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Left: + case ScrollingDirection.Right: + return DrawWidth; + + default: + return DrawHeight; + } + } + + private float getBreadth() + { + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + return DrawWidth; + + default: + return DrawHeight; + } + } + + private void flipPositionIfRequired(ref float position) + { + // We're dealing with screen coordinates in which the position decreases towards the centre of the screen resulting in an increase in start time. + // The scrolling algorithm instead assumes a top anchor meaning an increase in time corresponds to an increase in position, + // so when scrolling downwards the coordinates need to be flipped. + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + position = DrawHeight - position; + break; + + case ScrollingDirection.Right: + position = DrawWidth - position; + break; + } + } + + private void onDefaultsApplied(DrawableHitObject drawableObject) + { + // The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). + // In such a case, combinedObjCache will take care of updating the hitobject. + if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var objCache)) + { + combinedObjCache.Invalidate(); + objCache.Invalidate(); + } } private float scrollLength; @@ -69,11 +188,19 @@ namespace osu.Game.Rulesets.UI.Scrolling { base.Update(); - if (!initialStateCache.IsValid) + if (!layoutCache.IsValid) { foreach (var cached in hitObjectInitialStateCache.Values) cached.Invalidate(); + combinedObjCache.Invalidate(); + scrollingInfo.Algorithm.Reset(); + + layoutCache.Validate(); + } + + if (!combinedObjCache.IsValid) + { switch (direction.Value) { case ScrollingDirection.Up: @@ -86,15 +213,21 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - scrollingInfo.Algorithm.Reset(); - foreach (var obj in Objects) { + if (!hitObjectInitialStateCache.TryGetValue(obj, out var objCache)) + objCache = hitObjectInitialStateCache[obj] = new Cached(); + + if (objCache.IsValid) + continue; + computeLifetimeStartRecursive(obj); computeInitialStateRecursive(obj); + + objCache.Validate(); } - initialStateCache.Validate(); + combinedObjCache.Validate(); } } @@ -106,8 +239,6 @@ namespace osu.Game.Rulesets.UI.Scrolling computeLifetimeStartRecursive(obj); } - private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); - private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) { float originAdjustment = 0.0f; @@ -133,20 +264,13 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - var adjustedStartTime = scrollingInfo.Algorithm.TimeAt(-originAdjustment, hitObject.HitObject.StartTime, timeRange.Value, scrollLength); - return scrollingInfo.Algorithm.GetDisplayStartTime(adjustedStartTime, timeRange.Value); + return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); } // Cant use AddOnce() since the delegate is re-constructed every invocation private void computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => { - if (!hitObjectInitialStateCache.TryGetValue(hitObject, out var cached)) - cached = hitObjectInitialStateCache[hitObject] = new Cached(); - - if (cached.IsValid) - return; - - if (hitObject.HitObject is IHasEndTime e) + if (hitObject.HitObject is IHasDuration e) { switch (direction.Value) { @@ -169,8 +293,6 @@ namespace osu.Game.Rulesets.UI.Scrolling // Nested hitobjects don't need to scroll, but they do need accurate positions updatePosition(obj, hitObject.HitObject.StartTime); } - - cached.Validate(); }); protected override void UpdateAfterChildrenLife() diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index bf2203e176..9dac3f4de1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Drawables; +using osuTK; namespace osu.Game.Rulesets.UI.Scrolling { @@ -15,14 +16,26 @@ namespace osu.Game.Rulesets.UI.Scrolling protected readonly IBindable Direction = new Bindable(); [Resolved] - private IScrollingInfo scrollingInfo { get; set; } + protected IScrollingInfo ScrollingInfo { get; private set; } [BackgroundDependencyLoader] private void load() { - Direction.BindTo(scrollingInfo.Direction); + Direction.BindTo(ScrollingInfo.Direction); } + /// + /// Given a position in screen space, return the time within this column. + /// + public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => + ((ScrollingHitObjectContainer)HitObjectContainer).TimeAtScreenSpacePosition(screenSpacePosition); + + /// + /// Given a time, return the screen space position within this column. + /// + public virtual Vector2 ScreenSpacePositionAtTime(double time) + => ((ScrollingHitObjectContainer)HitObjectContainer).ScreenSpacePositionAtTime(time); + protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); } } diff --git a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs similarity index 74% rename from osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs rename to osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs index 2115d784a0..9b590f56dd 100644 --- a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs +++ b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs @@ -7,15 +7,15 @@ using osu.Game.Rulesets; namespace osu.Game.Scoring.Legacy { /// - /// A which retrieves the applicable and + /// A which retrieves the applicable and /// for the score from the database. /// - public class DatabasedLegacyScoreParser : LegacyScoreParser + public class DatabasedLegacyScoreDecoder : LegacyScoreDecoder { private readonly RulesetStore rulesets; private readonly BeatmapManager beatmaps; - public DatabasedLegacyScoreParser(RulesetStore rulesets, BeatmapManager beatmaps) + public DatabasedLegacyScoreDecoder(RulesetStore rulesets, BeatmapManager beatmaps) { this.rulesets = rulesets; this.beatmaps = beatmaps; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreParser.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs similarity index 91% rename from osu.Game/Scoring/Legacy/LegacyScoreParser.cs rename to osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 0029c843b4..a4a560c8e4 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreParser.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -19,7 +19,7 @@ using SharpCompress.Compressors.LZMA; namespace osu.Game.Scoring.Legacy { - public abstract class LegacyScoreParser + public abstract class LegacyScoreDecoder { private IBeatmap currentBeatmap; private Ruleset currentRuleset; @@ -28,10 +28,11 @@ namespace osu.Game.Scoring.Legacy { var score = new Score { - ScoreInfo = new ScoreInfo(), Replay = new Replay() }; + WorkingBeatmap workingBeatmap; + using (SerializationReader sr = new SerializationReader(stream)) { currentRuleset = GetRuleset(sr.ReadByte()); @@ -41,13 +42,10 @@ namespace osu.Game.Scoring.Legacy var version = sr.ReadInt32(); - var workingBeatmap = GetBeatmap(sr.ReadString()); + workingBeatmap = GetBeatmap(sr.ReadString()); if (workingBeatmap is DummyWorkingBeatmap) throw new BeatmapNotFoundException(); - currentBeatmap = workingBeatmap.Beatmap; - scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; - scoreInfo.User = new User { Username = sr.ReadString() }; // MD5Hash @@ -66,7 +64,10 @@ namespace osu.Game.Scoring.Legacy /* score.Perfect = */ sr.ReadBoolean(); - scoreInfo.Mods = currentRuleset.ConvertLegacyMods((LegacyMods)sr.ReadInt32()).ToArray(); + scoreInfo.Mods = currentRuleset.ConvertFromLegacyMods((LegacyMods)sr.ReadInt32()).ToArray(); + + currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods); + scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; /* score.HpGraphString = */ sr.ReadString(); @@ -113,6 +114,10 @@ namespace osu.Game.Scoring.Legacy CalculateAccuracy(score.ScoreInfo); + // before returning for database import, we must restore the database-sourced BeatmapInfo. + // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. + score.ScoreInfo.Beatmap = workingBeatmap.BeatmapInfo; + return score; } @@ -220,9 +225,11 @@ namespace osu.Game.Scoring.Legacy float lastTime = 0; ReplayFrame currentFrame = null; - foreach (var l in reader.ReadToEnd().Split(',')) + var frames = reader.ReadToEnd().Split(','); + + for (var i = 0; i < frames.Length; i++) { - var split = l.Split('|'); + var split = frames[i].Split('|'); if (split.Length < 4) continue; @@ -234,8 +241,14 @@ namespace osu.Game.Scoring.Legacy } var diff = Parsing.ParseFloat(split[0]); + lastTime += diff; + if (i == 0 && diff == 0) + // osu-stable adds a zero-time frame before potentially valid negative user frames. + // we need to ignore this. + continue; + // Todo: At some point we probably want to rewind and play back the negative-time frames // but for now we'll achieve equal playback to stable by skipping negative frames if (diff < 0) @@ -256,7 +269,7 @@ namespace osu.Game.Scoring.Legacy if (convertible == null) throw new InvalidOperationException($"Legacy replay cannot be converted for the ruleset: {currentRuleset.Description}"); - convertible.ConvertFrom(currentFrame, currentBeatmap, lastFrame); + convertible.FromLegacy(currentFrame, currentBeatmap, lastFrame); var frame = (ReplayFrame)convertible; frame.Time = currentFrame.Time; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs new file mode 100644 index 0000000000..db7e51e833 --- /dev/null +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using osu.Framework.Extensions; +using osu.Game.Beatmaps; +using osu.Game.IO.Legacy; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Replays.Types; +using SharpCompress.Compressors.LZMA; + +namespace osu.Game.Scoring.Legacy +{ + public class LegacyScoreEncoder + { + public const int LATEST_VERSION = 128; + + private readonly Score score; + private readonly IBeatmap beatmap; + + public LegacyScoreEncoder(Score score, IBeatmap beatmap) + { + this.score = score; + this.beatmap = beatmap; + + if (score.ScoreInfo.Beatmap.RulesetID < 0 || score.ScoreInfo.Beatmap.RulesetID > 3) + throw new ArgumentException("Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); + } + + public void Encode(Stream stream) + { + using (SerializationWriter sw = new SerializationWriter(stream)) + { + sw.Write((byte)(score.ScoreInfo.Ruleset.ID ?? 0)); + sw.Write(LATEST_VERSION); + sw.Write(score.ScoreInfo.Beatmap.MD5Hash); + sw.Write(score.ScoreInfo.UserString); + sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash()); + sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountGeki() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountKatu() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountMiss() ?? 0)); + sw.Write((int)(score.ScoreInfo.TotalScore)); + sw.Write((ushort)score.ScoreInfo.MaxCombo); + sw.Write(score.ScoreInfo.Combo == score.ScoreInfo.MaxCombo); + sw.Write((int)score.ScoreInfo.Ruleset.CreateInstance().ConvertToLegacyMods(score.ScoreInfo.Mods)); + + sw.Write(getHpGraphFormatted()); + sw.Write(score.ScoreInfo.Date.DateTime); + sw.WriteByteArray(createReplayData()); + sw.Write((long)0); + writeModSpecificData(score.ScoreInfo, sw); + } + } + + private void writeModSpecificData(ScoreInfo score, SerializationWriter sw) + { + } + + private byte[] createReplayData() + { + var content = new ASCIIEncoding().GetBytes(replayStringContent); + + using (var outStream = new MemoryStream()) + { + using (var lzma = new LzmaStream(new LzmaEncoderProperties(false, 1 << 21, 255), false, outStream)) + { + outStream.Write(lzma.Properties); + + long fileSize = content.Length; + for (int i = 0; i < 8; i++) + outStream.WriteByte((byte)(fileSize >> (8 * i))); + + lzma.Write(content); + } + + return outStream.ToArray(); + } + } + + private string replayStringContent + { + get + { + StringBuilder replayData = new StringBuilder(); + + if (score.Replay != null) + { + LegacyReplayFrame lastF = new LegacyReplayFrame(0, 0, 0, ReplayButtonState.None); + + foreach (var f in score.Replay.Frames.OfType().Select(f => f.ToLegacy(beatmap))) + { + replayData.Append(FormattableString.Invariant($"{f.Time - lastF.Time}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); + lastF = f; + } + } + + replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0); + return replayData.ToString(); + } + } + + private string getHpGraphFormatted() + { + // todo: implement, maybe? + return string.Empty; + } + } +} diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index 66b1acf591..6f73a284a2 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -12,7 +12,7 @@ namespace osu.Game.Scoring.Legacy switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) { case 3: - return scoreInfo.Statistics[HitResult.Perfect]; + return getCount(scoreInfo, HitResult.Perfect); } return null; @@ -35,10 +35,10 @@ namespace osu.Game.Scoring.Legacy case 0: case 1: case 3: - return scoreInfo.Statistics[HitResult.Great]; + return getCount(scoreInfo, HitResult.Great); case 2: - return scoreInfo.Statistics[HitResult.Perfect]; + return getCount(scoreInfo, HitResult.Perfect); } return null; @@ -65,7 +65,10 @@ namespace osu.Game.Scoring.Legacy switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) { case 3: - return scoreInfo.Statistics[HitResult.Good]; + return getCount(scoreInfo, HitResult.Good); + + case 2: + return getCount(scoreInfo, HitResult.SmallTickMiss); } return null; @@ -78,6 +81,10 @@ namespace osu.Game.Scoring.Legacy case 3: scoreInfo.Statistics[HitResult.Good] = value; break; + + case 2: + scoreInfo.Statistics[HitResult.SmallTickMiss] = value; + break; } } @@ -87,10 +94,13 @@ namespace osu.Game.Scoring.Legacy { case 0: case 1: - return scoreInfo.Statistics[HitResult.Good]; + return getCount(scoreInfo, HitResult.Good); case 3: - return scoreInfo.Statistics[HitResult.Ok]; + return getCount(scoreInfo, HitResult.Ok); + + case 2: + return getCount(scoreInfo, HitResult.LargeTickHit); } return null; @@ -108,6 +118,10 @@ namespace osu.Game.Scoring.Legacy case 3: scoreInfo.Statistics[HitResult.Ok] = value; break; + + case 2: + scoreInfo.Statistics[HitResult.LargeTickHit] = value; + break; } } @@ -117,7 +131,10 @@ namespace osu.Game.Scoring.Legacy { case 0: case 3: - return scoreInfo.Statistics[HitResult.Meh]; + return getCount(scoreInfo, HitResult.Meh); + + case 2: + return getCount(scoreInfo, HitResult.SmallTickHit); } return null; @@ -131,13 +148,25 @@ namespace osu.Game.Scoring.Legacy case 3: scoreInfo.Statistics[HitResult.Meh] = value; break; + + case 2: + scoreInfo.Statistics[HitResult.SmallTickHit] = value; + break; } } public static int? GetCountMiss(this ScoreInfo scoreInfo) => - scoreInfo.Statistics[HitResult.Miss]; + getCount(scoreInfo, HitResult.Miss); public static void SetCountMiss(this ScoreInfo scoreInfo, int value) => scoreInfo.Statistics[HitResult.Miss] = value; + + private static int? getCount(ScoreInfo scoreInfo, HitResult result) + { + if (scoreInfo.Statistics.TryGetValue(result, out var existing)) + return existing; + + return null; + } } } diff --git a/osu.Game/Scoring/LegacyDatabasedScore.cs b/osu.Game/Scoring/LegacyDatabasedScore.cs index 172e08e2d0..8908775472 100644 --- a/osu.Game/Scoring/LegacyDatabasedScore.cs +++ b/osu.Game/Scoring/LegacyDatabasedScore.cs @@ -16,10 +16,13 @@ namespace osu.Game.Scoring { ScoreInfo = score; - var replayFilename = score.Files.First(f => f.Filename.EndsWith(".osr", StringComparison.InvariantCultureIgnoreCase)).FileInfo.StoragePath; + var replayFilename = score.Files.FirstOrDefault(f => f.Filename.EndsWith(".osr", StringComparison.InvariantCultureIgnoreCase))?.FileInfo.StoragePath; + + if (replayFilename == null) + return; using (var stream = store.GetStream(replayFilename)) - Replay = new DatabasedLegacyScoreParser(rulesets, beatmaps).Parse(stream).Replay; + Replay = new DatabasedLegacyScoreDecoder(rulesets, beatmaps).Parse(stream).Replay; } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index c37bab9086..84c0d5b54e 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -13,6 +13,7 @@ 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 { @@ -31,6 +32,9 @@ namespace osu.Game.Scoring [Column(TypeName = "DECIMAL(1,4)")] public double Accuracy { get; set; } + [JsonIgnore] + public string DisplayAccuracy => Accuracy.FormatAccuracy(); + [JsonProperty(@"pp")] public double? PP { get; set; } @@ -111,9 +115,7 @@ namespace osu.Game.Scoring get => User?.Username; set { - if (User == null) - User = new User(); - + User ??= new User(); User.Username = value; } } @@ -125,9 +127,7 @@ namespace osu.Game.Scoring get => User?.Id ?? 1; set { - if (User == null) - User = new User(); - + User ??= new User(); User.Id = value ?? 1; } } @@ -147,6 +147,8 @@ namespace osu.Game.Scoring [JsonProperty("statistics")] public Dictionary Statistics = new Dictionary(); + public IOrderedEnumerable> SortedStatistics => Statistics.OrderByDescending(pair => pair.Key); + [JsonIgnore] [Column("Statistics")] public string StatisticsJson @@ -164,6 +166,10 @@ namespace osu.Game.Scoring } } + [NotMapped] + [JsonIgnore] + public List HitEvents { get; set; } + [JsonIgnore] public List Files { get; set; } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 249f0a932b..d5bd486e43 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -46,9 +46,9 @@ namespace osu.Game.Scoring { try { - return new DatabasedLegacyScoreParser(rulesets, beatmaps()).Parse(stream).ScoreInfo; + return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; } - catch (LegacyScoreParser.BeatmapNotFoundException e) + catch (LegacyScoreDecoder.BeatmapNotFoundException e) { Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error); return null; diff --git a/osu.Game/Scoring/ScoreStore.cs b/osu.Game/Scoring/ScoreStore.cs index 9627481f4d..f5c5cd5dad 100644 --- a/osu.Game/Scoring/ScoreStore.cs +++ b/osu.Game/Scoring/ScoreStore.cs @@ -18,6 +18,8 @@ namespace osu.Game.Scoring protected override IQueryable AddIncludesForConsumption(IQueryable query) => base.AddIncludesForConsumption(query) .Include(s => s.Beatmap) + .Include(s => s.Beatmap).ThenInclude(b => b.Metadata) + .Include(s => s.Beatmap).ThenInclude(b => b.BeatmapSet).ThenInclude(s => s.Metadata) .Include(s => s.Ruleset); } } diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index 5dfaceccf5..0f3615b7a9 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -30,7 +30,7 @@ namespace osu.Game.Screens protected override bool OnKeyDown(KeyDownEvent e) { - //we don't want to handle escape key. + // we don't want to handle escape key. return false; } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 1ab3a5b533..b08455be95 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.Backgrounds /// /// The amount of blur to be applied in addition to user-specified blur. /// - public readonly Bindable BlurAmount = new Bindable(); + public readonly Bindable BlurAmount = new BindableFloat(); internal readonly IBindable IsBreakTime = new Bindable(); @@ -49,9 +49,12 @@ namespace osu.Game.Screens.Backgrounds Beatmap = beatmap; InternalChild = dimmable = CreateFadeContainer(); + dimmable.EnableUserDim.BindTo(EnableUserDim); dimmable.IsBreakTime.BindTo(IsBreakTime); dimmable.BlurAmount.BindTo(BlurAmount); + + StoryboardReplacesBackground.BindTo(dimmable.StoryboardReplacesBackground); } [BackgroundDependencyLoader] @@ -99,7 +102,6 @@ namespace osu.Game.Screens.Backgrounds b.Depth = newDepth; dimmable.Background = Background = b; - StoryboardReplacesBackground.BindTo(dimmable.StoryboardReplacesBackground); } public override bool Equals(BackgroundScreen other) @@ -117,7 +119,7 @@ namespace osu.Game.Screens.Backgrounds /// /// Used in contexts where there can potentially be both user and screen-specified blurring occuring at the same time, such as in /// - public readonly Bindable BlurAmount = new Bindable(); + public readonly Bindable BlurAmount = new BindableFloat(); public Background Background { @@ -164,7 +166,7 @@ namespace osu.Game.Screens.Backgrounds BlurAmount.ValueChanged += _ => UpdateVisuals(); } - protected override bool ShowDimContent => !ShowStoryboard.Value || !StoryboardReplacesBackground.Value || !ShowVideo.Value; // The background needs to be hidden in the case of it being replaced by the storyboard + protected override bool ShowDimContent => !ShowStoryboard.Value || !StoryboardReplacesBackground.Value; // The background needs to be hidden in the case of it being replaced by the storyboard protected override void UpdateVisuals() { diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 095985e9d1..ef41c5be3d 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -4,7 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -21,12 +21,10 @@ namespace osu.Game.Screens.Backgrounds private int currentDisplay; private const int background_count = 7; - - private string backgroundName => $@"Menu/menu-background-{currentDisplay % background_count + 1}"; - private Bindable user; private Bindable skin; private Bindable mode; + private Bindable introSequence; [Resolved] private IBindable beatmap { get; set; } @@ -42,11 +40,13 @@ namespace osu.Game.Screens.Backgrounds user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); mode = config.GetBindable(OsuSetting.MenuBackgroundSource); + introSequence = config.GetBindable(OsuSetting.IntroSequence); user.ValueChanged += _ => Next(); skin.ValueChanged += _ => Next(); mode.ValueChanged += _ => Next(); beatmap.ValueChanged += _ => Next(); + introSequence.ValueChanged += _ => Next(); currentDisplay = RNG.Next(0, background_count); @@ -73,6 +73,18 @@ namespace osu.Game.Screens.Backgrounds private Background createBackground() { Background newBackground; + string backgroundName; + + switch (introSequence.Value) + { + case IntroSequence.Welcome: + backgroundName = "Intro/Welcome/menu-background"; + break; + + default: + backgroundName = $@"Menu/menu-background-{currentDisplay % background_count + 1}"; + break; + } if (user.Value?.IsSupporter ?? false) { diff --git a/osu.Game/Screens/Charts/ChartInfo.cs b/osu.Game/Screens/Charts/ChartInfo.cs deleted file mode 100644 index d9a9ad5eeb..0000000000 --- a/osu.Game/Screens/Charts/ChartInfo.cs +++ /dev/null @@ -1,9 +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.Charts -{ - public class ChartInfo : ScreenWhiteBox - { - } -} diff --git a/osu.Game/Screens/Charts/ChartListing.cs b/osu.Game/Screens/Charts/ChartListing.cs deleted file mode 100644 index 18bba6433f..0000000000 --- a/osu.Game/Screens/Charts/ChartListing.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. - -using System; -using System.Collections.Generic; - -namespace osu.Game.Screens.Charts -{ - public class ChartListing : ScreenWhiteBox - { - protected override IEnumerable PossibleChildren => new[] - { - typeof(ChartInfo) - }; - } -} diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index ce95d81f54..d9477dd4bc 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -52,30 +52,52 @@ namespace osu.Game.Screens.Edit { switch (beatDivisor) { + case 1: + return Color4.White; + case 2: - return colours.BlueLight; + return colours.Red; case 4: return colours.Blue; case 8: - return colours.BlueDarker; + return colours.Yellow; case 16: return colours.PurpleDark; case 3: - return colours.YellowLight; + return colours.Purple; case 6: - return colours.Yellow; + return colours.YellowDark; case 12: return colours.YellowDarker; default: - return Color4.White; + return Color4.Red; } } + + /// + /// Retrieves the applicable divisor for a specific beat index. + /// + /// The 0-based beat index. + /// The beat divisor. + /// The applicable divisor. + public static int GetDivisorForBeatIndex(int index, int beatDivisor) + { + int beat = index % beatDivisor; + + foreach (var divisor in BindableBeatDivisor.VALID_DIVISORS) + { + if ((beat * divisor) % beatDivisor == 0) + return divisor; + } + + return 0; + } } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 752615245e..afd9e3d760 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -3,6 +3,7 @@ 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; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Edit.Components.Menus MaskingContainer.CornerRadius = 0; ItemsContainer.Padding = new MarginPadding { Left = 100 }; - BackgroundColour = OsuColour.FromHex("111"); + BackgroundColour = Color4Extensions.FromHex("111"); ScreenSelectionTabControl tabControl; AddRangeInternal(new Drawable[] diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 62d6c4648b..59b3d1c565 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -5,13 +5,14 @@ using System.Linq; using osuTK; using osuTK.Graphics; 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.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -23,15 +24,14 @@ namespace osu.Game.Screens.Edit.Components { private IconButton playButton; - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } + + private readonly BindableNumber tempo = new BindableDouble(1); [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) + private void load() { - this.adjustableClock = adjustableClock; - - PlaybackTabControl tabs; - Children = new Drawable[] { playButton = new IconButton @@ -58,11 +58,18 @@ namespace osu.Game.Screens.Edit.Components RelativeSizeAxes = Axes.Both, Height = 0.5f, Padding = new MarginPadding { Left = 45 }, - Child = tabs = new PlaybackTabControl(), + Child = new PlaybackTabControl { Current = tempo }, } }; - tabs.Current.ValueChanged += tempo => Beatmap.Value.Track.Tempo.Value = tempo.NewValue; + Track?.AddAdjustment(AdjustableProperty.Tempo, tempo); + } + + protected override void Dispose(bool isDisposing) + { + Track?.RemoveAdjustment(AdjustableProperty.Tempo, tempo); + + base.Dispose(isDisposing); } protected override bool OnKeyDown(KeyDownEvent e) @@ -79,17 +86,17 @@ namespace osu.Game.Screens.Edit.Components private void togglePause() { - if (adjustableClock.IsRunning) - adjustableClock.Stop(); + if (editorClock.IsRunning) + editorClock.Stop(); else - adjustableClock.Start(); + editorClock.Start(); } protected override void Update() { base.Update(); - playButton.Icon = adjustableClock.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; + playButton.Icon = editorClock.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; } private class PlaybackTabControl : OsuTabControl diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs index 5854d66aa8..7be91f4e8e 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs @@ -9,7 +9,6 @@ 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.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -37,8 +36,8 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons { this.button = button; - Text = button.Text; - Action = button.Action; + Text = button.Item.ToString(); + Action = button.Select; RelativeSizeAxes = Axes.X; @@ -100,19 +99,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons bubble.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; } - protected override bool OnClick(ClickEvent e) - { - if (button.Selected.Value) - return true; - - if (!Enabled.Value) - return true; - - button.Selected.Value = true; - - return base.OnClick(e); - } - protected override SpriteText CreateText() => new OsuSpriteText { Depth = -1, diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index 3692c0437b..b515d7c8bd 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -15,33 +15,37 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons public readonly BindableBool Selected; /// - /// The text that should be displayed in this button. + /// The item related to this button. /// - public string Text; + public object Item; - /// - /// The that should be invoked when this button is selected. - /// - public Action Action; + private readonly Action action; - public RadioButton(string text, Action action) + public RadioButton(object item, Action action) { - Text = text; - Action = action; + Item = item; + this.action = action; Selected = new BindableBool(); } - public RadioButton(string text) - : this(text, null) + public RadioButton(string item) + : this(item, null) { - Text = text; - Action = null; + Item = item; + action = null; } /// /// Selects this . /// - public void Select() => Selected.Value = true; + public void Select() + { + if (!Selected.Value) + { + Selected.Value = true; + action?.Invoke(); + } + } /// /// Deselects this . diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index 0391074b11..c1f54d7938 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; using System; using osu.Framework.Allocation; -using osu.Framework.Timing; using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Components @@ -14,7 +13,8 @@ namespace osu.Game.Screens.Edit.Components { private readonly OsuSpriteText trackTimer; - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } public TimeInfoContainer() { @@ -30,17 +30,11 @@ namespace osu.Game.Screens.Edit.Components }; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) - { - this.adjustableClock = adjustableClock; - } - protected override void Update() { base.Update(); - trackTimer.Text = TimeSpan.FromMilliseconds(adjustableClock.CurrentTime).ToString(@"mm\:ss\:fff"); + trackTimer.Text = TimeSpan.FromMilliseconds(editorClock.CurrentTime).ToString(@"mm\:ss\:fff"); } } } 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 79ada40a89..9e9ac93d23 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -20,24 +19,22 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public class MarkerPart : TimelinePart { - private readonly Drawable marker; + private Drawable marker; - private readonly IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } - public MarkerPart(IAdjustableClock adjustableClock) + [BackgroundDependencyLoader] + private void load() { - this.adjustableClock = adjustableClock; - Add(marker = new MarkerVisualisation()); } protected override bool OnDragStart(DragStartEvent e) => true; - protected override bool OnDragEnd(DragEndEvent e) => true; - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { seekToPosition(e.ScreenSpaceMousePosition); - return true; } protected override bool OnMouseDown(MouseDownEvent e) @@ -61,14 +58,14 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts return; float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); - adjustableClock.Seek(markerPos / DrawWidth * Beatmap.Value.Track.Length); + editorClock.SeekTo(markerPos / DrawWidth * editorClock.TrackLength); }); } protected override void Update() { base.Update(); - marker.X = (float)adjustableClock.CurrentTime; + marker.X = (float)editorClock.CurrentTime; } protected override void LoadBeatmap(WorkingBeatmap beatmap) 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 7706e33179..4a7c3f26bc 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -11,20 +11,24 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { + public class TimelinePart : TimelinePart + { + } + /// /// Represents a part of the summary timeline.. /// - public abstract class TimelinePart : Container + public class TimelinePart : Container where T : Drawable { protected readonly IBindable Beatmap = new Bindable(); - private readonly Container timeline; + private readonly Container content; - protected override Container Content => timeline; + protected override Container Content => content; - protected TimelinePart() + public TimelinePart(Container content = null) { - AddInternal(timeline = new Container { RelativeSizeAxes = Axes.Both }); + AddInternal(this.content = content ?? new Container { RelativeSizeAxes = Axes.Both }); Beatmap.ValueChanged += b => { @@ -44,17 +48,17 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts // the track may not be loaded completely (only has a length once it is). if (!Beatmap.Value.Track.IsLoaded) { - timeline.RelativeChildSize = Vector2.One; + content.RelativeChildSize = Vector2.One; Schedule(updateRelativeChildSize); return; } - timeline.RelativeChildSize = new Vector2((float)Math.Max(1, Beatmap.Value.Track.Length), 1); + content.RelativeChildSize = new Vector2((float)Math.Max(1, Beatmap.Value.Track.Length), 1); } protected virtual void LoadBeatmap(WorkingBeatmap beatmap) { - timeline.Clear(); + content.Clear(); } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 20db2cac21..02cd4bccb4 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Timing; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -18,11 +17,11 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary public class SummaryTimeline : BottomBarContainer { [BackgroundDependencyLoader] - private void load(OsuColour colours, IAdjustableClock adjustableClock) + private void load(OsuColour colours) { Children = new Drawable[] { - new MarkerPart(adjustableClock) { RelativeSizeAxes = Axes.Both }, + new MarkerPart { RelativeSizeAxes = Axes.Both }, new ControlPointPart { Anchor = Anchor.Centre, 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 9c00cce57a..1ac960039e 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// public class PointVisualisation : Box { - protected PointVisualisation(double startTime) + public PointVisualisation(double startTime) { Origin = Anchor.TopCentre; diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 42773ef687..2dec3fd22e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -262,10 +262,10 @@ namespace osu.Game.Screens.Edit.Compose.Components return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { marker.Active = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) @@ -274,10 +274,14 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) + { + handleMouseInput(e.ScreenSpaceMousePosition); + } + + protected override void OnDragEnd(DragEndEvent e) { handleMouseInput(e.ScreenSpaceMousePosition); - return true; } private void handleMouseInput(Vector2 screenSpaceMousePosition) diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index cafaddc39e..fcff672045 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -2,19 +2,19 @@ // 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 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; -using osu.Framework.Timing; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; @@ -22,27 +22,33 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { - public class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + /// + /// A container which provides a "blueprint" display of hitobjects. + /// Includes selection and manipulation support via a . + /// + public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler { - public event Action> SelectionChanged; + protected DragBox DragBox { get; private set; } + + protected Container SelectionBlueprints { get; private set; } - private DragBox dragBox; - private SelectionBlueprintContainer selectionBlueprints; - private Container placementBlueprintContainer; - private PlacementBlueprint currentPlacement; private SelectionHandler selectionHandler; - private InputManager inputManager; + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } [Resolved] - private IAdjustableClock adjustableClock { get; set; } - - [Resolved] - private HitObjectComposer composer { get; set; } + private EditorClock editorClock { get; set; } [Resolved] private EditorBeatmap beatmap { get; set; } - public BlueprintContainer() + private readonly BindableList selectedHitObjects = new BindableList(); + + [Resolved(canBeNull: true)] + private IPositionSnapProvider snapProvider { get; set; } + + protected BlueprintContainer() { RelativeSizeAxes = Axes.Both; } @@ -50,65 +56,85 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - selectionHandler = composer.CreateSelectionHandler(); + selectionHandler = CreateSelectionHandler(); selectionHandler.DeselectAll = deselectAll; - InternalChildren = new[] + AddRangeInternal(new[] { - dragBox = new DragBox(select), + DragBox = CreateDragBox(select), selectionHandler, - selectionBlueprints = new SelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }, - placementBlueprintContainer = new Container { RelativeSizeAxes = Axes.Both }, - dragBox.CreateProxy() - }; + SelectionBlueprints = CreateSelectionBlueprintContainer(), + selectionHandler.CreateProxy(), + DragBox.CreateProxy().With(p => p.Depth = float.MinValue) + }); - foreach (var obj in composer.HitObjects) - addBlueprintFor(obj); + foreach (var obj in beatmap.HitObjects) + AddBlueprintFor(obj); + + selectedHitObjects.BindTo(beatmap.SelectedHitObjects); + selectedHitObjects.CollectionChanged += (selectedObjects, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var o in args.NewItems) + SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select(); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var o in args.OldItems) + SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect(); + + break; + } + }; } protected override void LoadComplete() { base.LoadComplete(); - beatmap.HitObjectAdded += addBlueprintFor; + beatmap.HitObjectAdded += AddBlueprintFor; beatmap.HitObjectRemoved += removeBlueprintFor; - - inputManager = GetContainingInputManager(); } - private HitObjectCompositionTool currentTool; + protected virtual Container CreateSelectionBlueprintContainer() => + new Container { RelativeSizeAxes = Axes.Both }; /// - /// The current placement tool. + /// Creates a which outlines s and handles movement of selections. /// - public HitObjectCompositionTool CurrentTool - { - get => currentTool; - set - { - if (currentTool == value) - return; + protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); - currentTool = value; + /// + /// Creates a for a specific . + /// + /// The to create the overlay for. + protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null; - refreshTool(); - } - } + protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); protected override bool OnMouseDown(MouseDownEvent e) { beginClickSelection(e); + prepareSelectionMovement(); + return e.Button == MouseButton.Left; } + private SelectionBlueprint clickedBlueprint; + protected override bool OnClick(ClickEvent e) { if (e.Button == MouseButton.Right) return false; + // store for double-click handling + clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); + // Deselection should only occur if no selected blueprints are hovered // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection - if (endClickSelection() || selectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) + if (endClickSelection() || clickedBlueprint != null) return true; deselectAll(); @@ -120,31 +146,20 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Button == MouseButton.Right) return false; - SelectionBlueprint clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); - - if (clickedBlueprint == null) + // ensure the blueprint which was hovered for the first click is still the hovered blueprint. + if (clickedBlueprint == null || selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint) return false; - adjustableClock?.Seek(clickedBlueprint.DrawableObject.HitObject.StartTime); + editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { // Special case for when a drag happened instead of a click Schedule(() => endClickSelection()); - return e.Button == MouseButton.Left; - } - protected override bool OnMouseMove(MouseMoveEvent e) - { - if (currentPlacement != null) - { - updatePlacementPosition(e.ScreenSpaceMousePosition); - return true; - } - - return base.OnMouseMove(e); + finishSelectionMovement(); } protected override bool OnDragStart(DragStartEvent e) @@ -152,38 +167,49 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Button == MouseButton.Right) return false; - if (!beginSelectionMovement()) + if (movementBlueprint != null) { - dragBox.UpdateDrag(e); - dragBox.FadeIn(250, Easing.OutQuint); + isDraggingBlueprint = true; + changeHandler?.BeginChange(); + return true; } - return true; - } - - protected override bool OnDrag(DragEvent e) - { - if (e.Button == MouseButton.Right) - return false; - - if (!moveCurrentSelection(e)) - dragBox.UpdateDrag(e); - - return true; - } - - protected override bool OnDragEnd(DragEndEvent e) - { - if (e.Button == MouseButton.Right) - return false; - - if (!finishSelectionMovement()) + if (DragBox.HandleDrag(e)) { - dragBox.FadeOut(250, Easing.OutQuint); + DragBox.Show(); + return true; + } + + return false; + } + + protected override void OnDrag(DragEvent e) + { + if (e.Button == MouseButton.Right) + return; + + if (DragBox.State == Visibility.Visible) + DragBox.HandleDrag(e); + + moveCurrentSelection(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (e.Button == MouseButton.Right) + return; + + if (isDraggingBlueprint) + { + changeHandler?.EndChange(); + isDraggingBlueprint = false; + } + + if (DragBox.State == Visibility.Visible) + { + DragBox.Hide(); selectionHandler.UpdateVisibility(); } - - return true; } protected override bool OnKeyDown(KeyDownEvent e) @@ -201,8 +227,6 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - protected override bool OnKeyUp(KeyUpEvent e) => false; - public bool OnPressed(PlatformAction action) { switch (action.ActionType) @@ -215,35 +239,15 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - public bool OnReleased(PlatformAction action) => false; - - protected override void Update() + public void OnReleased(PlatformAction action) { - base.Update(); - - if (currentPlacement != null) - { - if (composer.CursorInPlacementArea) - currentPlacement.State = PlacementState.Shown; - else if (currentPlacement?.PlacementBegun == false) - currentPlacement.State = PlacementState.Hidden; - } } #region Blueprint Addition/Removal - private void addBlueprintFor(HitObject hitObject) - { - var drawable = composer.HitObjects.FirstOrDefault(d => d.HitObject == hitObject); - if (drawable == null) - return; - - addBlueprintFor(drawable); - } - private void removeBlueprintFor(HitObject hitObject) { - var blueprint = selectionBlueprints.Single(m => m.DrawableObject.HitObject == hitObject); + var blueprint = SelectionBlueprints.SingleOrDefault(m => m.HitObject == hitObject); if (blueprint == null) return; @@ -252,52 +256,22 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.Selected -= onBlueprintSelected; blueprint.Deselected -= onBlueprintDeselected; - selectionBlueprints.Remove(blueprint); + SelectionBlueprints.Remove(blueprint); + + if (movementBlueprint == blueprint) + finishSelectionMovement(); } - private void addBlueprintFor(DrawableHitObject hitObject) + protected virtual void AddBlueprintFor(HitObject hitObject) { - refreshTool(); - - var blueprint = composer.CreateBlueprintFor(hitObject); + var blueprint = CreateBlueprintFor(hitObject); if (blueprint == null) return; blueprint.Selected += onBlueprintSelected; blueprint.Deselected += onBlueprintDeselected; - selectionBlueprints.Add(blueprint); - } - - #endregion - - #region Placement - - /// - /// Refreshes the current placement tool. - /// - private void refreshTool() - { - placementBlueprintContainer.Clear(); - currentPlacement = null; - - var blueprint = CurrentTool?.CreatePlacementBlueprint(); - - if (blueprint != null) - { - placementBlueprintContainer.Child = currentPlacement = blueprint; - - // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - updatePlacementPosition(inputManager.CurrentState.Mouse.Position); - } - } - - private void updatePlacementPosition(Vector2 screenSpacePosition) - { - Vector2 snappedGridPosition = composer.GetSnappedPosition(ToLocalSpace(screenSpacePosition), 0).position; - Vector2 snappedScreenSpacePosition = ToScreenSpace(snappedGridPosition); - - currentPlacement.UpdatePosition(snappedScreenSpacePosition); + SelectionBlueprints.Add(blueprint); } #endregion @@ -324,7 +298,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!allowDeselection && selectionHandler.SelectedBlueprints.Any(s => s.IsHovered)) return; - foreach (SelectionBlueprint blueprint in selectionBlueprints.AliveBlueprints) + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren) { if (blueprint.IsHovered) { @@ -354,12 +328,24 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The rectangle to perform a selection on in screen-space coordinates. private void select(RectangleF rect) { - foreach (var blueprint in selectionBlueprints) + foreach (var blueprint in SelectionBlueprints) { - if (blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.SelectionPoint)) - blueprint.Select(); - else - blueprint.Deselect(); + // 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(); + break; + + case SelectionState.Selected: + // if the editor is playing, we generally don't want to deselect objects even if outside the selection area. + if (!editorClock.IsRunning && !isValidForSelection()) + blueprint.Deselect(); + break; + } } } @@ -368,7 +354,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// private void selectAll() { - selectionBlueprints.ToList().ForEach(m => m.Select()); + SelectionBlueprints.ToList().ForEach(m => m.Select()); selectionHandler.UpdateVisibility(); } @@ -380,44 +366,41 @@ namespace osu.Game.Screens.Edit.Compose.Components private void onBlueprintSelected(SelectionBlueprint blueprint) { selectionHandler.HandleSelected(blueprint); - selectionBlueprints.ChangeChildDepth(blueprint, 1); - - SelectionChanged?.Invoke(selectionHandler.SelectedHitObjects); + SelectionBlueprints.ChangeChildDepth(blueprint, 1); + beatmap.SelectedHitObjects.Add(blueprint.HitObject); } private void onBlueprintDeselected(SelectionBlueprint blueprint) { selectionHandler.HandleDeselected(blueprint); - selectionBlueprints.ChangeChildDepth(blueprint, 0); - - SelectionChanged?.Invoke(selectionHandler.SelectedHitObjects); + SelectionBlueprints.ChangeChildDepth(blueprint, 0); + beatmap.SelectedHitObjects.Remove(blueprint.HitObject); } #endregion #region Selection Movement - private Vector2? screenSpaceMovementStartPosition; + private Vector2? movementBlueprintOriginalPosition; private SelectionBlueprint movementBlueprint; + private bool isDraggingBlueprint; /// /// Attempts to begin the movement of any selected blueprints. /// - /// Whether movement began. - private bool beginSelectionMovement() + private void prepareSelectionMovement() { - Debug.Assert(movementBlueprint == null); + if (!selectionHandler.SelectedBlueprints.Any()) + return; // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !selectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) - return false; + return; // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject - movementBlueprint = selectionHandler.SelectedBlueprints.OrderBy(b => b.DrawableObject.HitObject.StartTime).First(); - screenSpaceMovementStartPosition = movementBlueprint.DrawableObject.ToScreenSpace(movementBlueprint.DrawableObject.OriginPosition); - - return true; + movementBlueprint = selectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First(); + movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct } /// @@ -430,23 +413,27 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprint == null) return false; - Debug.Assert(screenSpaceMovementStartPosition != null); + Debug.Assert(movementBlueprintOriginalPosition != null); - Vector2 startPosition = screenSpaceMovementStartPosition.Value; - HitObject draggedObject = movementBlueprint.DrawableObject.HitObject; + HitObject draggedObject = movementBlueprint.HitObject; - // The final movement position, relative to screenSpaceMovementStartPosition - Vector2 movePosition = startPosition + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - (Vector2 snappedPosition, double snappedTime) = composer.GetSnappedPosition(ToLocalSpace(movePosition), draggedObject.StartTime); + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - // Move the hitobjects - if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, startPosition, ToScreenSpace(snappedPosition)))) + // Retrieve a snapped position. + var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); + + // Move the hitobjects. + if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition))) return true; - // Apply the start time at the newly snapped-to position - double offset = snappedTime - draggedObject.StartTime; - foreach (HitObject obj in selectionHandler.SelectedHitObjects) - obj.StartTime += offset; + if (result.Time.HasValue) + { + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - draggedObject.StartTime; + foreach (HitObject obj in selectionHandler.SelectedHitObjects) + obj.StartTime += offset; + } return true; } @@ -460,7 +447,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprint == null) return false; - screenSpaceMovementStartPosition = null; + movementBlueprintOriginalPosition = null; movementBlueprint = null; return true; @@ -474,34 +461,9 @@ namespace osu.Game.Screens.Edit.Compose.Components if (beatmap != null) { - beatmap.HitObjectAdded -= addBlueprintFor; + beatmap.HitObjectAdded -= AddBlueprintFor; beatmap.HitObjectRemoved -= removeBlueprintFor; } } - - private class SelectionBlueprintContainer : Container - { - public IEnumerable AliveBlueprints => AliveInternalChildren.Cast(); - - protected override int Compare(Drawable x, Drawable y) - { - if (!(x is SelectionBlueprint xBlueprint) || !(y is SelectionBlueprint yBlueprint)) - return base.Compare(x, y); - - return Compare(xBlueprint, yBlueprint); - } - - public int Compare(SelectionBlueprint x, SelectionBlueprint y) - { - // dpeth is used to denote selected status (we always want selected blueprints to handle input first). - int d = x.Depth.CompareTo(y.Depth); - if (d != 0) - return d; - - // Put earlier hitobjects towards the end of the list, so they handle input first - int i = y.DrawableObject.HitObject.StartTime.CompareTo(x.DrawableObject.HitObject.StartTime); - return i == 0 ? CompareReverseChildID(x, y) : i; - } - } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 23ed10b92d..730f482f83 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { } - protected override void CreateContent(Vector2 startPosition) + protected override void CreateContent() { const float crosshair_thickness = 1; const float crosshair_max_size = 10; @@ -26,7 +26,7 @@ namespace osu.Game.Screens.Edit.Compose.Components new Box { Origin = Anchor.Centre, - Position = startPosition, + Position = StartPosition, Width = crosshair_thickness, EdgeSmoothness = new Vector2(1), Height = Math.Min(crosshair_max_size, DistanceSpacing * 2), @@ -34,15 +34,15 @@ namespace osu.Game.Screens.Edit.Compose.Components new Box { Origin = Anchor.Centre, - Position = startPosition, + Position = StartPosition, EdgeSmoothness = new Vector2(1), Width = Math.Min(crosshair_max_size, DistanceSpacing * 2), Height = crosshair_thickness, } }); - float dx = Math.Max(startPosition.X, DrawWidth - startPosition.X); - float dy = Math.Max(startPosition.Y, DrawHeight - startPosition.Y); + float dx = Math.Max(StartPosition.X, DrawWidth - StartPosition.X); + float dy = Math.Max(StartPosition.Y, DrawHeight - StartPosition.Y); float maxDistance = new Vector2(dx, dy).Length; int requiredCircles = Math.Min(MaxIntervals, (int)(maxDistance / DistanceSpacing)); @@ -53,11 +53,11 @@ namespace osu.Game.Screens.Edit.Compose.Components AddInternal(new CircularProgress { Origin = Anchor.Centre, - Position = startPosition, + Position = StartPosition, Current = { Value = 1 }, Size = new Vector2(radius), InnerRadius = 4 * 1f / radius, - Colour = GetColourForBeatIndex(i) + Colour = GetColourForIndexFromPlacement(i) }); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs new file mode 100644 index 0000000000..e1f311f1b8 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A blueprint container generally displayed as an overlay to a ruleset's playfield. + /// + public class ComposeBlueprintContainer : BlueprintContainer + { + [Resolved] + private HitObjectComposer composer { get; set; } + + private PlacementBlueprint currentPlacement; + + private readonly Container placementBlueprintContainer; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + private InputManager inputManager; + + private readonly IEnumerable drawableHitObjects; + + public ComposeBlueprintContainer(IEnumerable drawableHitObjects) + { + this.drawableHitObjects = drawableHitObjects; + + placementBlueprintContainer = new Container + { + RelativeSizeAxes = Axes.Both + }; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(placementBlueprintContainer); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + #region Placement + + /// + /// Refreshes the current placement tool. + /// + private void refreshTool() + { + removePlacement(); + createPlacement(); + } + + private void updatePlacementPosition() + { + var snapResult = composer.SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); + + currentPlacement.UpdatePosition(snapResult); + } + + #endregion + + protected override void Update() + { + base.Update(); + + if (composer.CursorInPlacementArea) + createPlacement(); + else if (currentPlacement?.PlacementActive == false) + removePlacement(); + + if (currentPlacement != null) + updatePlacementPosition(); + } + + protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) + { + var drawable = drawableHitObjects.FirstOrDefault(d => d.HitObject == hitObject); + + if (drawable == null) + return null; + + return CreateBlueprintFor(drawable); + } + + public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; + + protected override void AddBlueprintFor(HitObject hitObject) + { + refreshTool(); + base.AddBlueprintFor(hitObject); + } + + private void createPlacement() + { + if (currentPlacement != null) return; + + var blueprint = CurrentTool?.CreatePlacementBlueprint(); + + if (blueprint != null) + { + placementBlueprintContainer.Child = currentPlacement = blueprint; + + // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame + updatePlacementPosition(); + } + } + + private void removePlacement() + { + if (currentPlacement == null) return; + + currentPlacement.EndPlacement(false); + currentPlacement.Expire(); + currentPlacement = null; + } + + private HitObjectCompositionTool currentTool; + + /// + /// The current placement tool. + /// + public HitObjectCompositionTool CurrentTool + { + get => currentTool; + + set + { + if (currentTool == value) + return; + + currentTool = value; + + refreshTool(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 53c5cf97fa..8a92a2011d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osuTK; @@ -42,7 +43,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected OsuColour Colours { get; private set; } [Resolved] - protected IDistanceSnapProvider SnapProvider { get; private set; } + protected IPositionSnapProvider SnapProvider { get; private set; } [Resolved] private EditorBeatmap beatmap { get; set; } @@ -50,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private BindableBeatDivisor beatDivisor { get; set; } - private readonly Cached gridCache = new Cached(); + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); private readonly double? endTime; /// @@ -66,6 +67,8 @@ namespace osu.Game.Screens.Edit.Compose.Components StartTime = startTime; RelativeSizeAxes = Axes.Both; + + AddLayout(gridCache); } protected override void LoadComplete() @@ -91,14 +94,6 @@ namespace osu.Game.Screens.Edit.Compose.Components gridCache.Invalidate(); } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.RequiredParentSizeToFit) > 0) - gridCache.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); - } - protected override void Update() { base.Update(); @@ -106,7 +101,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!gridCache.IsValid) { ClearInternal(); - CreateContent(StartPosition); + CreateContent(); gridCache.Validate(); } } @@ -114,7 +109,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Creates the content which visualises the grid ticks. /// - protected abstract void CreateContent(Vector2 startPosition); + protected abstract void CreateContent(); /// /// Snaps a position to this grid. @@ -126,25 +121,17 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Retrieves the applicable colour for a beat index. /// - /// The 0-based beat index. + /// The 0-based beat index from the point of placement. /// The applicable colour. - protected ColourInfo GetColourForBeatIndex(int index) + protected ColourInfo GetColourForIndexFromPlacement(int placementIndex) { - int beat = (index + 1) % beatDivisor.Value; - ColourInfo colour = Colours.Gray5; + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(StartTime); + var beatLength = timingPoint.BeatLength / beatDivisor.Value; + var beatIndex = (int)Math.Round((StartTime - timingPoint.Time) / beatLength); - for (int i = 0; i < BindableBeatDivisor.VALID_DIVISORS.Length; i++) - { - int divisor = BindableBeatDivisor.VALID_DIVISORS[i]; + var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); - if ((beat * divisor) % beatDivisor.Value == 0) - { - colour = BindableBeatDivisor.GetColourFor(divisor, Colours); - break; - } - } - - int repeatIndex = index / beatDivisor.Value; + int repeatIndex = placementIndex / beatDivisor.Value; return colour.MultiplyAlpha(0.5f / (repeatIndex + 1)); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index 2a510e74fd..0615ebfc20 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,11 +16,11 @@ 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 + public class DragBox : CompositeDrawable, IStateful { - private readonly Action performSelection; + protected readonly Action PerformSelection; - private Drawable box; + protected Drawable Box; /// /// Creates a new . @@ -27,7 +28,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// A delegate that performs drag selection. public DragBox(Action performSelection) { - this.performSelection = performSelection; + PerformSelection = performSelection; RelativeSizeAxes = Axes.Both; AlwaysPresent = true; @@ -37,20 +38,29 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - InternalChild = box = new Container - { - Masking = true, - BorderColour = Color4.White, - BorderThickness = SelectionHandler.BORDER_RADIUS, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.1f - } - }; + InternalChild = Box = CreateBox(); } - public void UpdateDrag(MouseButtonEvent e) + protected virtual Drawable CreateBox() => new Container + { + Masking = true, + BorderColour = Color4.White, + BorderThickness = SelectionHandler.BORDER_RADIUS, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f + } + }; + + 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) { var dragPosition = e.ScreenSpaceMousePosition; var dragStartPosition = e.ScreenSpaceMouseDownPosition; @@ -58,15 +68,48 @@ namespace osu.Game.Screens.Edit.Compose.Components 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 dragRectangle = dragQuad.AABBFloat; + var rec = dragQuad.AABBFloat; + dragRectangle = rec; - var topLeft = ToLocalSpace(dragRectangle.TopLeft); - var bottomRight = ToLocalSpace(dragRectangle.BottomRight); + var topLeft = ToLocalSpace(rec.TopLeft); + var bottomRight = ToLocalSpace(rec.BottomRight); - box.Position = topLeft; - box.Size = bottomRight - topLeft; - - performSelection?.Invoke(dragRectangle); + Box.Position = topLeft; + Box.Size = bottomRight - topLeft; + return true; } + + private Visibility state; + + public Visibility State + { + get => state; + set + { + if (value == state) return; + + state = value; + this.FadeTo(state == Visibility.Hidden ? 0 : 1, 250, Easing.OutQuint); + StateChanged?.Invoke(state); + } + } + + 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 Show() => State = Visibility.Visible; + + public event Action StateChanged; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs index fe0a47aec8..0792d0f80e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -7,7 +7,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// An event which occurs when a is moved. + /// An event which occurs when a is moved. /// public class MoveSelectionEvent { @@ -16,11 +16,6 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public readonly SelectionBlueprint Blueprint; - /// - /// The starting screen-space position of the hitobject. - /// - public readonly Vector2 ScreenSpaceStartPosition; - /// /// The expected screen-space position of the hitobject at the current cursor position. /// @@ -29,18 +24,14 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// The distance between and the hitobject's current position, in the coordinate-space of the hitobject's parent. /// - /// - /// This does not use and does not represent the cumulative movement distance. - /// public readonly Vector2 InstantDelta; - public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpaceStartPosition, Vector2 screenSpacePosition) + public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpacePosition) { Blueprint = blueprint; - ScreenSpaceStartPosition = screenSpaceStartPosition; ScreenSpacePosition = screenSpacePosition; - InstantDelta = Blueprint.DrawableObject.Parent.ToLocalSpace(ScreenSpacePosition) - Blueprint.DrawableObject.Position; + InstantDelta = Blueprint.GetInstantDelta(ScreenSpacePosition); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index e2d7855eb5..9700cb8c8e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -15,6 +15,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.States; using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -33,12 +34,17 @@ namespace osu.Game.Screens.Edit.Compose.Components public IEnumerable SelectedBlueprints => selectedBlueprints; private readonly List selectedBlueprints; - public IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.DrawableObject.HitObject); + public IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject); - private Drawable outline; + private Drawable content; - [Resolved] - private IPlacementHandler placementHandler { get; set; } + private OsuSpriteText selectionDetailsText; + + [Resolved(CanBeNull = true)] + protected EditorBeatmap EditorBeatmap { get; private set; } + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } public SelectionHandler() { @@ -52,16 +58,42 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - InternalChild = outline = new Container + InternalChild = content = new Container { - Masking = true, - BorderThickness = BORDER_RADIUS, - BorderColour = colours.Yellow, - Child = new Box + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - AlwaysPresent = true, - Alpha = 0 + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = BORDER_RADIUS, + BorderColour = colours.YellowDark, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0 + } + }, + new Container + { + Name = "info text", + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + }, + selectionDetailsText = new OsuSpriteText + { + Padding = new MarginPadding(2), + Colour = colours.Gray0, + Font = OsuFont.Default.With(size: 11) + } + } + } } }; } @@ -71,9 +103,16 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Handles the selected s being moved. /// + /// + /// Just returning true is enough to allow updates to take place. + /// Custom implementation is only required if other attributes are to be considered, like changing columns. + /// /// The move event. - /// Whether any s were moved. - public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; + /// + /// Whether any s could be moved. + /// Returning true will also propagate StartTime changes provided by the closest . + /// + public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => true; public bool OnPressed(PlatformAction action) { @@ -87,7 +126,9 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - public bool OnReleased(PlatformAction action) => action.ActionMethod == PlatformActionMethod.Delete; + public void OnReleased(PlatformAction action) + { + } #endregion @@ -102,7 +143,13 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Handle a blueprint becoming selected. /// /// The blueprint. - internal void HandleSelected(SelectionBlueprint blueprint) => selectedBlueprints.Add(blueprint); + internal void HandleSelected(SelectionBlueprint blueprint) + { + selectedBlueprints.Add(blueprint); + EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject); + + UpdateVisibility(); + } /// /// Handle a blueprint becoming deselected. @@ -111,10 +158,9 @@ namespace osu.Game.Screens.Edit.Compose.Components internal void HandleDeselected(SelectionBlueprint blueprint) { selectedBlueprints.Remove(blueprint); + EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject); - // We don't want to update visibility if > 0, since we may be deselecting blueprints during drag-selection - if (selectedBlueprints.Count == 0) - UpdateVisibility(); + UpdateVisibility(); } /// @@ -139,14 +185,16 @@ namespace osu.Game.Screens.Edit.Compose.Components DeselectAll?.Invoke(); blueprint.Select(); } - - UpdateVisibility(); } private void deleteSelected() { + changeHandler?.BeginChange(); + foreach (var h in selectedBlueprints.ToList()) - placementHandler.Delete(h.DrawableObject.HitObject); + EditorBeatmap?.Remove(h.HitObject); + + changeHandler?.EndChange(); } #endregion @@ -158,7 +206,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// internal void UpdateVisibility() { - if (selectedBlueprints.Count > 0) + int count = selectedBlueprints.Count; + + selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; + + if (count > 0) Show(); else Hide(); @@ -184,8 +236,8 @@ namespace osu.Game.Screens.Edit.Compose.Components topLeft -= new Vector2(5); bottomRight += new Vector2(5); - outline.Size = bottomRight - topLeft; - outline.Position = topLeft; + content.Size = bottomRight - topLeft; + content.Position = topLeft; } #endregion @@ -198,6 +250,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void AddHitSample(string sampleName) { + changeHandler?.BeginChange(); + foreach (var h in SelectedHitObjects) { // Make sure there isn't already an existing sample @@ -206,6 +260,8 @@ namespace osu.Game.Screens.Edit.Compose.Components h.Samples.Add(new HitSampleInfo { Name = sampleName }); } + + changeHandler?.EndChange(); } /// @@ -214,22 +270,33 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void RemoveHitSample(string sampleName) { + changeHandler?.BeginChange(); + foreach (var h in SelectedHitObjects) h.SamplesBindable.RemoveAll(s => s.Name == sampleName); + + changeHandler?.EndChange(); } #endregion #region Context Menu - public virtual MenuItem[] ContextMenuItems + public MenuItem[] ContextMenuItems { get { if (!selectedBlueprints.Any(b => b.IsHovered)) return Array.Empty(); - var items = new List + var items = new List(); + + items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints)); + + if (selectedBlueprints.Count == 1) + items.AddRange(selectedBlueprints[0].ContextMenuItems); + + items.AddRange(new[] { new OsuMenuItem("Sound") { @@ -241,15 +308,20 @@ namespace osu.Game.Screens.Edit.Compose.Components } }, new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected), - }; - - if (selectedBlueprints.Count == 1) - items.AddRange(selectedBlueprints[0].ContextMenuItems); + }); return items.ToArray(); } } + /// + /// Provide context menu items relevant to current selection. Calling base is not required. + /// + /// The current selection. + /// The relevant menu items. + protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + => Enumerable.Empty(); + private MenuItem createHitSampleMenuItem(string name, string sampleName) { return new TernaryStateMenuItem(name, MenuItemType.Standard, setHitSampleState) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs index 0d4c48b5f6..8c8b38d9ea 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs @@ -12,14 +12,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class CentreMarker : CompositeDrawable { - private const float triangle_width = 20; + private const float triangle_width = 15; private const float triangle_height = 10; private const float bar_width = 2; public CentreMarker() { RelativeSizeAxes = Axes.Y; - Size = new Vector2(20, 1); + Size = new Vector2(triangle_width, 1); Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -39,6 +39,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Origin = Anchor.BottomCentre, Size = new Vector2(triangle_width, triangle_height), Scale = new Vector2(1, -1) + }, + new Triangle + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(triangle_width, triangle_height), } }; } @@ -46,7 +52,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load(OsuColour colours) { - Colour = colours.Red; + Colour = colours.RedDark; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index b4f3b1f610..717d60b4f3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.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.Bindables; @@ -8,34 +9,35 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class Timeline : ZoomableScrollContainer + [Cached(typeof(IPositionSnapProvider))] + [Cached] + public class Timeline : ZoomableScrollContainer, IPositionSnapProvider { public readonly Bindable WaveformVisible = new Bindable(); public readonly IBindable Beatmap = new Bindable(); - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } public Timeline() { ZoomDuration = 200; ZoomEasing = Easing.OutQuint; - Zoom = 10; ScrollbarVisible = false; } private WaveformGraph waveform; [BackgroundDependencyLoader] - private void load(IBindable beatmap, IAdjustableClock adjustableClock, OsuColour colours) + private void load(IBindable beatmap, OsuColour colours) { - this.adjustableClock = adjustableClock; - Add(waveform = new WaveformGraph { RelativeSizeAxes = Axes.Both, @@ -47,7 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }); // We don't want the centre marker to scroll - AddInternal(new CentreMarker()); + AddInternal(new CentreMarker { Depth = float.MaxValue }); WaveformVisible.ValueChanged += visible => waveform.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); @@ -56,9 +58,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { waveform.Waveform = b.NewValue.Waveform; track = b.NewValue.Track; + + if (track.Length > 0) + { + MaxZoom = getZoomLevelForVisibleMilliseconds(500); + MinZoom = getZoomLevelForVisibleMilliseconds(10000); + Zoom = getZoomLevelForVisibleMilliseconds(2000); + } }, true); } + private float getZoomLevelForVisibleMilliseconds(double milliseconds) => (float)(track.Length / milliseconds); + /// /// The timeline's scroll position in the last frame. /// @@ -89,7 +100,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Content.Margin = new MarginPadding { Horizontal = DrawWidth / 2 }; // This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren - if (adjustableClock.IsRunning) + if (editorClock.IsRunning) scrollToTrackTime(); } @@ -99,21 +110,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (handlingDragInput) seekTrackToCurrent(); - else if (!adjustableClock.IsRunning) + else if (!editorClock.IsRunning) { // The track isn't running. There are two cases we have to be wary of: // 1) The user flick-drags on this timeline: We want the track to follow us // 2) The user changes the track time through some other means (scrolling in the editor or overview timeline): We want to follow the track time // The simplest way to cover both cases is by checking whether the scroll position has changed and the audio hasn't been changed externally - if (Current != lastScrollPosition && adjustableClock.CurrentTime == lastTrackTime) + if (Current != lastScrollPosition && editorClock.CurrentTime == lastTrackTime) seekTrackToCurrent(); else scrollToTrackTime(); } lastScrollPosition = Current; - lastTrackTime = adjustableClock.CurrentTime; + lastTrackTime = editorClock.CurrentTime; } private void seekTrackToCurrent() @@ -121,15 +132,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (!track.IsLoaded) return; - adjustableClock.Seek(Current / Content.DrawWidth * track.Length); + editorClock.Seek(Current / Content.DrawWidth * track.Length); } private void scrollToTrackTime() { - if (!track.IsLoaded) + if (!track.IsLoaded || track.Length == 0) return; - ScrollTo((float)(adjustableClock.CurrentTime / track.Length) * Content.DrawWidth, false); + ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false); } protected override bool OnMouseDown(MouseDownEvent e) @@ -143,24 +154,46 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return false; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { endUserDrag(); - return base.OnMouseUp(e); + base.OnMouseUp(e); } private void beginUserDrag() { handlingDragInput = true; - trackWasPlaying = adjustableClock.IsRunning; - adjustableClock.Stop(); + trackWasPlaying = editorClock.IsRunning; + editorClock.Stop(); } private void endUserDrag() { handlingDragInput = false; if (trackWasPlaying) - adjustableClock.Start(); + editorClock.Start(); } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); + + private double getTimeFromPosition(Vector2 localPosition) => + (localPosition.X / Content.DrawWidth) * track.Length; + + public float GetBeatSnapDistanceAt(double referenceTime) => throw new NotImplementedException(); + + public float DurationToDistance(double referenceTime, double duration) => throw new NotImplementedException(); + + public double DistanceToDuration(double referenceTime, float distance) => throw new NotImplementedException(); + + public double GetSnappedDurationFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); + + public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index 02e5db306d..b99a053859 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -2,11 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osuTK; @@ -31,7 +31,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("111") + Colour = Color4Extensions.FromHex("111") }, new GridContainer { @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("222") + Colour = Color4Extensions.FromHex("222") }, new FillFlowContainer { @@ -76,7 +76,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("333") + Colour = Color4Extensions.FromHex("333") }, new Container { diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs new file mode 100644 index 0000000000..84328466c3 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + internal class TimelineBlueprintContainer : BlueprintContainer + { + [Resolved(CanBeNull = true)] + private Timeline timeline { get; set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + private DragEvent lastDragEvent; + + private Bindable placement; + + private SelectionBlueprint placementBlueprint; + + public TimelineBlueprintContainer() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Height = 0.4f; + + AddInternal(new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DragBox.Alpha = 0; + + placement = beatmap.PlacementObject.GetBoundCopy(); + placement.ValueChanged += placementChanged; + } + + private void placementChanged(ValueChangedEvent obj) + { + if (obj.NewValue == null) + { + if (placementBlueprint != null) + { + SelectionBlueprints.Remove(placementBlueprint); + placementBlueprint = null; + } + } + else + { + placementBlueprint = CreateBlueprintFor(obj.NewValue); + + placementBlueprint.Colour = Color4.MediumPurple; + + SelectionBlueprints.Add(placementBlueprint); + } + } + + protected override Container CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + + protected override void OnDrag(DragEvent e) + { + handleScrollViaDrag(e); + + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + lastDragEvent = null; + } + + protected override void Update() + { + // trigger every frame so drags continue to update selection while playback is scrolling the timeline. + if (lastDragEvent != null) + OnDrag(lastDragEvent); + + base.Update(); + } + + protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => new TimelineHitObjectBlueprint(hitObject) + { + OnDragHandled = handleScrollViaDrag + }; + + protected override DragBox CreateDragBox(Action performSelect) => new TimelineDragBox(performSelect); + + private void handleScrollViaDrag(DragEvent e) + { + lastDragEvent = e; + + if (lastDragEvent == null) + return; + + if (timeline != null) + { + var timelineQuad = timeline.ScreenSpaceDrawQuad; + var mouseX = e.ScreenSpaceMousePosition.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)); + } + } + + internal class TimelineSelectionHandler : SelectionHandler + { + // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + } + + private class TimelineDragBox : DragBox + { + private Vector2 lastMouseDown; + private float localMouseDown; + + public TimelineDragBox(Action performSelect) + : base(performSelect) + { + } + + protected override Drawable CreateBox() => new Box + { + RelativeSizeAxes = Axes.Y, + Alpha = 0.3f + }; + + public override bool HandleDrag(MouseButtonEvent e) + { + // store the original position of the mouse down, as we may be scrolled during selection. + if (lastMouseDown != e.ScreenSpaceMouseDownPosition) + { + lastMouseDown = e.ScreenSpaceMouseDownPosition; + localMouseDown = e.MouseDownPosition.X; + } + + float selection1 = localMouseDown; + float selection2 = e.MousePosition.X; + + Box.X = Math.Min(selection1, selection2); + Box.Width = Math.Abs(selection1 - selection2); + + PerformSelection?.Invoke(Box.ScreenSpaceDrawQuad.AABBFloat); + return true; + } + } + + protected class TimelineSelectionBlueprintContainer : Container + { + protected override Container Content { get; } + + public TimelineSelectionBlueprintContainer() + { + AddInternal(new TimelinePart(Content = new Container { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs index 8865bf31ea..5550c6a748 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs @@ -6,10 +6,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -52,6 +55,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline HoverColour = OsuColour.Gray(0.25f); FlashColour = OsuColour.Gray(0.5f); } + + private ScheduledDelegate repeatSchedule; + + /// + /// The initial delay before mouse down repeat begins. + /// + private const int repeat_initial_delay = 250; + + /// + /// The delay between mouse down repeats after the initial repeat. + /// + private const int repeat_tick_rate = 70; + + protected override bool OnClick(ClickEvent e) + { + // don't actuate a click since we are manually handling repeats. + return true; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + { + Action clickAction = () => base.OnClick(new ClickEvent(e.CurrentState, e.Button)); + + // run once for initial down + clickAction(); + + Scheduler.Add(repeatSchedule = new ScheduledDelegate(clickAction, Clock.CurrentTime + repeat_initial_delay, repeat_tick_rate)); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + repeatSchedule?.Cancel(); + base.OnMouseUp(e); + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs new file mode 100644 index 0000000000..b95b3842b3 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -0,0 +1,316 @@ +// Copyright (c) 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 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.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineHitObjectBlueprint : SelectionBlueprint + { + private readonly Circle circle; + + [UsedImplicitly] + private readonly Bindable startTime; + + public Action OnDragHandled; + + private readonly DragBar dragBar; + + private readonly List shadowComponents = new List(); + + private const float thickness = 5; + + private const float shadow_radius = 5; + + private const float circle_size = 16; + + public TimelineHitObjectBlueprint(HitObject hitObject) + : base(hitObject) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + startTime = hitObject.StartTimeBindable.GetBoundCopy(); + startTime.BindValueChanged(time => X = (float)time.NewValue, true); + + RelativePositionAxes = Axes.X; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + circle = new Circle + { + Size = new Vector2(circle_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + AlwaysPresent = true, + Colour = Color4.White, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = shadow_radius, + Colour = Color4.Black + }, + }; + + shadowComponents.Add(circle); + + if (hitObject is IHasDuration) + { + DragBar dragBarUnderlay; + Container extensionBar; + + AddRangeInternal(new Drawable[] + { + extensionBar = new Container + { + Masking = true, + Size = new Vector2(1, thickness), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativePositionAxes = Axes.X, + RelativeSizeAxes = Axes.X, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = shadow_radius, + Colour = Color4.Black + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + }, + circle, + // only used for drawing the shadow + dragBarUnderlay = new DragBar(null), + // cover up the shadow on the join + new Box + { + Height = thickness, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + }, + dragBar = new DragBar(hitObject) { OnDragHandled = e => OnDragHandled?.Invoke(e) }, + }); + + shadowComponents.Add(dragBarUnderlay); + shadowComponents.Add(extensionBar); + } + else + { + AddInternal(circle); + } + + updateShadows(); + } + + protected override void Update() + { + base.Update(); + + // no bindable so we perform this every update + Width = (float)(HitObject.GetEndTime() - HitObject.StartTime); + } + + protected override bool ShouldBeConsideredForInput(Drawable child) => true; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + base.ReceivePositionalInputAt(screenSpacePos) || + circle.ReceivePositionalInputAt(screenSpacePos) || + dragBar?.ReceivePositionalInputAt(screenSpacePos) == true; + + protected override void OnSelected() + { + updateShadows(); + } + + private void updateShadows() + { + foreach (var s in shadowComponents) + { + if (State == SelectionState.Selected) + { + s.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = shadow_radius / 2, + Colour = Color4.Orange, + }; + } + else + { + s.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = shadow_radius, + Colour = State == SelectionState.Selected ? Color4.Orange : Color4.Black + }; + } + } + } + + protected override void OnDeselected() + { + updateShadows(); + } + + public override Quad SelectionQuad + { + get + { + // correctly include the circle in the selection quad region, as it is usually outside the blueprint itself. + var leftQuad = circle.ScreenSpaceDrawQuad; + var rightQuad = dragBar?.ScreenSpaceDrawQuad ?? ScreenSpaceDrawQuad; + + return new Quad(leftQuad.TopLeft, Vector2.ComponentMax(rightQuad.TopRight, leftQuad.TopRight), + leftQuad.BottomLeft, Vector2.ComponentMax(rightQuad.BottomRight, leftQuad.BottomRight)); + } + } + + public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; + + public class DragBar : Container + { + private readonly HitObject hitObject; + + [Resolved] + private Timeline timeline { get; set; } + + public Action OnDragHandled; + + public override bool HandlePositionalInput => hitObject != null; + + public DragBar(HitObject hitObject) + { + this.hitObject = hitObject; + + CornerRadius = 2; + Masking = true; + Size = new Vector2(5, 1); + Anchor = Anchor.CentreRight; + Origin = Anchor.Centre; + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private bool hasMouseDown; + + protected override bool OnMouseDown(MouseDownEvent e) + { + hasMouseDown = true; + updateState(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + hasMouseDown = false; + updateState(); + base.OnMouseUp(e); + } + + private void updateState() + { + Colour = IsHovered || hasMouseDown ? Color4.OrangeRed : Color4.White; + } + + [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; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + OnDragHandled?.Invoke(e); + + if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time) + { + switch (hitObject) + { + case IHasRepeats repeatHitObject: + // find the number of repeats which can fit in the requested time. + var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); + var proposedCount = Math.Max(0, (int)Math.Round((time - hitObject.StartTime) / lengthOfOneRepeat) - 1); + + if (proposedCount == repeatHitObject.RepeatCount) + return; + + repeatHitObject.RepeatCount = proposedCount; + break; + + case IHasDuration endTimeHitObject: + var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); + + if (endTimeHitObject.EndTime == snappedTime) + return; + + endTimeHitObject.Duration = snappedTime - hitObject.StartTime; + break; + } + + beatmap.UpdateHitObject(hitObject); + } + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + + OnDragHandled?.Invoke(null); + changeHandler?.EndChange(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectDisplay.cs deleted file mode 100644 index b20f2fa11d..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectDisplay.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Edit.Compose.Components.Timeline -{ - internal class TimelineHitObjectDisplay : TimelinePart - { - private EditorBeatmap beatmap { get; } - - public TimelineHitObjectDisplay(EditorBeatmap beatmap) - { - RelativeSizeAxes = Axes.Both; - - this.beatmap = beatmap; - } - - [BackgroundDependencyLoader] - private void load() - { - foreach (var h in beatmap.HitObjects) - add(h); - - beatmap.HitObjectAdded += add; - beatmap.HitObjectRemoved += remove; - beatmap.StartTimeChanged += h => - { - remove(h); - add(h); - }; - } - - private void remove(HitObject h) - { - foreach (var d in Children.OfType().Where(c => c.HitObject == h)) - d.Expire(); - } - - private void add(HitObject h) - { - var yOffset = Children.Count(d => d.X == h.StartTime); - - Add(new TimelineHitObjectRepresentation(h) { Y = -yOffset * TimelineHitObjectRepresentation.THICKNESS }); - } - - private class TimelineHitObjectRepresentation : CompositeDrawable - { - public const float THICKNESS = 3; - - public readonly HitObject HitObject; - - public TimelineHitObjectRepresentation(HitObject hitObject) - { - HitObject = hitObject; - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - - Width = (float)(hitObject.GetEndTime() - hitObject.StartTime); - - X = (float)hitObject.StartTime; - - RelativePositionAxes = Axes.X; - RelativeSizeAxes = Axes.X; - - if (hitObject is IHasEndTime) - { - AddInternal(new Container - { - CornerRadius = 2, - Masking = true, - Size = new Vector2(1, THICKNESS), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativePositionAxes = Axes.X, - RelativeSizeAxes = Axes.X, - Colour = Color4.Black, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - } - }); - } - - AddInternal(new Circle - { - Size = new Vector2(16), - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.X, - AlwaysPresent = true, - Colour = Color4.White, - BorderColour = Color4.Black, - BorderThickness = THICKNESS, - }); - } - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs new file mode 100644 index 0000000000..36ee976bf7 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineTickDisplay : TimelinePart + { + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private Bindable working { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + public TimelineTickDisplay() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + beatDivisor.BindValueChanged(_ => createLines(), true); + } + + private void createLines() + { + Clear(); + + for (var i = 0; i < beatmap.ControlPointInfo.TimingPoints.Count; i++) + { + var point = beatmap.ControlPointInfo.TimingPoints[i]; + var until = i + 1 < beatmap.ControlPointInfo.TimingPoints.Count ? beatmap.ControlPointInfo.TimingPoints[i + 1].Time : working.Value.Track.Length; + + int beat = 0; + + for (double t = point.Time; t < until; t += point.BeatLength / beatDivisor.Value) + { + var indexInBeat = beat % beatDivisor.Value; + + if (indexInBeat == 0) + { + Add(new PointVisualisation(t) + { + Colour = BindableBeatDivisor.GetColourFor(1, colours), + Origin = Anchor.TopCentre, + }); + } + else + { + var divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value); + var colour = BindableBeatDivisor.GetColourFor(divisor, colours); + var height = 0.1f - (float)divisor / BindableBeatDivisor.VALID_DIVISORS.Last() * 0.08f; + + Add(new PointVisualisation(t) + { + Colour = colour, + Height = height, + Origin = Anchor.TopCentre, + }); + + Add(new PointVisualisation(t) + { + Colour = colour, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomCentre, + Height = height, + }); + } + + beat++; + } + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 54922fec5e..227eecf9c7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -2,11 +2,13 @@ // 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.Transforms; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Graphics.Containers; using osuTK; @@ -30,18 +32,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private float currentZoom = 1; + [Resolved(canBeNull: true)] + private IFrameBasedClock editorClock { get; set; } + public ZoomableScrollContainer() : base(Direction.Horizontal) { base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y }); } - private int minZoom = 1; + private float minZoom = 1; /// /// The minimum zoom level allowed. /// - public int MinZoom + public float MinZoom { get => minZoom; set @@ -56,12 +61,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - private int maxZoom = 60; + private float maxZoom = 60; /// /// The maximum zoom level allowed. /// - public int MaxZoom + public float MaxZoom { get => maxZoom; set @@ -104,8 +109,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override bool OnScroll(ScrollEvent e) { if (e.IsPrecise) + { + // can't handle scroll correctly while playing. + // the editor will handle this case for us. + if (editorClock?.IsRunning == true) + return false; + // for now, we don't support zoom when using a precision scroll device. this needs gesture support. return base.OnScroll(e); + } setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); return true; diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 1a6aae294a..04983ca597 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -25,13 +25,13 @@ namespace osu.Game.Screens.Edit.Compose // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation // full access to all skin sources. - var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider)); + var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, EditorBeatmap.PlayableBeatmap)); // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(composer)); } - protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineHitObjectDisplay(EditorBeatmap); + protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineBlueprintContainer(); } } diff --git a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs index 47a4277430..aefcbc6542 100644 --- a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs +++ b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs @@ -17,7 +17,8 @@ namespace osu.Game.Screens.Edit.Compose /// Notifies that a placement has finished. /// /// The that has been placed. - void EndPlacement(HitObject hitObject); + /// Whether the object should be committed. + void EndPlacement(HitObject hitObject, bool commit); /// /// Deletes a . diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 14d69cddd1..d92f3922c3 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -22,10 +22,13 @@ using osu.Game.Screens.Edit.Design; using osuTK.Input; using System.Collections.Generic; using osu.Framework; +using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Timing; @@ -34,7 +37,8 @@ using osu.Game.Users; namespace osu.Game.Screens.Edit { - public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler + [Cached(typeof(IBeatSnapProvider))] + public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider { public override float BackgroundParallaxAmount => 0.1f; @@ -44,6 +48,11 @@ namespace osu.Game.Screens.Edit public override bool DisallowExternalBeatmapRulesetChanges => true; + public override bool AllowRateAdjustments => false; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + private Box bottomBackground; private Container screenContainer; @@ -54,9 +63,9 @@ namespace osu.Game.Screens.Edit private IBeatmap playableBeatmap; private EditorBeatmap editorBeatmap; + private EditorChangeHandler changeHandler; private DependencyContainer dependencies; - private GameHost host; protected override UserActivity InitialActivity => new UserActivity.Editing(Beatmap.Value.BeatmapInfo); @@ -66,8 +75,6 @@ namespace osu.Game.Screens.Edit [BackgroundDependencyLoader] private void load(OsuColour colours, GameHost host) { - this.host = host; - beatDivisor.Value = Beatmap.Value.BeatmapInfo.BeatDivisor; beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue); @@ -76,27 +83,46 @@ namespace osu.Game.Screens.Edit clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false }; clock.ChangeSource(sourceClock); - playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); - editorBeatmap = new EditorBeatmap(playableBeatmap); + dependencies.CacheAs(clock); + AddInternal(clock); - dependencies.CacheAs(clock); - dependencies.CacheAs(clock); + // todo: remove caching of this and consume via editorBeatmap? dependencies.Cache(beatDivisor); - dependencies.CacheAs(editorBeatmap); - EditorMenuBar menuBar; - - var fileMenuItems = new List(); - - if (RuntimeInfo.IsDesktop) + try { - fileMenuItems.Add(new EditorMenuItem("Export", MenuItemType.Standard, exportBeatmap)); - fileMenuItems.Add(new EditorMenuItemSpacer()); + playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); + // couldn't load, hard abort! + this.Exit(); + return; } + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap)); + dependencies.CacheAs(editorBeatmap); + + changeHandler = new EditorChangeHandler(editorBeatmap); + dependencies.CacheAs(changeHandler); + + EditorMenuBar menuBar; + OsuMenuItem undoMenuItem; + OsuMenuItem redoMenuItem; + + var fileMenuItems = new List + { + new EditorMenuItem("Save", MenuItemType.Standard, saveBeatmap) + }; + + if (RuntimeInfo.IsDesktop) + fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + + fileMenuItems.Add(new EditorMenuItemSpacer()); fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); - InternalChild = new OsuContextMenuContainer + AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Children = new[] @@ -127,6 +153,14 @@ namespace osu.Game.Screens.Edit new MenuItem("File") { Items = fileMenuItems + }, + new MenuItem("Edit") + { + Items = new[] + { + undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo) + } } } } @@ -181,7 +215,10 @@ namespace osu.Game.Screens.Edit } }, } - }; + }); + + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); menuBar.Mode.ValueChanged += onModeChanged; @@ -194,6 +231,30 @@ namespace osu.Game.Screens.Edit clock.ProcessFrame(); } + public bool OnPressed(PlatformAction action) + { + switch (action.ActionType) + { + case PlatformActionType.Undo: + Undo(); + return true; + + case PlatformActionType.Redo: + Redo(); + return true; + + case PlatformActionType.Save: + saveBeatmap(); + return true; + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) @@ -214,11 +275,22 @@ namespace osu.Game.Screens.Edit protected override bool OnScroll(ScrollEvent e) { - scrollAccumulation += (e.ScrollDelta.X + e.ScrollDelta.Y) * (e.IsPrecise ? 0.1 : 1); + const double precision = 1; - const int precision = 1; + double scrollComponent = e.ScrollDelta.X + e.ScrollDelta.Y; - while (Math.Abs(scrollAccumulation) > precision) + double scrollDirection = Math.Sign(scrollComponent); + + // this is a special case to handle the "pivot" scenario. + // if we are precise scrolling in one direction then change our mind and scroll backwards, + // the existing accumulation should be applied in the inverse direction to maintain responsiveness. + if (Math.Sign(scrollAccumulation) != scrollDirection) + scrollAccumulation = scrollDirection * (precision - Math.Abs(scrollAccumulation)); + + scrollAccumulation += scrollComponent * (e.IsPrecise ? 0.1 : 1); + + // because we are doing snapped seeking, we need to add up precise scrolls until they accumulate to an arbitrary cut-off. + while (Math.Abs(scrollAccumulation) >= precision) { if (scrollAccumulation > 0) seek(e, -1); @@ -243,12 +315,8 @@ namespace osu.Game.Screens.Edit return false; } - public bool OnReleased(GlobalAction action) => action == GlobalAction.Back; - - public override void OnResuming(IScreen last) + public void OnReleased(GlobalAction action) { - base.OnResuming(last); - Beatmap.Value.Track?.Stop(); } public override void OnEntering(IScreen last) @@ -272,9 +340,12 @@ namespace osu.Game.Screens.Edit return base.OnExiting(next); } + protected void Undo() => changeHandler.RestoreState(-1); + + protected void Redo() => changeHandler.RestoreState(1); + private void resetTrack(bool seekToStart = false) { - Beatmap.Value.Track?.ResetSpeedAdjustments(); Beatmap.Value.Track?.Stop(); if (seekToStart) @@ -292,8 +363,6 @@ namespace osu.Game.Screens.Edit } } - private void exportBeatmap() => host.OpenFileExternally(Beatmap.Value.Save()); - private void onModeChanged(ValueChangedEvent e) { currentScreen?.Exit(); @@ -329,5 +398,19 @@ namespace osu.Game.Screens.Edit else clock.SeekForward(!clock.IsRunning, amount); } + + private void saveBeatmap() => beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap); + + private void exportBeatmap() + { + saveBeatmap(); + beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); + } + + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); + + public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); + + public int BeatDivisor => beatDivisor.Value; } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 6ed74dfdb0..23c8c9f605 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -4,15 +4,21 @@ using System; using System.Collections; 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.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit { - public class EditorBeatmap : IBeatmap + public class EditorBeatmap : Component, IBeatmap, IBeatSnapProvider { /// /// Invoked when a is added to this . @@ -25,22 +31,74 @@ namespace osu.Game.Screens.Edit public event Action HitObjectRemoved; /// - /// Invoked when the start time of a in this was changed. + /// Invoked when a is updated. /// - public event Action StartTimeChanged; + public event Action HitObjectUpdated; + + /// + /// All currently selected s. + /// + public readonly BindableList SelectedHitObjects = new BindableList(); + + /// + /// The current placement. Null if there's no active placement. + /// + public readonly Bindable PlacementObject = new Bindable(); public readonly IBeatmap PlayableBeatmap; + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + private readonly IBeatmapProcessor beatmapProcessor; + private readonly Dictionary> startTimeBindables = new Dictionary>(); public EditorBeatmap(IBeatmap playableBeatmap) { PlayableBeatmap = playableBeatmap; + beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset?.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); + foreach (var obj in HitObjects) trackStartTime(obj); } + private readonly HashSet pendingUpdates = new HashSet(); + private ScheduledDelegate scheduledUpdate; + + /// + /// Updates a , invoking and re-processing the beatmap. + /// + /// The to update. + public void UpdateHitObject([NotNull] HitObject hitObject) => updateHitObject(hitObject, false); + + private void updateHitObject([CanBeNull] HitObject hitObject, bool silent) + { + scheduledUpdate?.Cancel(); + + if (hitObject != null) + pendingUpdates.Add(hitObject); + + scheduledUpdate = Schedule(() => + { + beatmapProcessor?.PreProcess(); + + foreach (var obj in pendingUpdates) + obj.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty); + + beatmapProcessor?.PostProcess(); + + if (!silent) + { + foreach (var obj in pendingUpdates) + HitObjectUpdated?.Invoke(obj); + } + + pendingUpdates.Clear(); + }); + } + public BeatmapInfo BeatmapInfo { get => PlayableBeatmap.BeatmapInfo; @@ -63,37 +121,104 @@ namespace osu.Game.Screens.Edit private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; + /// + /// Adds a collection of s to this . + /// + /// The s to add. + public void AddRange(IEnumerable hitObjects) + { + foreach (var h in hitObjects) + Add(h); + } + /// /// Adds a to this . /// /// The to add. public void Add(HitObject hitObject) { - trackStartTime(hitObject); - // Preserve existing sorting order in the beatmap var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime); - mutableHitObjects.Insert(insertionIndex + 1, hitObject); + Insert(insertionIndex + 1, hitObject); + } + + /// + /// Inserts a into this . + /// + /// + /// It is the invoker's responsibility to make sure that sorting order is maintained. + /// + /// The index to insert the at. + /// The to insert. + public void Insert(int index, HitObject hitObject) + { + trackStartTime(hitObject); + + mutableHitObjects.Insert(index, hitObject); HitObjectAdded?.Invoke(hitObject); + updateHitObject(hitObject, true); } /// /// Removes a from this . /// /// The to add. - public void Remove(HitObject hitObject) + /// True if the has been removed, false otherwise. + public bool Remove(HitObject hitObject) { - if (!mutableHitObjects.Contains(hitObject)) - return; + int index = FindIndex(hitObject); - mutableHitObjects.Remove(hitObject); + if (index == -1) + return false; + + RemoveAt(index); + return true; + } + + /// + /// Finds the index of a in this . + /// + /// The to search for. + /// The index of . + public int FindIndex(HitObject hitObject) => mutableHitObjects.IndexOf(hitObject); + + /// + /// Removes a at an index in this . + /// + /// The index of the to remove. + public void RemoveAt(int index) + { + var hitObject = (HitObject)mutableHitObjects[index]; + + mutableHitObjects.RemoveAt(index); var bindable = startTimeBindables[hitObject]; bindable.UnbindAll(); startTimeBindables.Remove(hitObject); HitObjectRemoved?.Invoke(hitObject); + + updateHitObject(null, true); + } + + /// + /// Clears all from this . + /// + public void Clear() + { + var removed = HitObjects.ToList(); + + mutableHitObjects.Clear(); + + foreach (var b in startTimeBindables) + b.Value.UnbindAll(); + startTimeBindables.Clear(); + + foreach (var h in removed) + HitObjectRemoved?.Invoke(h); + + updateHitObject(null, true); } private void trackStartTime(HitObject hitObject) @@ -107,7 +232,7 @@ namespace osu.Game.Screens.Edit var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime); mutableHitObjects.Insert(insertionIndex + 1, hitObject); - StartTimeChanged?.Invoke(hitObject); + UpdateHitObject(hitObject); }; } @@ -121,5 +246,17 @@ namespace osu.Game.Screens.Edit return list.Count - 1; } + + public double SnapTime(double time, double? referenceTime) + { + var timingPoint = ControlPointInfo.TimingPointAt(referenceTime ?? time); + var beatLength = timingPoint.BeatLength / BeatDivisor; + + return timingPoint.Time + (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero) * beatLength; + } + + public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; + + public int BeatDivisor => beatDivisor?.Value ?? 1; } } diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs new file mode 100644 index 0000000000..1553c2d2ef --- /dev/null +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using osu.Framework.Bindables; +using osu.Game.Beatmaps.Formats; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + /// + /// Tracks changes to the . + /// + public class EditorChangeHandler : IEditorChangeHandler + { + public readonly Bindable CanUndo = new Bindable(); + public readonly Bindable CanRedo = new Bindable(); + + private readonly LegacyEditorBeatmapPatcher patcher; + private readonly List savedStates = new List(); + + private int currentState = -1; + + private readonly EditorBeatmap editorBeatmap; + private int bulkChangesStarted; + private bool isRestoring; + + public const int MAX_SAVED_STATES = 50; + + /// + /// Creates a new . + /// + /// The to track the s of. + public EditorChangeHandler(EditorBeatmap editorBeatmap) + { + this.editorBeatmap = editorBeatmap; + + editorBeatmap.HitObjectAdded += hitObjectAdded; + editorBeatmap.HitObjectRemoved += hitObjectRemoved; + editorBeatmap.HitObjectUpdated += hitObjectUpdated; + + patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); + + // Initial state. + SaveState(); + } + + private void hitObjectAdded(HitObject obj) => SaveState(); + + private void hitObjectRemoved(HitObject obj) => SaveState(); + + private void hitObjectUpdated(HitObject obj) => SaveState(); + + public void BeginChange() => bulkChangesStarted++; + + public void EndChange() + { + if (bulkChangesStarted == 0) + throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}."); + + if (--bulkChangesStarted == 0) + SaveState(); + } + + /// + /// Saves the current state. + /// + public void SaveState() + { + if (bulkChangesStarted > 0) + return; + + if (isRestoring) + return; + + if (currentState < savedStates.Count - 1) + savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1); + + if (savedStates.Count > MAX_SAVED_STATES) + savedStates.RemoveAt(0); + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(editorBeatmap).Encode(sw); + + savedStates.Add(stream.ToArray()); + } + + currentState = savedStates.Count - 1; + + updateBindables(); + } + + /// + /// Restores an older or newer state. + /// + /// The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used. + public void RestoreState(int direction) + { + if (bulkChangesStarted > 0) + return; + + if (savedStates.Count == 0) + return; + + int newState = Math.Clamp(currentState + direction, 0, savedStates.Count - 1); + if (currentState == newState) + return; + + isRestoring = true; + + patcher.Patch(savedStates[currentState], savedStates[newState]); + currentState = newState; + + isRestoring = false; + + updateBindables(); + } + + private void updateBindables() + { + CanUndo.Value = savedStates.Count > 0 && currentState > 0; + CanRedo.Value = currentState < savedStates.Count - 1; + } + } +} diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 93a5f19121..d4d0feb813 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -3,7 +3,9 @@ using System; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -13,7 +15,7 @@ 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 : DecoupleableInterpolatingFramedClock + public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { public readonly double TrackLength; @@ -21,12 +23,11 @@ namespace osu.Game.Screens.Edit private readonly BindableBeatDivisor beatDivisor; - public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) - { - this.beatDivisor = beatDivisor; + private readonly DecoupleableInterpolatingFramedClock underlyingClock; - ControlPointInfo = beatmap.Beatmap.ControlPointInfo; - TrackLength = beatmap.Track.Length; + public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) + : this(beatmap.Beatmap.ControlPointInfo, beatmap.Track.Length, beatDivisor) + { } public EditorClock(ControlPointInfo controlPointInfo, double trackLength, BindableBeatDivisor beatDivisor) @@ -35,6 +36,13 @@ namespace osu.Game.Screens.Edit ControlPointInfo = controlPointInfo; TrackLength = trackLength; + + underlyingClock = new DecoupleableInterpolatingFramedClock(); + } + + public EditorClock() + : this(new ControlPointInfo(), 1000, new BindableBeatDivisor()) + { } /// @@ -79,20 +87,22 @@ namespace osu.Game.Screens.Edit private void seek(int direction, bool snapped, double amount = 1) { + double current = CurrentTimeAccurate; + if (amount <= 0) throw new ArgumentException("Value should be greater than zero", nameof(amount)); - var timingPoint = ControlPointInfo.TimingPointAt(CurrentTime); + var timingPoint = ControlPointInfo.TimingPointAt(current); - if (direction < 0 && timingPoint.Time == CurrentTime) + if (direction < 0 && timingPoint.Time == current) // When going backwards and we're at the boundary of two timing points, we compute the seek distance with the timing point which we are seeking into - timingPoint = ControlPointInfo.TimingPointAt(CurrentTime - 1); + timingPoint = ControlPointInfo.TimingPointAt(current - 1); double seekAmount = timingPoint.BeatLength / beatDivisor.Value * amount; - double seekTime = CurrentTime + seekAmount * direction; + double seekTime = current + seekAmount * direction; if (!snapped || ControlPointInfo.TimingPoints.Count == 0) { - Seek(seekTime); + SeekTo(seekTime); return; } @@ -108,9 +118,14 @@ namespace osu.Game.Screens.Edit seekTime = timingPoint.Time + closestBeat * seekAmount; + // limit forward seeking to only up to the next timing point's start time. + var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); + if (seekTime > nextTimingPoint?.Time) + seekTime = nextTimingPoint.Time; + // Due to the rounding above, we may end up on the current beat. This will effectively cause 0 seeking to happen, but we don't want this. // Instead, we'll go to the next beat in the direction when this is the case - if (Precision.AlmostEquals(CurrentTime, seekTime)) + if (Precision.AlmostEquals(current, seekTime, 0.5f)) { closestBeat += direction > 0 ? 1 : -1; seekTime = timingPoint.Time + closestBeat * seekAmount; @@ -119,13 +134,99 @@ namespace osu.Game.Screens.Edit if (seekTime < timingPoint.Time && timingPoint != ControlPointInfo.TimingPoints.First()) seekTime = timingPoint.Time; - var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); - if (seekTime > nextTimingPoint?.Time) - seekTime = nextTimingPoint.Time; - // Ensure the sought point is within the boundaries seekTime = Math.Clamp(seekTime, 0, TrackLength); - Seek(seekTime); + SeekTo(seekTime); + } + + /// + /// The current time of this clock, include any active transform seeks performed via . + /// + public double CurrentTimeAccurate => + Transforms.OfType().FirstOrDefault()?.EndValue ?? CurrentTime; + + public double CurrentTime => underlyingClock.CurrentTime; + + public void Reset() + { + ClearTransforms(); + underlyingClock.Reset(); + } + + public void Start() + { + ClearTransforms(); + underlyingClock.Start(); + } + + public void Stop() + { + underlyingClock.Stop(); + } + + public bool Seek(double position) + { + ClearTransforms(); + return underlyingClock.Seek(position); + } + + public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); + + double IAdjustableClock.Rate + { + get => underlyingClock.Rate; + set => underlyingClock.Rate = value; + } + + double IClock.Rate => underlyingClock.Rate; + + public bool IsRunning => underlyingClock.IsRunning; + + public void ProcessFrame() => underlyingClock.ProcessFrame(); + + public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; + + public double FramesPerSecond => underlyingClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; + + public void ChangeSource(IClock source) => underlyingClock.ChangeSource(source); + + public IClock Source => underlyingClock.Source; + + public bool IsCoupled + { + get => underlyingClock.IsCoupled; + set => underlyingClock.IsCoupled = value; + } + + private const double transform_time = 300; + + public void SeekTo(double seekDestination) + { + if (IsRunning) + Seek(seekDestination); + else + transformSeekTo(seekDestination, transform_time, Easing.OutQuint); + } + + private void transformSeekTo(double seek, double duration = 0, Easing easing = Easing.None) + => this.TransformTo(this.PopulateTransform(new TransformSeek(), seek, duration, easing)); + + private double currentTime + { + get => underlyingClock.CurrentTime; + set => underlyingClock.Seek(value); + } + + private class TransformSeek : Transform + { + public override string TargetMember => nameof(currentTime); + + protected override void Apply(EditorClock clock, double time) => + clock.currentTime = Interpolation.ValueAt(time, StartValue, EndValue, StartTime, EndTime, Easing); + + protected override void ReadIntoStartValue(EditorClock clock) => StartValue = clock.currentTime; } } } diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index aa8d99b517..e9ff0b5598 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -18,9 +18,11 @@ namespace osu.Game.Screens.Edit private const float vertical_margins = 10; private const float horizontal_margins = 20; + private const float timeline_height = 110; + private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); - private TimelineArea timelineArea; + private Container timelineContainer; [BackgroundDependencyLoader(true)] private void load([CanBeNull] BindableBeatDivisor beatDivisor) @@ -32,66 +34,57 @@ namespace osu.Game.Screens.Edit Children = new Drawable[] { - new GridContainer + mainContent = new Container { + Name = "Main content", RelativeSizeAxes = Axes.Both, - Content = new[] + Padding = new MarginPadding { - new Drawable[] + Horizontal = horizontal_margins, + Top = vertical_margins + timeline_height, + Bottom = vertical_margins + }, + }, + new Container + { + Name = "Timeline", + RelativeSizeAxes = Axes.X, + Height = timeline_height, + Children = new Drawable[] + { + new Box { - new Container + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f) + }, + new Container + { + Name = "Timeline content", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, + Child = new GridContainer { - Name = "Timeline", RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + Content = new[] { - new Box + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f) - }, - new Container - { - Name = "Timeline content", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, - Child = new GridContainer + timelineContainer = new Container { RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = timelineArea = CreateTimelineArea() - }, - new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } - }, - }, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 90), - } + Padding = new MarginPadding { Right = 5 }, }, - } + new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } + }, + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 90), } - } - }, - new Drawable[] - { - mainContent = new Container - { - Name = "Main content", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, - } + }, } - }, - RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 110) } + } }, }; @@ -100,14 +93,20 @@ namespace osu.Game.Screens.Edit mainContent.Add(content); content.FadeInFromZero(300, Easing.OutQuint); - LoadComponentAsync(CreateTimelineContent(), timelineArea.Add); + LoadComponentAsync(new TimelineArea + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new TimelineTickDisplay(), + CreateTimelineContent(), + } + }, timelineContainer.Add); }); } protected abstract Drawable CreateMainContent(); protected virtual Drawable CreateTimelineContent() => new Container(); - - protected TimelineArea CreateTimelineArea() => new TimelineArea { RelativeSizeAxes = Axes.Both }; } } diff --git a/osu.Game/Screens/Edit/IEditorChangeHandler.cs b/osu.Game/Screens/Edit/IEditorChangeHandler.cs new file mode 100644 index 0000000000..c1328252d4 --- /dev/null +++ b/osu.Game/Screens/Edit/IEditorChangeHandler.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.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + /// + /// Interface for a component that manages changes in the . + /// + public interface IEditorChangeHandler + { + /// + /// Begins a bulk state change event. should be invoked soon after. + /// + /// + /// This should be invoked when multiple changes to the should be bundled together into one state change event. + /// When nested invocations are involved, a state change will not occur until an equal number of invocations of are received. + /// + /// + /// When a group of s are deleted, a single undo and redo state change should update the state of all . + /// + void BeginChange(); + + /// + /// Ends a bulk state change event. + /// + /// + /// This should be invoked as soon as possible after to cause a state change. + /// + void EndChange(); + } +} diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs new file mode 100644 index 0000000000..fc3dd4c105 --- /dev/null +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using DiffPlex; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.IO; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; + +namespace osu.Game.Screens.Edit +{ + /// + /// Patches an based on the difference between two legacy (.osu) states. + /// + public class LegacyEditorBeatmapPatcher + { + private readonly EditorBeatmap editorBeatmap; + + public LegacyEditorBeatmapPatcher(EditorBeatmap editorBeatmap) + { + this.editorBeatmap = editorBeatmap; + } + + public void Patch(byte[] currentState, byte[] newState) + { + // Diff the beatmaps + var result = new Differ().CreateLineDiffs(readString(currentState), readString(newState), true, false); + + // 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]"); + + var toRemove = new List(); + var toAdd = new List(); + + foreach (var block in result.DiffBlocks) + { + // Removed hitobjects + for (int i = 0; i < block.DeleteCountA; i++) + { + int hoIndex = block.DeleteStartA + i - oldHitObjectsIndex - 1; + + if (hoIndex < 0) + continue; + + toRemove.Add(hoIndex); + } + + // Added hitobjects + for (int i = 0; i < block.InsertCountB; i++) + { + int hoIndex = block.InsertStartB + i - newHitObjectsIndex - 1; + + if (hoIndex < 0) + continue; + + toAdd.Add(hoIndex); + } + } + + // 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(); + + // 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 (var i in toAdd) + editorBeatmap.Insert(i, newBeatmap.HitObjects[i]); + } + } + + private string readString(byte[] state) => Encoding.UTF8.GetString(state); + + private IBeatmap readBeatmap(byte[] state) + { + using (var stream = new MemoryStream(state)) + using (var reader = new LineBufferedReader(stream, true)) + { + var decoded = Decoder.GetDecoder(reader).Decode(reader); + decoded.BeatmapInfo.Ruleset = editorBeatmap.BeatmapInfo.Ruleset; + return new PassThroughWorkingBeatmap(decoded).GetPlayableBeatmap(editorBeatmap.BeatmapInfo.Ruleset); + } + } + + private class PassThroughWorkingBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public PassThroughWorkingBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetTrack() => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 96e3ab48f2..5c59cfbfe8 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -152,7 +152,7 @@ namespace osu.Game.Screens.Edit.Timing public HeaderText(string text) { Text = text.ToUpper(); - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Black); + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); } } diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs index ccf1582486..603fb77f31 100644 --- a/osu.Game/Screens/Edit/Timing/Section.cs +++ b/osu.Game/Screens/Edit/Timing/Section.cs @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Edit.Timing { checkbox = new OsuCheckbox { - LabelText = typeof(T).Name.Replace(typeof(ControlPoint).Name, string.Empty) + LabelText = typeof(T).Name.Replace(nameof(Beatmaps.ControlPoints.ControlPoint), string.Empty) } } }, diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index d9da3ff92d..a08a660e7e 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -7,7 +7,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; @@ -23,7 +22,7 @@ namespace osu.Game.Screens.Edit.Timing private Bindable selectedGroup = new Bindable(); [Resolved] - private IAdjustableClock clock { get; set; } + private EditorClock clock { get; set; } protected override Drawable CreateMainContent() => new GridContainer { @@ -50,7 +49,7 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.BindValueChanged(selected => { if (selected.NewValue != null) - clock.Seek(selected.NewValue.Time); + clock.SeekTo(selected.NewValue.Time); }); } @@ -62,7 +61,7 @@ namespace osu.Game.Screens.Edit.Timing private IBindableList controlGroups; [Resolved] - private IFrameBasedClock clock { get; set; } + private EditorClock clock { get; set; } [Resolved] protected IBindable Beatmap { get; private set; } diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 22fe0ad816..761f842c22 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -56,5 +56,14 @@ namespace osu.Game.Screens /// Whether mod rate adjustments are allowed to be applied. /// bool AllowRateAdjustments { get; } + + /// + /// Invoked when the back button has been pressed to close any overlays before exiting this . + /// + /// + /// Return true to block this from being exited after closing an overlay. + /// Return false if this should continue exiting. + /// + bool OnBackButton(); } } diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index 41ee01be20..0bfabdaa15 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -5,12 +5,14 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shaders; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Screens.Menu; -using osuTK; using osu.Framework.Screens; +using osu.Framework.Threading; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using IntroSequence = osu.Game.Configuration.IntroSequence; namespace osu.Game.Screens @@ -24,31 +26,12 @@ namespace osu.Game.Screens ValidForResume = false; } - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - base.LogoArriving(logo, resuming); - - logo.BeatMatching = false; - logo.Triangles = false; - logo.RelativePositionAxes = Axes.None; - logo.Origin = Anchor.BottomRight; - logo.Anchor = Anchor.BottomRight; - logo.Position = new Vector2(-40); - logo.Scale = new Vector2(0.2f); - - logo.Delay(500).FadeInFromZero(1000, Easing.OutQuint); - } - - protected override void LogoSuspending(OsuLogo logo) - { - base.LogoSuspending(logo); - logo.FadeOut(logo.Alpha * 400); - } - private OsuScreen loadableScreen; private ShaderPrecompiler precompiler; private IntroSequence introSequence; + private LoadingSpinner spinner; + private ScheduledDelegate spinnerShow; protected virtual OsuScreen CreateLoadableScreen() { @@ -68,6 +51,9 @@ namespace osu.Game.Screens case IntroSequence.Circles: return new IntroCircles(); + case IntroSequence.Welcome: + return new IntroWelcome(); + default: return new IntroTriangles(); } @@ -82,6 +68,17 @@ namespace osu.Game.Screens LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal); LoadComponentAsync(loadableScreen = CreateLoadableScreen()); + LoadComponentAsync(spinner = new LoadingSpinner(true, true) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(40), + }, _ => + { + AddInternal(spinner); + spinnerShow = Scheduler.AddDelayed(spinner.Show, 200); + }); + checkIfLoaded(); } @@ -93,7 +90,15 @@ namespace osu.Game.Screens return; } - this.Push(loadableScreen); + spinnerShow?.Cancel(); + + if (spinner.State.Value == Visibility.Visible) + { + spinner.Hide(); + Scheduler.AddDelayed(() => this.Push(loadableScreen), LoadingSpinner.TRANSITION_DURATION); + } + else + this.Push(loadableScreen); } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs index fac6b69e1f..be6ed9700c 100644 --- a/osu.Game/Screens/Menu/Button.cs +++ b/osu.Game/Screens/Menu/Button.cs @@ -6,6 +6,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,7 +16,6 @@ using osuTK.Graphics; using osuTK.Input; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics.Containers; -using osu.Framework.Audio.Track; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; @@ -132,7 +132,7 @@ namespace osu.Game.Screens.Menu private bool rightward; - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -194,10 +194,10 @@ namespace osu.Game.Screens.Menu return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { boxHoverLayer.FadeTo(0, 1000, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index ed8e4c70f9..30e5e9702e 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Menu public Action OnEdit; public Action OnExit; - public Action OnDirect; + public Action OnBeatmapListing; public Action OnSolo; public Action OnSettings; public Action OnMulti; @@ -130,7 +130,7 @@ namespace osu.Game.Screens.Menu buttonsTopLevel.Add(new Button(@"play", @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P)); buttonsTopLevel.Add(new Button(@"osu!editor", @"button-generic-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E)); - buttonsTopLevel.Add(new Button(@"osu!direct", @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnDirect?.Invoke(), 0, Key.D)); + buttonsTopLevel.Add(new Button(@"osu!direct", @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.D)); if (host.CanExit) buttonsTopLevel.Add(new Button(@"exit", string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); @@ -211,7 +211,9 @@ namespace osu.Game.Screens.Menu } } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private bool goBack() { @@ -299,15 +301,15 @@ namespace osu.Game.Screens.Menu case ButtonSystemState.Initial: logoDelayedAction?.Cancel(); logoDelayedAction = Scheduler.AddDelayed(() => - { - logoTrackingContainer.StopTracking(); + { + logoTrackingContainer.StopTracking(); - game?.Toolbar.Hide(); + game?.Toolbar.Hide(); - logo.ClearTransforms(targetMember: nameof(Position)); - logo.MoveTo(new Vector2(0.5f), 800, Easing.OutExpo); - logo.ScaleTo(1, 800, Easing.OutExpo); - }, buttonArea.Alpha * 150); + logo.ClearTransforms(targetMember: nameof(Position)); + logo.MoveTo(new Vector2(0.5f), 800, Easing.OutExpo); + logo.ScaleTo(1, 800, Easing.OutExpo); + }, buttonArea.Alpha * 150); break; case ButtonSystemState.TopLevel: diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs new file mode 100644 index 0000000000..d120eb21a8 --- /dev/null +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class ConfirmExitDialog : PopupDialog + { + public ConfirmExitDialog(Action confirm, Action cancel) + { + HeaderText = "Are you sure you want to exit?"; + BodyText = "Last chance to back out."; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Goodbye", + Action = confirm + }, + new PopupDialogCancelButton + { + Text = @"Just a little more", + Action = cancel + }, + }; + } + } +} diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs index bcab73715b..986de1edf0 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -33,6 +34,7 @@ namespace osu.Game.Screens.Menu private readonly OsuScreen nextScreen; private readonly Bindable currentUser = new Bindable(); + private FillFlowContainer fill; public Disclaimer(OsuScreen nextScreen = null) { @@ -49,16 +51,16 @@ namespace osu.Game.Screens.Menu { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.ExclamationTriangle, + Icon = FontAwesome.Solid.Flask, Size = new Vector2(icon_size), Y = icon_y, }, - new FillFlowContainer + fill = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Y = icon_y + icon_size, + Y = icon_y, Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Children = new Drawable[] @@ -71,6 +73,8 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Spacing = new Vector2(0, 2), + LayoutDuration = 2000, + LayoutEasing = Easing.OutQuint }, supportFlow = new LinkFlowContainer { @@ -86,23 +90,16 @@ namespace osu.Game.Screens.Menu } }; - textFlow.AddText("This is an ", t => t.Font = t.Font.With(Typeface.Exo, 30, FontWeight.Light)); - textFlow.AddText("early development build", t => t.Font = t.Font.With(Typeface.Exo, 30, FontWeight.SemiBold)); + textFlow.AddText("This project is an ongoing ", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Light)); + textFlow.AddText("work in progress", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.SemiBold)); - textFlow.AddParagraph("Things may not work as expected", t => t.Font = t.Font.With(size: 20)); textFlow.NewParagraph(); static void format(SpriteText t) => t.Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold); - textFlow.AddParagraph("Detailed bug reports are welcomed via github issues.", format); + textFlow.AddParagraph(getRandomTip(), t => t.Font = t.Font.With(Typeface.Torus, 20, FontWeight.SemiBold)); textFlow.NewParagraph(); - textFlow.AddText("Visit ", format); - textFlow.AddLink("discord.gg/ppy", "https://discord.gg/ppy", creationParameters: format); - textFlow.AddText(" to help out or follow progress!", format); - - textFlow.NewParagraph(); - textFlow.NewParagraph(); textFlow.NewParagraph(); iconColour = colours.Yellow; @@ -114,7 +111,7 @@ namespace osu.Game.Screens.Menu if (e.NewValue.IsSupporter) { - supportFlow.AddText("Thank you for supporting osu!", format); + supportFlow.AddText("Eternal thanks to you for supporting osu!", format); } else { @@ -125,7 +122,7 @@ namespace osu.Game.Screens.Menu heart = supportFlow.AddIcon(FontAwesome.Solid.Heart, t => { - t.Padding = new MarginPadding { Left = 5 }; + t.Padding = new MarginPadding { Left = 5, Top = 3 }; t.Font = t.Font.With(size: 12); t.Origin = Anchor.Centre; t.Colour = colours.Pink; @@ -139,11 +136,6 @@ namespace osu.Game.Screens.Menu }, true); } - private void animateHeart() - { - heart.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -155,15 +147,28 @@ namespace osu.Game.Screens.Menu { base.OnEntering(last); - icon.Delay(1000).FadeColour(iconColour, 200, Easing.OutQuint); - icon.Delay(1000) - .MoveToY(icon_y * 1.1f, 160, Easing.OutCirc) - .RotateTo(-10, 160, Easing.OutCirc) - .Then() - .MoveToY(icon_y, 160, Easing.InCirc) - .RotateTo(0, 160, Easing.InCirc); + icon.RotateTo(10); + icon.FadeOut(); + icon.ScaleTo(0.5f); + + icon.Delay(500).FadeIn(500).ScaleTo(1, 500, Easing.OutQuint); + + using (BeginDelayedSequence(3000, true)) + { + icon.FadeColour(iconColour, 200, Easing.OutQuint); + icon.MoveToY(icon_y * 1.3f, 500, Easing.OutCirc) + .RotateTo(-360, 520, Easing.OutQuint) + .Then() + .MoveToY(icon_y, 160, Easing.InQuart) + .FadeColour(Color4.White, 160); + + fill.Delay(520 + 160).MoveToOffset(new Vector2(0, 15), 160, Easing.OutQuart); + } supportFlow.FadeOut().Delay(2000).FadeIn(500); + double delay = 500; + foreach (var c in textFlow.Children) + c.FadeTo(0.001f).Delay(delay += 20).FadeIn(500); animateHeart(); @@ -178,5 +183,35 @@ namespace osu.Game.Screens.Menu this.Push(nextScreen); }); } + + private string getRandomTip() + { + string[] tips = + { + "You can press Ctrl-T anywhere in the game to toggle the toolbar!", + "You can press Ctrl-O anywhere in the game to access options!", + "All settings are dynamic and take effect in real-time. Try changing the skin while playing!", + "New features are coming online every update. Make sure to stay up-to-date!", + "If you find the UI too large or small, try adjusting UI scale in settings!", + "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", + "For now, osu!direct is available to all users on lazer. You can access it anywhere using Ctrl-D!", + "Seeking in replays is available by dragging on the difficulty bar at the bottom of the screen!", + "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", + "Try scrolling down in the mod select panel to find a bunch of new fun mods!", + "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!", + "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!", + "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!", + "Check out the \"timeshift\" multiplayer system, which has local permanent leaderboards and playlist support!", + "Toggle advanced frame / thread statistics with Ctrl-F11!", + "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!", + }; + + 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 aaa3a77e74..db2faeb60a 100644 --- a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs +++ b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs @@ -24,16 +24,13 @@ namespace osu.Game.Screens.Menu return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { if (action == GlobalAction.Back) { if (!Fired) AbortConfirm(); - return true; } - - return false; } } } diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs index aa9cee969c..d4cd073b7a 100644 --- a/osu.Game/Screens/Menu/IntroCircles.cs +++ b/osu.Game/Screens/Menu/IntroCircles.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Menu private void load(AudioManager audio) { if (MenuVoice.Value) - welcome = audio.Samples.Get(@"welcome"); + welcome = audio.Samples.Get(@"Intro/welcome"); } protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index df83e98494..5f91aaad15 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -7,7 +7,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -41,9 +41,9 @@ namespace osu.Game.Screens.Menu protected IBindable MenuMusic { get; private set; } - private WorkingBeatmap introBeatmap; + private WorkingBeatmap initialBeatmap; - protected Track Track { get; private set; } + protected Track Track => initialBeatmap?.Track; private readonly BindableDouble exitingVolumeFade = new BindableDouble(1); @@ -51,6 +51,8 @@ namespace osu.Game.Screens.Menu private SampleChannel seeya; + protected virtual string SeeyaSampleName => "Intro/seeya"; + private LeasedBindable beatmap; private MainMenu mainMenu; @@ -58,6 +60,11 @@ namespace osu.Game.Screens.Menu [Resolved] private AudioManager audio { get; set; } + /// + /// Whether the is provided by osu! resources, rather than a user beatmap. + /// + protected bool UsingThemedIntro { get; private set; } + [BackgroundDependencyLoader] private void load(OsuConfigManager config, SkinManager skinManager, BeatmapManager beatmaps, Framework.Game game) { @@ -66,44 +73,57 @@ namespace osu.Game.Screens.Menu MenuVoice = config.GetBindable(OsuSetting.MenuVoice); MenuMusic = config.GetBindable(OsuSetting.MenuMusic); - - seeya = audio.Samples.Get(@"seeya"); + seeya = audio.Samples.Get(SeeyaSampleName); BeatmapSetInfo setInfo = null; + // if the user has requested not to play theme music, we should attempt to find a random beatmap from their collection. if (!MenuMusic.Value) { - var sets = beatmaps.GetAllUsableBeatmapSets(); + var sets = beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal); + if (sets.Count > 0) - setInfo = beatmaps.QueryBeatmapSet(s => s.ID == sets[RNG.Next(0, sets.Count - 1)].ID); - } - - if (setInfo == null) - { - setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); - - if (setInfo == null) { - // we need to import the default menu background beatmap - setInfo = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).Result; - - setInfo.Protected = true; - beatmaps.Update(setInfo); + setInfo = beatmaps.QueryBeatmapSet(s => s.ID == sets[RNG.Next(0, sets.Count - 1)].ID); + initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); } } - introBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); - Track = introBeatmap.Track; - } + // we generally want a song to be playing on startup, so use the intro music even if a user has specified not to if no other track is available. + if (setInfo == null) + { + if (!loadThemedIntro()) + { + // if we detect that the theme track or beatmap is unavailable this is either first startup or things are in a bad state. + // this could happen if a user has nuked their files store. for now, reimport to repair this. + var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).Result; + import.Protected = true; + beatmaps.Update(import); - public override bool OnExiting(IScreen next) => !DidLoadMenu; + loadThemedIntro(); + } + } + + bool loadThemedIntro() + { + setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); + + if (setInfo != null) + { + initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); + UsingThemedIntro = !(Track is TrackVirtual); + } + + return UsingThemedIntro; + } + } public override void OnResuming(IScreen last) { this.FadeIn(300); double fadeOutTime = exit_delay; - //we also handle the exit transition. + // we also handle the exit transition. if (MenuVoice.Value) seeya.Play(); else @@ -121,7 +141,7 @@ namespace osu.Game.Screens.Menu public override void OnSuspending(IScreen next) { base.OnSuspending(next); - Track = null; + initialBeatmap = null; } protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); @@ -129,7 +149,7 @@ namespace osu.Game.Screens.Menu 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 (MenuMusic.Value) + if (UsingThemedIntro) Track.Restart(); } @@ -143,8 +163,7 @@ namespace osu.Game.Screens.Menu if (!resuming) { - beatmap.Value = introBeatmap; - introBeatmap = null; + beatmap.Value = initialBeatmap; logo.MoveTo(new Vector2(0.5f)); logo.ScaleTo(Vector2.One); diff --git a/osu.Game/Screens/Menu/IntroSequence.cs b/osu.Game/Screens/Menu/IntroSequence.cs index e2dd14b18c..6731fef6f7 100644 --- a/osu.Game/Screens/Menu/IntroSequence.cs +++ b/osu.Game/Screens/Menu/IntroSequence.cs @@ -94,7 +94,7 @@ namespace osu.Game.Screens.Menu }, } }, - bigRing = new Ring(OsuColour.FromHex(@"B6C5E9"), 0.85f), + bigRing = new Ring(Color4Extensions.FromHex(@"B6C5E9"), 0.85f), mediumRing = new Ring(Color4.White.Opacity(130), 0.7f), smallRing = new Ring(Color4.White, 0.6f), welcomeText = new OsuSpriteText @@ -121,7 +121,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Height = 0, - Colour = OsuColour.FromHex(@"C6D8FF").Opacity(160), + Colour = Color4Extensions.FromHex(@"C6D8FF").Opacity(160), }, foregroundFill = new Box { @@ -139,28 +139,28 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Position = new Vector2(0, circle_offset), - Colour = OsuColour.FromHex(@"AA92FF"), + Colour = Color4Extensions.FromHex(@"AA92FF"), }, blueCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.CentreRight, Position = new Vector2(-circle_offset, 0), - Colour = OsuColour.FromHex(@"8FE5FE"), + Colour = Color4Extensions.FromHex(@"8FE5FE"), }, yellowCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.BottomCentre, Position = new Vector2(0, -circle_offset), - Colour = OsuColour.FromHex(@"FFD64C"), + Colour = Color4Extensions.FromHex(@"FFD64C"), }, pinkCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.CentreLeft, Position = new Vector2(circle_offset, 0), - Colour = OsuColour.FromHex(@"e967a1"), + Colour = Color4Extensions.FromHex(@"e967a1"), }, }; diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index c86f1393a4..b56ba6c8a4 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.IO; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -12,8 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -44,18 +42,16 @@ namespace osu.Game.Screens.Menu private SampleChannel welcome; [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { - if (MenuVoice.Value && !MenuMusic.Value) - welcome = audio.Samples.Get(@"welcome"); + if (MenuVoice.Value && !UsingThemedIntro) + welcome = audio.Samples.Get(@"Intro/welcome"); } protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); - logo.Triangles = true; - if (!resuming) { PrepareMenuLoad(); @@ -63,7 +59,7 @@ namespace osu.Game.Screens.Menu LoadComponentAsync(new TrianglesIntroSequence(logo, background) { RelativeSizeAxes = Axes.Both, - Clock = new FramedClock(MenuMusic.Value ? Track : null), + Clock = new FramedClock(UsingThemedIntro ? Track : null), LoadMenu = LoadMenu }, t => { @@ -90,7 +86,7 @@ namespace osu.Game.Screens.Menu private RulesetFlow rulesets; private Container rulesetsScale; private Container logoContainerSecondary; - private Drawable lazerLogo; + private LazerLogo lazerLogo; private GlitchingTriangles triangles; @@ -102,13 +98,12 @@ namespace osu.Game.Screens.Menu this.background = background; } - private OsuGameBase game; + [Resolved] + private OsuGameBase game { get; set; } [BackgroundDependencyLoader] - private void load(TextureStore textures, OsuGameBase game) + private void load(TextureStore textures) { - this.game = game; - InternalChildren = new Drawable[] { triangles = new GlitchingTriangles @@ -142,10 +137,10 @@ namespace osu.Game.Screens.Menu RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = lazerLogo = new LazerLogo(textures.GetStream("Menu/logo-triangles.mp4")) + Child = lazerLogo = new LazerLogo { Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = Anchor.Centre } }, }; @@ -221,6 +216,9 @@ namespace osu.Game.Screens.Menu // matching flyte curve y = 0.25x^2 + (max(0, x - 0.7) / 0.3) ^ 5 lazerLogo.FadeIn().ScaleTo(scale_start).Then().Delay(logo_scale_duration * 0.7f).ScaleTo(scale_start - scale_adjust, logo_scale_duration * 0.3f, Easing.InQuint); + + lazerLogo.TransformTo(nameof(LazerLogo.Progress), 1f, logo_scale_duration); + logoContainerSecondary.ScaleTo(scale_start).Then().ScaleTo(scale_start - scale_adjust * 0.25f, logo_scale_duration, Easing.InQuad); } @@ -262,14 +260,40 @@ namespace osu.Game.Screens.Menu private class LazerLogo : CompositeDrawable { - public LazerLogo(Stream videoStream) + private HueAnimation highlight, background; + + public float Progress + { + get => background.AnimationProgress; + set + { + background.AnimationProgress = value; + highlight.AnimationProgress = value; + } + } + + public LazerLogo() { Size = new Vector2(960); + } - InternalChild = new VideoSprite(videoStream) + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Clock = new FramedOffsetClock(Clock) { Offset = -logo_1 } + highlight = new HueAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-highlight"), + Colour = Color4.White, + }, + background = new HueAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }, }; } } diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs new file mode 100644 index 0000000000..bf42e36e8c --- /dev/null +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -0,0 +1,152 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Screens.Backgrounds; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public class IntroWelcome : IntroScreen + { + protected override string BeatmapHash => "64e00d7022195959bfa3109d09c2e2276c8f12f486b91fcf6175583e973b48f2"; + protected override string BeatmapFile => "welcome.osz"; + private const double delay_step_two = 2142; + private SampleChannel welcome; + private SampleChannel pianoReverb; + protected override string SeeyaSampleName => "Intro/Welcome/seeya"; + + protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) + { + Alpha = 0, + }; + + private BackgroundScreenDefault background; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + if (MenuVoice.Value) + welcome = audio.Samples.Get(@"Intro/Welcome/welcome"); + + pianoReverb = audio.Samples.Get(@"Intro/Welcome/welcome_piano"); + + Track.Looping = true; + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + LoadComponentAsync(new WelcomeIntroSequence + { + RelativeSizeAxes = Axes.Both + }, intro => + { + PrepareMenuLoad(); + + intro.LogoVisualisation.AddAmplitudeSource(pianoReverb); + + AddInternal(intro); + + welcome?.Play(); + pianoReverb?.Play(); + + Scheduler.AddDelayed(() => + { + StartTrack(); + + const float fade_in_time = 200; + + logo.ScaleTo(1); + logo.FadeIn(fade_in_time); + + background.FadeIn(fade_in_time); + + LoadMenu(); + }, delay_step_two); + }); + } + } + + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + background.FadeOut(100); + } + + private class WelcomeIntroSequence : Container + { + private Sprite welcomeText; + private Container scaleContainer; + + public LogoVisualisation LogoVisualisation { get; private set; } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Origin = Anchor.Centre; + Anchor = Anchor.Centre; + + Children = new Drawable[] + { + scaleContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + LogoVisualisation = new LogoVisualisation + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.5f, + AccentColour = Color4.DarkBlue, + Size = new Vector2(0.96f) + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(480), + Colour = Color4.Black + }, + welcomeText = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = textures.Get(@"Intro/Welcome/welcome_text") + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + using (BeginDelayedSequence(0, true)) + { + scaleContainer.ScaleTo(0.9f).ScaleTo(1, delay_step_two).OnComplete(_ => Expire()); + scaleContainer.FadeInFromZero(1800); + + welcomeText.ScaleTo(new Vector2(1, 0)).ScaleTo(Vector2.One, 400, Easing.Out); + } + } + } + } +} diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 1a625f8d83..ebbb19636c 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -12,16 +12,20 @@ using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Skinning; -using osu.Game.Online.API; -using osu.Game.Users; 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.Extensions.Color4Extensions; +using osu.Framework.Utils; namespace osu.Game.Screens.Menu { + /// + /// A visualiser that reacts to music coming from beatmaps. + /// public class LogoVisualisation : Drawable, IHasAccentColour { private readonly IBindable beatmap = new Bindable(); @@ -65,68 +69,63 @@ namespace osu.Game.Screens.Menu public Color4 AccentColour { get; set; } + /// + /// The relative movement of bars based on input amplification. Defaults to 1. + /// + public float Magnitude { get; set; } = 1; + private readonly float[] frequencyAmplitudes = new float[256]; private IShader shader; private readonly Texture texture; - private Bindable user; - private Bindable skin; - public LogoVisualisation() { texture = Texture.WhitePixel; Blending = BlendingParameters.Additive; } + private readonly List amplitudeSources = new List(); + + public void AddAmplitudeSource(IHasAmplitudes amplitudeSource) + { + amplitudeSources.Add(amplitudeSource); + } + [BackgroundDependencyLoader] - private void load(ShaderManager shaders, IBindable beatmap, IAPIProvider api, SkinManager skinManager) + private void load(ShaderManager shaders, IBindable beatmap) { this.beatmap.BindTo(beatmap); shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); - user = api.LocalUser.GetBoundCopy(); - skin = skinManager.CurrentSkin.GetBoundCopy(); - - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); } + private readonly float[] temporalAmplitudes = new float[ChannelAmplitudes.AMPLITUDES_SIZE]; + private void updateAmplitudes() { - var track = beatmap.Value.TrackLoaded ? beatmap.Value.Track : null; - var effect = beatmap.Value.BeatmapLoaded ? beatmap.Value.Beatmap.ControlPointInfo.EffectPointAt(track?.CurrentTime ?? Time.Current) : null; + var effect = beatmap.Value.BeatmapLoaded && beatmap.Value.TrackLoaded + ? beatmap.Value.Beatmap?.ControlPointInfo.EffectPointAt(beatmap.Value.Track.CurrentTime) + : null; - float[] temporalAmplitudes = track?.CurrentAmplitudes.FrequencyAmplitudes; + for (int i = 0; i < temporalAmplitudes.Length; i++) + temporalAmplitudes[i] = 0; + + if (beatmap.Value.TrackLoaded) + addAmplitudesFromSource(beatmap.Value.Track); + + foreach (var source in amplitudeSources) + addAmplitudesFromSource(source); for (int i = 0; i < bars_per_visualiser; i++) { - if (track?.IsRunning ?? false) - { - float targetAmplitude = (temporalAmplitudes?[(i + indexOffset) % bars_per_visualiser] ?? 0) * (effect?.KiaiMode == true ? 1 : 0.5f); - if (targetAmplitude > frequencyAmplitudes[i]) - frequencyAmplitudes[i] = targetAmplitude; - } - else - { - int index = (i + index_change) % bars_per_visualiser; - if (frequencyAmplitudes[index] > frequencyAmplitudes[i]) - frequencyAmplitudes[i] = frequencyAmplitudes[index]; - } + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (effect?.KiaiMode == true ? 1 : 0.5f); + if (targetAmplitude > frequencyAmplitudes[i]) + frequencyAmplitudes[i] = targetAmplitude; } indexOffset = (indexOffset + index_change) % bars_per_visualiser; } - private void updateColour() - { - Color4 defaultColour = Color4.White.Opacity(0.2f); - - if (user.Value?.IsSupporter ?? false) - AccentColour = skin.Value.GetConfig(GlobalSkinColour.MenuGlow)?.Value ?? defaultColour; - else - AccentColour = defaultColour; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -149,11 +148,24 @@ namespace osu.Game.Screens.Menu frequencyAmplitudes[i] = 0; } - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); } protected override DrawNode CreateDrawNode() => new VisualisationDrawNode(this); + private void addAmplitudesFromSource([NotNull] IHasAmplitudes source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + var amplitudes = source.CurrentAmplitudes.FrequencyAmplitudes.Span; + + for (int i = 0; i < amplitudes.Length; i++) + { + if (i < temporalAmplitudes.Length) + temporalAmplitudes[i] += amplitudes[i]; + } + } + private class VisualisationDrawNode : DrawNode { protected new LogoVisualisation Source => (LogoVisualisation)base.Source; @@ -161,7 +173,7 @@ namespace osu.Game.Screens.Menu private IShader shader; private Texture texture; - //Assuming the logo is a circle, we don't need a second dimension. + // Assuming the logo is a circle, we don't need a second dimension. private float size; private Color4 colour; @@ -205,16 +217,16 @@ namespace osu.Game.Screens.Menu if (audioData[i] < amplitude_dead_zone) continue; - float rotation = MathHelper.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); + 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 + // 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 * (float)Math.Sqrt(2 * (1 - Math.Cos(MathHelper.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); - //The distance between the position and the sides of the bar. + 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. + // 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( @@ -230,7 +242,7 @@ namespace osu.Game.Screens.Menu colourInfo, null, vertexBatch.AddAction, - //barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. + // 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)); } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index b28d572b5c..57252d557e 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -1,24 +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 osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.IO; using osu.Game.Online.API; using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; using osu.Game.Screens.Backgrounds; -using osu.Game.Screens.Charts; using osu.Game.Screens.Edit; using osu.Game.Screens.Multi; using osu.Game.Screens.Select; @@ -64,13 +62,18 @@ namespace osu.Game.Screens.Menu protected override BackgroundScreen CreateBackground() => background; + internal Track Track { get; private set; } + private Bindable holdDelay; private Bindable loginDisplayed; private ExitConfirmOverlay exitConfirmOverlay; + private ParallaxContainer buttonsContainer; + private SongTicker songTicker; + [BackgroundDependencyLoader(true)] - private void load(DirectOverlay direct, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics) + private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, RankingsOverlay rankings, OsuConfigManager config, SessionStatics statics) { holdDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); loginDisplayed = statics.GetBindable(Static.LoginOverlayDisplayed); @@ -89,16 +92,15 @@ namespace osu.Game.Screens.Menu }); } - AddRangeInternal(new Drawable[] + AddRangeInternal(new[] { - new ParallaxContainer + buttonsContainer = new ParallaxContainer { ParallaxAmount = 0.01f, Children = new Drawable[] { buttons = new ButtonSystem { - OnChart = delegate { this.Push(new ChartListing()); }, OnEdit = delegate { this.Push(new Editor()); }, OnSolo = onSolo, OnMulti = delegate { this.Push(new Multiplayer()); }, @@ -107,6 +109,13 @@ namespace osu.Game.Screens.Menu } }, sideFlashes = new MenuSideFlashes(), + songTicker = new SongTicker + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 15, Top = 5 } + }, + exitConfirmOverlay?.CreateProxy() ?? Drawable.Empty() }); buttons.StateChanged += state => @@ -125,18 +134,22 @@ namespace osu.Game.Screens.Menu }; buttons.OnSettings = () => settings?.ToggleVisibility(); - buttons.OnDirect = () => direct?.ToggleVisibility(); + buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); + buttons.OnChart = () => rankings?.ShowSpotlights(); LoadComponentAsync(background = new BackgroundScreenDefault()); preloadSongSelect(); } + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + private void confirmAndExit() { if (exitConfirmed) return; exitConfirmed = true; - this.Exit(); + game?.PerformFromScreen(menu => menu.Exit()); } private void preloadSongSelect() @@ -156,22 +169,28 @@ namespace osu.Game.Screens.Menu return s; } + [Resolved] + private Storage storage { get; set; } + public override void OnEntering(IScreen last) { base.OnEntering(last); buttons.FadeInFromZero(500); - var track = Beatmap.Value.Track; + Track = Beatmap.Value.Track; var metadata = Beatmap.Value.Metadata; - if (last is IntroScreen && track != null) + if (last is IntroScreen && Track != null) { - if (!track.IsRunning) + if (!Track.IsRunning) { - track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * track.Length); - track.Start(); + Track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * Track.Length); + Track.Start(); } } + + if (storage is OsuStorage osuStorage && osuStorage.Error != OsuStorageError.None) + dialogOverlay?.Push(new StorageErrorDialog(osuStorage, osuStorage.Error)); } private bool exitConfirmed; @@ -190,7 +209,7 @@ namespace osu.Game.Screens.Menu buttons.State = ButtonSystemState.TopLevel; this.FadeIn(FADE_IN_DURATION, Easing.OutQuint); - this.MoveTo(new Vector2(0, 0), FADE_IN_DURATION, Easing.OutQuint); + buttonsContainer.MoveTo(new Vector2(0, 0), FADE_IN_DURATION, Easing.OutQuint); sideFlashes.Delay(FADE_IN_DURATION).FadeIn(64, Easing.InQuint); } @@ -227,7 +246,7 @@ namespace osu.Game.Screens.Menu buttons.State = ButtonSystemState.EnteringMode; this.FadeOut(FADE_OUT_DURATION, Easing.InSine); - this.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); + buttonsContainer.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); sideFlashes.FadeOut(64, Easing.OutQuint); } @@ -238,11 +257,10 @@ namespace osu.Game.Screens.Menu (Background as BackgroundScreenDefault)?.Next(); - //we may have consumed our preloaded instance, so let's make another. + // we may have consumed our preloaded instance, so let's make another. preloadSongSelect(); - if (Beatmap.Value.Track != null && music?.IsUserPaused != true) - Beatmap.Value.Track.Start(); + music.EnsurePlayingSomething(); } public override bool OnExiting(IScreen next) @@ -262,33 +280,11 @@ namespace osu.Game.Screens.Menu } buttons.State = ButtonSystemState.Exit; + + songTicker.Hide(); + this.FadeOut(3000); return base.OnExiting(next); } - - private class ConfirmExitDialog : PopupDialog - { - public ConfirmExitDialog(Action confirm, Action cancel) - { - HeaderText = "Are you sure you want to exit?"; - BodyText = "Last chance to back out."; - - Icon = FontAwesome.Solid.ExclamationTriangle; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Good bye", - Action = confirm - }, - new PopupDialogCancelButton - { - Text = @"Just a little more", - Action = cancel - }, - }; - } - } } } diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs new file mode 100644 index 0000000000..5eb3f1efa0 --- /dev/null +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.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 osuTK.Graphics; +using osu.Game.Skinning; +using osu.Game.Online.API; +using osu.Game.Users; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; + +namespace osu.Game.Screens.Menu +{ + internal class MenuLogoVisualisation : LogoVisualisation + { + private Bindable user; + private Bindable skin; + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, SkinManager skinManager) + { + user = api.LocalUser.GetBoundCopy(); + skin = skinManager.CurrentSkin.GetBoundCopy(); + + user.ValueChanged += _ => updateColour(); + skin.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + Color4 defaultColour = Color4.White.Opacity(0.2f); + + if (user.Value?.IsSupporter ?? false) + AccentColour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? defaultColour; + else + AccentColour = defaultColour; + } + } +} diff --git a/osu.Game/Screens/Menu/MenuSideFlashes.cs b/osu.Game/Screens/Menu/MenuSideFlashes.cs index 3a88cda4ef..2ff8132d47 100644 --- a/osu.Game/Screens/Menu/MenuSideFlashes.cs +++ b/osu.Game/Screens/Menu/MenuSideFlashes.cs @@ -3,7 +3,6 @@ using osuTK.Graphics; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -16,6 +15,7 @@ using osu.Game.Skinning; using osu.Game.Online.API; using osu.Game.Users; using System; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; namespace osu.Game.Screens.Menu @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Menu skin.BindValueChanged(_ => updateColour(), true); } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (beatIndex < 0) return; @@ -100,7 +100,7 @@ namespace osu.Game.Screens.Menu flash(rightBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes); } - private void flash(Drawable d, double beatLength, bool kiai, TrackAmplitudes amplitudes) + private void flash(Drawable d, double beatLength, bool kiai, ChannelAmplitudes amplitudes) { d.FadeTo(Math.Max(0, ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier)), box_fade_in_time) .Then() @@ -112,7 +112,7 @@ namespace osu.Game.Screens.Menu Color4 baseColour = colours.Blue; if (user.Value?.IsSupporter ?? false) - baseColour = skin.Value.GetConfig(GlobalSkinColour.MenuGlow)?.Value ?? baseColour; + baseColour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? baseColour; // linear colour looks better in this case, so let's use it for now. Color4 gradientDark = baseColour.Opacity(0).ToLinear(); diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 534400e720..089906c342 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -6,15 +6,15 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osuTK; @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Menu /// public class OsuLogo : BeatSyncedContainer { - public readonly Color4 OsuPink = OsuColour.FromHex(@"e967a1"); + public readonly Color4 OsuPink = Color4Extensions.FromHex(@"e967a1"); private const double transition_length = 300; @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Menu private readonly Container logoBeatContainer; private readonly Container logoAmplitudeContainer; private readonly Container logoHoverContainer; - private readonly LogoVisualisation visualizer; + private readonly MenuLogoVisualisation visualizer; private readonly IntroSequence intro; @@ -139,7 +139,7 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both, Children = new Drawable[] { - visualizer = new LogoVisualisation + visualizer = new MenuLogoVisualisation { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, @@ -176,8 +176,8 @@ namespace osu.Game.Screens.Menu triangles = new Triangles { TriangleScale = 4, - ColourLight = OsuColour.FromHex(@"ff7db7"), - ColourDark = OsuColour.FromHex(@"de5b95"), + ColourLight = Color4Extensions.FromHex(@"ff7db7"), + ColourDark = Color4Extensions.FromHex(@"de5b95"), RelativeSizeAxes = Axes.Both, }, } @@ -264,7 +264,7 @@ namespace osu.Game.Screens.Menu private int lastBeatIndex; - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -353,12 +353,11 @@ namespace osu.Game.Screens.Menu return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button != MouseButton.Left) return false; + if (e.Button != MouseButton.Left) return; logoBounceContainer.ScaleTo(1f, 500, Easing.OutElastic); - return true; } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Menu/SongTicker.cs b/osu.Game/Screens/Menu/SongTicker.cs new file mode 100644 index 0000000000..c4943e77d5 --- /dev/null +++ b/osu.Game/Screens/Menu/SongTicker.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osu.Game.Graphics; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Framework.Localisation; + +namespace osu.Game.Screens.Menu +{ + public class SongTicker : Container + { + private const int fade_duration = 800; + + [Resolved] + private Bindable beatmap { get; set; } + + private readonly OsuSpriteText title, artist; + + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + + public SongTicker() + { + AutoSizeAxes = Axes.Both; + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + Children = new Drawable[] + { + title = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light, italics: true) + }, + artist = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 16) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => Scheduler.AddOnce(show), true); + } + + private void show() + { + var metadata = beatmap.Value.Metadata; + + title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); + artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + + this.FadeInFromZero(fade_duration / 2f) + .Delay(4000) + .Then().FadeOut(fade_duration); + } + } +} diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs new file mode 100644 index 0000000000..dcaad4013a --- /dev/null +++ b/osu.Game/Screens/Menu/StorageErrorDialog.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.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.IO; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class StorageErrorDialog : PopupDialog + { + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved] + private OsuGameBase osuGame { get; set; } + + public StorageErrorDialog(OsuStorage storage, OsuStorageError error) + { + HeaderText = "osu! storage error"; + Icon = FontAwesome.Solid.ExclamationTriangle; + + var buttons = new List(); + + switch (error) + { + case OsuStorageError.NotAccessible: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is not accessible. If it is on external storage, please reconnect the device and try again."; + + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Try again", + Action = () => + { + if (!storage.TryChangeToCustomStorage(out var nextError)) + dialogOverlay.Push(new StorageErrorDialog(storage, nextError)); + } + }, + new PopupDialogCancelButton + { + Text = "Use default location until restart", + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + break; + + case OsuStorageError.AccessibleButEmpty: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is empty. If you have moved the files, please close osu! and move them back."; + + // Todo: Provide the option to search for the files similar to migration. + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Start fresh at specified location" + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + + break; + } + + Buttons = buttons; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs b/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs new file mode 100644 index 0000000000..3f2ab28f1a --- /dev/null +++ b/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.Multi.Components +{ + public class BeatmapDetailAreaPlaylistTabItem : BeatmapDetailAreaTabItem + { + public override string Name => "Playlist"; + } +} diff --git a/osu.Game/Screens/Multi/Components/BeatmapTitle.cs b/osu.Game/Screens/Multi/Components/BeatmapTitle.cs index b41b2d073e..9e7a59d7d2 100644 --- a/osu.Game/Screens/Multi/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/Multi/Components/BeatmapTitle.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -25,7 +26,9 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - CurrentItem.BindValueChanged(_ => updateText(), true); + Playlist.CollectionChanged += (_, __) => updateText(); + + updateText(); } private float textSize = OsuFont.DEFAULT_FONT_SIZE; @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Multi.Components textFlow.Clear(); - var beatmap = CurrentItem.Value?.Beatmap; + var beatmap = Playlist.FirstOrDefault()?.Beatmap; if (beatmap == null) { @@ -70,7 +73,7 @@ namespace osu.Game.Screens.Multi.Components { new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = new LocalisedString((beatmap.Value.Metadata.ArtistUnicode, beatmap.Value.Metadata.Artist)), Font = OsuFont.GetFont(size: TextSize), }, new OsuSpriteText @@ -80,10 +83,10 @@ namespace osu.Game.Screens.Multi.Components }, new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title)), + Text = new LocalisedString((beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title)), Font = OsuFont.GetFont(size: TextSize), } - }, LinkAction.OpenBeatmap, beatmap.OnlineBeatmapID.ToString(), "Open beatmap"); + }, LinkAction.OpenBeatmap, beatmap.Value.OnlineBeatmapID.ToString(), "Open beatmap"); } } } diff --git a/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs b/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs index d63f2fecd2..ce3b612262 100644 --- a/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs +++ b/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,6 +13,8 @@ namespace osu.Game.Screens.Multi.Components { public class BeatmapTypeInfo : MultiplayerComposite { + private LinkFlowContainer beatmapAuthor; + public BeatmapTypeInfo() { AutoSizeAxes = Axes.Both; @@ -20,8 +23,6 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - LinkFlowContainer beatmapAuthor; - InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -50,18 +51,22 @@ namespace osu.Game.Screens.Multi.Components } }; - CurrentItem.BindValueChanged(item => + Playlist.CollectionChanged += (_, __) => updateInfo(); + + updateInfo(); + } + + private void updateInfo() + { + beatmapAuthor.Clear(); + + var beatmap = Playlist.FirstOrDefault()?.Beatmap; + + if (beatmap != null) { - beatmapAuthor.Clear(); - - var beatmap = item.NewValue?.Beatmap; - - if (beatmap != null) - { - beatmapAuthor.AddText("mapped by ", s => s.Colour = OsuColour.Gray(0.8f)); - beatmapAuthor.AddUserLink(beatmap.Metadata.Author); - } - }, true); + beatmapAuthor.AddText("mapped by ", s => s.Colour = OsuColour.Gray(0.8f)); + beatmapAuthor.AddUserLink(beatmap.Value.Metadata.Author); + } } } } diff --git a/osu.Game/Screens/Multi/Components/DrawableGameType.cs b/osu.Game/Screens/Multi/Components/DrawableGameType.cs index f4941dd73a..28240f0796 100644 --- a/osu.Game/Screens/Multi/Components/DrawableGameType.cs +++ b/osu.Game/Screens/Multi/Components/DrawableGameType.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.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -27,7 +28,7 @@ namespace osu.Game.Screens.Multi.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"545454"), + Colour = Color4Extensions.FromHex(@"545454"), }, }; } diff --git a/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.cs new file mode 100644 index 0000000000..2c5fd2d397 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.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.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.Online.Multiplayer; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + public class MatchBeatmapDetailArea : BeatmapDetailArea + { + public Action CreateNewItem; + + public readonly Bindable SelectedItem = new Bindable(); + + [Resolved(typeof(Room))] + protected BindableList Playlist { get; private set; } + + private readonly Drawable playlistArea; + private readonly DrawableRoomPlaylist playlist; + + public MatchBeatmapDetailArea() + { + Add(playlistArea = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 10 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 10 }, + Child = playlist = new DrawableRoomPlaylist(true, false) + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Drawable[] + { + new TriangleButton + { + Text = "Add new playlist entry", + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Action = () => CreateNewItem?.Invoke() + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlist.Items.BindTo(Playlist); + playlist.SelectedItem.BindTo(SelectedItem); + } + + protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + base.OnTabChanged(tab, selectedMods); + + switch (tab) + { + case BeatmapDetailAreaPlaylistTabItem _: + playlistArea.Show(); + break; + + default: + playlistArea.Hide(); + break; + } + } + + protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Prepend(new BeatmapDetailAreaPlaylistTabItem()).ToArray(); + } +} diff --git a/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs b/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs index 6080458aec..0015feb26a 100644 --- a/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs +++ b/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs @@ -1,11 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.Drawables; -using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.Multi.Components @@ -46,17 +46,21 @@ namespace osu.Game.Screens.Multi.Components }, }; - CurrentItem.BindValueChanged(item => updateBeatmap(item.NewValue), true); - Type.BindValueChanged(type => gameTypeContainer.Child = new DrawableGameType(type.NewValue) { Size = new Vector2(height) }, true); + + Playlist.CollectionChanged += (_, __) => updateBeatmap(); + + updateBeatmap(); } - private void updateBeatmap(PlaylistItem item) + private void updateBeatmap() { + var item = Playlist.FirstOrDefault(); + if (item?.Beatmap != null) { drawableRuleset.FadeIn(transition_duration); - drawableRuleset.Child = new DifficultyIcon(item.Beatmap, item.Ruleset) { Size = new Vector2(height) }; + drawableRuleset.Child = new DifficultyIcon(item.Beatmap.Value, item.Ruleset.Value) { Size = new Vector2(height) }; } else drawableRuleset.FadeOut(transition_duration); diff --git a/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs b/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs index 968fa6e72e..2240e55e2f 100644 --- a/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs +++ b/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; @@ -10,6 +11,7 @@ namespace osu.Game.Screens.Multi.Components public class MultiplayerBackgroundSprite : MultiplayerComposite { private readonly BeatmapSetCoverType beatmapSetCoverType; + private UpdateableBeatmapBackgroundSprite sprite; public MultiplayerBackgroundSprite(BeatmapSetCoverType beatmapSetCoverType = BeatmapSetCoverType.Cover) { @@ -19,11 +21,16 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - UpdateableBeatmapBackgroundSprite sprite; - InternalChild = sprite = CreateBackgroundSprite(); - CurrentItem.BindValueChanged(item => sprite.Beatmap.Value = item.NewValue?.Beatmap, true); + Playlist.CollectionChanged += (_, __) => updateBeatmap(); + + updateBeatmap(); + } + + private void updateBeatmap() + { + sprite.Beatmap.Value = Playlist.FirstOrDefault()?.Beatmap.Value; } protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(beatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game/Screens/Multi/Components/OverlinedHeader.cs b/osu.Game/Screens/Multi/Components/OverlinedHeader.cs new file mode 100644 index 0000000000..7ec20c8cae --- /dev/null +++ b/osu.Game/Screens/Multi/Components/OverlinedHeader.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + /// + /// A header used in the multiplayer interface which shows text / details beneath a line. + /// + public class OverlinedHeader : MultiplayerComposite + { + private bool showLine = true; + + public bool ShowLine + { + get => showLine; + set + { + showLine = value; + line.Alpha = value ? 1 : 0; + } + } + + public Bindable Details = new Bindable(); + + private readonly Circle line; + private readonly OsuSpriteText details; + + public OverlinedHeader(string title) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Margin = new MarginPadding { Bottom = 5 }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + line = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2, + Margin = new MarginPadding { Bottom = 2 } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Top = 5 }, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = title, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + details = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + } + }, + } + }; + + Details.BindValueChanged(val => details.Text = val.NewValue); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + line.Colour = colours.Yellow; + details.Colour = colours.Yellow; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs new file mode 100644 index 0000000000..6ea4283379 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.Multi.Components +{ + public class ParticipantsDisplay : MultiplayerComposite + { + public Bindable Details = new Bindable(); + + public ParticipantsDisplay(Direction direction) + { + OsuScrollContainer scroll; + ParticipantsList list; + + AddInternal(scroll = new OsuScrollContainer(direction) + { + Child = list = new ParticipantsList() + }); + + switch (direction) + { + case Direction.Horizontal: + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + scroll.RelativeSizeAxes = Axes.X; + scroll.Height = ParticipantsList.TILE_SIZE + OsuScrollContainer.SCROLL_BAR_HEIGHT + OsuScrollContainer.SCROLL_BAR_PADDING * 2; + + list.RelativeSizeAxes = Axes.Y; + list.AutoSizeAxes = Axes.X; + break; + + case Direction.Vertical: + RelativeSizeAxes = Axes.Both; + + scroll.RelativeSizeAxes = Axes.Both; + + list.RelativeSizeAxes = Axes.X; + list.AutoSizeAxes = Axes.Y; + break; + } + } + + [BackgroundDependencyLoader] + private void load() + { + ParticipantCount.BindValueChanged(_ => setParticipantCount()); + MaxParticipants.BindValueChanged(_ => setParticipantCount(), true); + } + + private void setParticipantCount() => + Details.Value = MaxParticipants.Value != null ? $"{ParticipantCount.Value}/{MaxParticipants.Value}" : ParticipantCount.Value.ToString(); + } +} diff --git a/osu.Game/Screens/Multi/Components/ParticipantsList.cs b/osu.Game/Screens/Multi/Components/ParticipantsList.cs new file mode 100644 index 0000000000..7978b4eaab --- /dev/null +++ b/osu.Game/Screens/Multi/Components/ParticipantsList.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + public class ParticipantsList : MultiplayerComposite + { + public const float TILE_SIZE = 35; + + public override Axes RelativeSizeAxes + { + get => base.RelativeSizeAxes; + set + { + base.RelativeSizeAxes = value; + + if (tiles != null) + tiles.RelativeSizeAxes = value; + } + } + + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set + { + base.AutoSizeAxes = value; + + if (tiles != null) + tiles.AutoSizeAxes = value; + } + } + + private FillDirection direction = FillDirection.Full; + + public FillDirection Direction + { + get => direction; + set + { + direction = value; + + if (tiles != null) + tiles.Direction = value; + } + } + + [BackgroundDependencyLoader] + private void load() + { + RecentParticipants.CollectionChanged += (_, __) => updateParticipants(); + updateParticipants(); + } + + private ScheduledDelegate scheduledUpdate; + private FillFlowContainer tiles; + + private void updateParticipants() + { + scheduledUpdate?.Cancel(); + scheduledUpdate = Schedule(() => + { + tiles?.FadeOut(250, Easing.Out).Expire(); + + tiles = new FillFlowContainer + { + Alpha = 0, + Direction = Direction, + AutoSizeAxes = AutoSizeAxes, + RelativeSizeAxes = RelativeSizeAxes, + Spacing = Vector2.One + }; + + for (int i = 0; i < RecentParticipants.Count; i++) + tiles.Add(new UserTile { User = RecentParticipants[i] }); + + AddInternal(tiles); + + tiles.Delay(250).FadeIn(250, Easing.OutQuint); + }); + } + + private class UserTile : CompositeDrawable, IHasTooltip + { + public User User + { + get => avatar.User; + set => avatar.User = value; + } + + public string TooltipText => User?.Username ?? string.Empty; + + private readonly UpdateableAvatar avatar; + + public UserTile() + { + Size = new Vector2(TILE_SIZE); + CornerRadius = 5f; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"27252d"), + }, + avatar = new UpdateableAvatar { RelativeSizeAxes = Axes.Both }, + }; + } + } + } +} diff --git a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs index 97af6674bf..a115f06e7b 100644 --- a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs @@ -17,6 +17,9 @@ namespace osu.Game.Screens.Multi.Components [Resolved(typeof(Room), nameof(Room.Status))] private Bindable status { get; set; } + [Resolved(typeof(Room), nameof(Room.Category))] + private Bindable category { get; set; } + public StatusColouredContainer(double transitionDuration = 100) { this.transitionDuration = transitionDuration; @@ -25,7 +28,11 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - status.BindValueChanged(s => this.FadeColour(s.NewValue.GetAppropriateColour(colours), transitionDuration), true); + status.BindValueChanged(s => + { + this.FadeColour(category.Value == RoomCategory.Spotlight ? colours.Pink : s.NewValue.GetAppropriateColour(colours) + , transitionDuration); + }, true); } } } diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs new file mode 100644 index 0000000000..89c335183b --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylist.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.Specialized; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylist : OsuRearrangeableListContainer + { + public readonly Bindable SelectedItem = new Bindable(); + + private readonly bool allowEdit; + private readonly bool allowSelection; + + public DrawableRoomPlaylist(bool allowEdit, bool allowSelection) + { + this.allowEdit = allowEdit; + this.allowSelection = allowSelection; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Scheduled since items are removed and re-added upon rearrangement + Items.CollectionChanged += (_, args) => Schedule(() => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Remove: + if (args.OldItems.Contains(SelectedItem)) + SelectedItem.Value = null; + break; + } + }); + } + + protected override ScrollContainer CreateScrollContainer() => base.CreateScrollContainer().With(d => + { + d.ScrollbarVisible = false; + }); + + protected override FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer> + { + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + Spacing = new Vector2(0, 2) + }; + + protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => new DrawableRoomPlaylistItem(item, allowEdit, allowSelection) + { + SelectedItem = { BindTarget = SelectedItem }, + RequestDeletion = requestDeletion + }; + + private void requestDeletion(PlaylistItem item) + { + if (SelectedItem.Value == item) + { + if (Items.Count == 1) + SelectedItem.Value = null; + else + SelectedItem.Value = Items.GetNext(item) ?? Items[^2]; + } + + Items.Remove(item); + } + } +} diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs new file mode 100644 index 0000000000..c0892235f2 --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs @@ -0,0 +1,321 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays.BeatmapListing.Panels; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylistItem : OsuRearrangeableListItem + { + public Action RequestDeletion; + + public readonly Bindable SelectedItem = new Bindable(); + + protected override bool ShowDragHandle => allowEdit; + + private Container maskingContainer; + private Container difficultyIconContainer; + private LinkFlowContainer beatmapText; + private LinkFlowContainer authorText; + private ModDisplay modDisplay; + + private readonly Bindable beatmap = new Bindable(); + private readonly Bindable ruleset = new Bindable(); + private readonly BindableList requiredMods = new BindableList(); + + public readonly PlaylistItem Item; + + private readonly bool allowEdit; + private readonly bool allowSelection; + + protected override bool ShouldBeConsideredForInput(Drawable child) => allowEdit || !allowSelection || SelectedItem.Value == Model; + + public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection) + : base(item) + { + Item = item; + + // TODO: edit support should be moved out into a derived class + this.allowEdit = allowEdit; + + this.allowSelection = allowSelection; + + beatmap.BindTo(item.Beatmap); + ruleset.BindTo(item.Ruleset); + requiredMods.BindTo(item.RequiredMods); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + if (!allowEdit) + HandleColour = HandleColour.Opacity(0); + + maskingContainer.BorderColour = colours.Yellow; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(selected => maskingContainer.BorderThickness = selected.NewValue == Model ? 5 : 0, true); + + beatmap.BindValueChanged(_ => scheduleRefresh()); + ruleset.BindValueChanged(_ => scheduleRefresh()); + + requiredMods.CollectionChanged += (_, __) => scheduleRefresh(); + + refresh(); + } + + private ScheduledDelegate scheduledRefresh; + + private void scheduleRefresh() + { + scheduledRefresh?.Cancel(); + scheduledRefresh = Schedule(refresh); + } + + private void refresh() + { + difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value) { Size = new Vector2(32) }; + + beatmapText.Clear(); + beatmapText.AddLink(Item.Beatmap.ToString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); + + authorText.Clear(); + + if (Item.Beatmap?.Value?.Metadata?.Author != null) + { + authorText.AddText("mapped by "); + authorText.AddUserLink(Item.Beatmap.Value?.Metadata.Author); + } + + modDisplay.Current.Value = requiredMods.ToArray(); + } + + protected override Drawable CreateContent() => maskingContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 50, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box // A transparent box that forces the border to be drawn if the panel background is opaque + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new PanelBackground + { + RelativeSizeAxes = Axes.Both, + Beatmap = { BindTarget = beatmap } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 8 }, + Spacing = new Vector2(8, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + difficultyIconContainer = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + beatmapText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + authorText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, + modDisplay = new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.4f), + DisplayUnrankedText = false, + ExpansionMode = ExpansionMode.AlwaysExpanded + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + X = -18, + ChildrenEnumerable = CreateButtons() + } + } + }; + + protected virtual IEnumerable CreateButtons() => + new Drawable[] + { + new PlaylistDownloadButton(Item) + { + Size = new Vector2(50, 30) + }, + new IconButton + { + Icon = FontAwesome.Solid.MinusSquare, + Alpha = allowEdit ? 1 : 0, + Action = () => RequestDeletion?.Invoke(Model), + }, + }; + + protected override bool OnClick(ClickEvent e) + { + if (allowSelection) + SelectedItem.Value = Model; + return true; + } + + private class PlaylistDownloadButton : BeatmapPanelDownloadButton + { + private readonly PlaylistItem playlistItem; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + public PlaylistDownloadButton(PlaylistItem playlistItem) + : base(playlistItem.Beatmap.Value.BeatmapSet) + { + this.playlistItem = playlistItem; + Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + State.BindValueChanged(stateChanged, true); + FinishTransforms(true); + } + + private void stateChanged(ValueChangedEvent state) + { + switch (state.NewValue) + { + 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 == playlistItem.Beatmap.Value.MD5Hash) == null) + State.Value = DownloadState.NotDownloaded; + else + this.FadeTo(0, 500); + + break; + + default: + this.FadeTo(1, 500); + break; + } + } + } + + // 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) + { + public readonly Bindable Beatmap = new Bindable(); + + public PanelBackground() + { + InternalChildren = new Drawable[] + { + new UpdateableBeatmapBackgroundSprite + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Beatmap = { BindTarget = Beatmap } + }, + new FillFlowContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle + Shear = new Vector2(0.8f, 0), + Alpha = 0.5f, + Children = new[] + { + // The left half with no gradient applied + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Width = 0.4f, + }, + // Piecewise-linear gradient with 3 segments to make it appear smoother + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), + Width = 0.05f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), + Width = 0.2f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), + Width = 0.05f, + }, + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs new file mode 100644 index 0000000000..439aaaa275 --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist + { + public Action RequestShowResults; + + public DrawableRoomPlaylistWithResults() + : base(false, true) + { + } + + protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => + new DrawableRoomPlaylistItemWithResults(item, false, true) + { + RequestShowResults = () => RequestShowResults(item), + SelectedItem = { BindTarget = SelectedItem }, + }; + + private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem + { + public Action RequestShowResults; + + public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection) + : base(item, allowEdit, allowSelection) + { + } + + protected override IEnumerable CreateButtons() => + base.CreateButtons().Prepend(new FilledIconButton + { + Icon = FontAwesome.Solid.ChartPie, + Action = () => RequestShowResults?.Invoke(), + TooltipText = "View results" + }); + + private class FilledIconButton : IconButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = colours.Gray4, + }); + } + } + } + } +} diff --git a/osu.Game/Screens/Multi/Header.cs b/osu.Game/Screens/Multi/Header.cs index 1cbf2a45e7..653cb3791a 100644 --- a/osu.Game/Screens/Multi/Header.cs +++ b/osu.Game/Screens/Multi/Header.cs @@ -1,55 +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 Humanizer; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.SearchableList; +using osu.Game.Graphics.Sprites; +using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Multi { public class Header : Container { - public const float HEIGHT = 121; - - private readonly HeaderBreadcrumbControl breadcrumbs; + public const float HEIGHT = 80; public Header(ScreenStack stack) { - MultiHeaderTitle title; RelativeSizeAxes = Axes.X; Height = HEIGHT; + HeaderBreadcrumbControl breadcrumbs; + MultiHeaderTitle title; + Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"2f2043"), + Colour = Color4Extensions.FromHex(@"#1f1921"), }, new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Padding = new MarginPadding { Left = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { title = new MultiHeaderTitle { Anchor = Anchor.CentreLeft, Origin = Anchor.BottomLeft, - X = -ScreenTitle.ICON_WIDTH, }, breadcrumbs = new HeaderBreadcrumbControl(stack) { Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }, + Origin = Anchor.BottomLeft + } }, }, }; @@ -63,25 +68,61 @@ namespace osu.Game.Screens.Multi breadcrumbs.Current.TriggerChange(); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + private class MultiHeaderTitle : CompositeDrawable { - breadcrumbs.StripColour = colours.Green; - } + private const float spacing = 6; + + private readonly OsuSpriteText dot; + private readonly OsuSpriteText pageTitle; - private class MultiHeaderTitle : ScreenTitle - { public IMultiplayerSubScreen Screen { - set => Section = value.ShortTitle.ToLowerInvariant(); + set => pageTitle.Text = value.ShortTitle.Titleize(); + } + + public MultiHeaderTitle() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(spacing, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Multiplayer" + }, + dot = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "·" + }, + pageTitle = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Lounge" + } + } + }, + }; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Title = "multi"; - Icon = OsuIcon.Multi; - AccentColour = colours.Yellow; + pageTitle.Colour = dot.Colour = colours.Yellow; } } @@ -90,12 +131,28 @@ namespace osu.Game.Screens.Multi public HeaderBreadcrumbControl(ScreenStack stack) : base(stack) { + RelativeSizeAxes = Axes.X; + StripColour = Color4.Transparent; } protected override void LoadComplete() { base.LoadComplete(); - AccentColour = Color4.White; + AccentColour = Color4Extensions.FromHex("#e35c99"); + } + + protected override TabItem CreateTabItem(IScreen value) => new HeaderBreadcrumbTabItem(value) + { + AccentColour = AccentColour + }; + + private class HeaderBreadcrumbTabItem : BreadcrumbTabItem + { + public HeaderBreadcrumbTabItem(IScreen value) + : base(value) + { + Bar.Colour = Color4.Transparent; + } } } } diff --git a/osu.Game/Screens/Multi/IRoomManager.cs b/osu.Game/Screens/Multi/IRoomManager.cs index f6c979851e..bf75843c3e 100644 --- a/osu.Game/Screens/Multi/IRoomManager.cs +++ b/osu.Game/Screens/Multi/IRoomManager.cs @@ -14,6 +14,11 @@ namespace osu.Game.Screens.Multi /// event Action RoomsUpdated; + /// + /// Whether an initial listing of rooms has been received. + /// + Bindable InitialRoomsReceived { get; } + /// /// All the active s. /// diff --git a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs index f6cbe300f3..8dd1b239e8 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs @@ -75,8 +75,13 @@ namespace osu.Game.Screens.Multi.Lounge.Components { matchingFilter = value; - if (IsLoaded) - this.FadeTo(MatchingFilter ? 1 : 0, 200); + if (!IsLoaded) + return; + + if (matchingFilter) + this.FadeIn(200); + else + Hide(); } } @@ -102,6 +107,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + float stripWidth = side_strip_width * (Room.Category.Value == RoomCategory.Spotlight ? 2 : 1); + Children = new Drawable[] { new StatusColouredContainer(transition_duration) @@ -129,12 +136,12 @@ namespace osu.Game.Screens.Multi.Lounge.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"212121"), + Colour = Color4Extensions.FromHex(@"212121"), }, new StatusColouredContainer(transition_duration) { RelativeSizeAxes = Axes.Y, - Width = side_strip_width, + Width = stripWidth, Child = new Box { RelativeSizeAxes = Axes.Both } }, new Container @@ -142,7 +149,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components RelativeSizeAxes = Axes.Y, Width = cover_width, Masking = true, - Margin = new MarginPadding { Left = side_strip_width }, + Margin = new MarginPadding { Left = stripWidth }, Child = new MultiplayerBackgroundSprite(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Both } }, new Container @@ -151,7 +158,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components Padding = new MarginPadding { Vertical = content_padding, - Left = side_strip_width + cover_width + content_padding, + Left = stripWidth + cover_width + content_padding, Right = content_padding, }, Children = new Drawable[] @@ -212,6 +219,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components Alpha = 0; } + protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + private class RoomName : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Multiplayer.Room.Name))] diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs index 29d41132a7..be1083ce8d 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs @@ -4,24 +4,28 @@ using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Threading; -using osu.Game.Graphics; using osu.Game.Overlays.SearchableList; +using osu.Game.Rulesets; using osuTK.Graphics; namespace osu.Game.Screens.Multi.Lounge.Components { - public class FilterControl : SearchableListFilterControl + public class FilterControl : SearchableListFilterControl { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"362e42"); - protected override PrimaryFilter DefaultTab => PrimaryFilter.Open; - protected override SecondaryFilter DefaultCategory => SecondaryFilter.Public; + protected override Color4 BackgroundColour => Color4.Black.Opacity(0.5f); + protected override RoomStatusFilter DefaultTab => RoomStatusFilter.Open; + protected override RoomCategoryFilter DefaultCategory => RoomCategoryFilter.Any; protected override float ContentHorizontalPadding => base.ContentHorizontalPadding + OsuScreen.HORIZONTAL_OVERFLOW_PADDING; [Resolved(CanBeNull = true)] private Bindable filter { get; set; } + [Resolved] + private IBindable ruleset { get; set; } + public FilterControl() { DisplayStyleControl.Hide(); @@ -30,15 +34,16 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load() { - if (filter == null) - filter = new Bindable(); + filter ??= new Bindable(); } protected override void LoadComplete() { base.LoadComplete(); + ruleset.BindValueChanged(_ => updateFilter()); Search.Current.BindValueChanged(_ => scheduleUpdateFilter()); + Dropdown.Current.BindValueChanged(_ => updateFilter()); Tabs.Current.BindValueChanged(_ => updateFilter(), true); } @@ -57,25 +62,27 @@ namespace osu.Game.Screens.Multi.Lounge.Components filter.Value = new FilterCriteria { SearchString = Search.Current.Value ?? string.Empty, - PrimaryFilter = Tabs.Current.Value, - SecondaryFilter = DisplayStyleControl.Dropdown.Current.Value + StatusFilter = Tabs.Current.Value, + RoomCategoryFilter = Dropdown.Current.Value, + Ruleset = ruleset.Value }; } } - public enum PrimaryFilter + public enum RoomStatusFilter { Open, [Description("Recently Ended")] - RecentlyEnded, + Ended, Participated, Owned, } - public enum SecondaryFilter + public enum RoomCategoryFilter { - Public, - //Private, + Any, + Normal, + Spotlight } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs index 666bc44a8d..6d70225eec 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets; + namespace osu.Game.Screens.Multi.Lounge.Components { public class FilterCriteria { public string SearchString; - public PrimaryFilter PrimaryFilter; - public SecondaryFilter SecondaryFilter; + public RoomStatusFilter StatusFilter; + public RoomCategoryFilter RoomCategoryFilter; + public RulesetInfo Ruleset; } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs b/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs index a55db096af..4152a9a3b2 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components if (host.NewValue != null) { hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold, italics: true)); + hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(Typeface.Torus, weight: FontWeight.Bold, italics: true)); flagContainer.Child = new UpdateableFlag(host.NewValue.Country) { RelativeSizeAxes = Axes.Both }; } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs new file mode 100644 index 0000000000..e6f6ce5ed2 --- /dev/null +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs @@ -0,0 +1,93 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Multi.Components; +using osuTK; + +namespace osu.Game.Screens.Multi.Lounge.Components +{ + public class RoomInfo : MultiplayerComposite + { + private readonly List statusElements = new List(); + private readonly OsuTextFlowContainer roomName; + + public RoomInfo() + { + AutoSizeAxes = Axes.Y; + + RoomStatusInfo statusInfo; + ModeTypeInfo typeInfo; + ParticipantInfo participantInfo; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + roomName = new OsuTextFlowContainer(t => t.Font = OsuFont.GetFont(size: 30)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + statusInfo = new RoomStatusInfo(), + } + }, + typeInfo = new ModeTypeInfo + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight + } + } + }, + participantInfo = new ParticipantInfo(), + } + }; + + statusElements.AddRange(new Drawable[] { statusInfo, typeInfo, participantInfo }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (RoomID.Value == null) + statusElements.ForEach(e => e.FadeOut()); + + RoomID.BindValueChanged(id => + { + if (id.NewValue == null) + statusElements.ForEach(e => e.FadeOut(100)); + else + statusElements.ForEach(e => e.FadeIn(100)); + }, true); + + RoomName.BindValueChanged(name => + { + roomName.Text = name.NewValue ?? "No room selected"; + }, true); + } + } +} diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs index 5030d8cb50..77fbd606f4 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs @@ -1,26 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Multi.Lounge.Components @@ -31,295 +18,74 @@ namespace osu.Game.Screens.Multi.Lounge.Components private readonly MarginPadding contentPadding = new MarginPadding { Horizontal = 20, Vertical = 10 }; - private ParticipantCountDisplay participantCount; - private OsuSpriteText name; - private BeatmapTypeInfo beatmapTypeInfo; - private ParticipantInfo participantInfo; - [Resolved] private BeatmapManager beatmaps { get; set; } - private readonly Bindable status = new Bindable(new RoomStatusNoneSelected()); - [BackgroundDependencyLoader] private void load(OsuColour colours) { + OverlinedHeader participantsHeader; + InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"343138"), + Colour = Color4.Black, + Alpha = 0.25f }, - new GridContainer + new Container { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Padding = new MarginPadding { Horizontal = 30 }, + Child = new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + Content = new[] { - new FillFlowContainer + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = 200, - Masking = true, - Children = new Drawable[] + new RoomInfo { - new MultiplayerBackgroundSprite { RelativeSizeAxes = Axes.Both }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.5f), Color4.Black.Opacity(0)), - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Children = new Drawable[] - { - participantCount = new ParticipantCountDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - name = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 30), - Current = RoomName - }, - }, - }, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Vertical = 60 }, }, - }, - new StatusColouredContainer(transition_duration) - { - RelativeSizeAxes = Axes.X, - Height = 5, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + participantsHeader = new OverlinedHeader("Recent Participants"), + new ParticipantsDisplay(Direction.Vertical) { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - LayoutDuration = transition_duration, - Padding = contentPadding, - Spacing = new Vector2(0f, 5f), - Children = new Drawable[] - { - new StatusColouredContainer(transition_duration) - { - AutoSizeAxes = Axes.Both, - Child = new StatusText - { - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), - } - }, - beatmapTypeInfo = new BeatmapTypeInfo(), - }, - }, - }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = contentPadding, - Children = new Drawable[] - { - participantInfo = new ParticipantInfo(), - }, - }, + RelativeSizeAxes = Axes.X, + Height = ParticipantsList.TILE_SIZE * 3, + Details = { BindTarget = participantsHeader.Details } + } + } + } + }, + new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] + { + new DrawableRoomPlaylist(false, false) + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Playlist } }, }, }, - new Drawable[] + RowDimensions = new[] { - new MatchParticipants - { - RelativeSizeAxes = Axes.Both, - } + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), } } } }; - - Status.BindValueChanged(_ => updateStatus(), true); - RoomID.BindValueChanged(_ => updateStatus(), true); - } - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(status, new CacheInfo(nameof(Room.Status), typeof(Room))); - return dependencies; - } - - private void updateStatus() - { - if (RoomID.Value == null) - { - status.Value = new RoomStatusNoneSelected(); - - participantCount.FadeOut(transition_duration); - beatmapTypeInfo.FadeOut(transition_duration); - name.FadeOut(transition_duration); - participantInfo.FadeOut(transition_duration); - } - else - { - status.Value = Status.Value; - - participantCount.FadeIn(transition_duration); - beatmapTypeInfo.FadeIn(transition_duration); - name.FadeIn(transition_duration); - participantInfo.FadeIn(transition_duration); - } - } - - private class RoomStatusNoneSelected : RoomStatus - { - public override string Message => @"No Room Selected"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Gray8; - } - - private class StatusText : OsuSpriteText - { - [Resolved(typeof(Room), nameof(Room.Status))] - private Bindable status { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - status.BindValueChanged(s => Text = s.NewValue.Message, true); - } - } - - private class MatchParticipants : MultiplayerComposite - { - private readonly FillFlowContainer fill; - - public MatchParticipants() - { - Padding = new MarginPadding { Horizontal = 10 }; - - InternalChild = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = fill = new FillFlowContainer - { - Spacing = new Vector2(10), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Full, - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - RoomID.BindValueChanged(_ => updateParticipants(), true); - } - - [Resolved] - private IAPIProvider api { get; set; } - - private GetRoomScoresRequest request; - - private void updateParticipants() - { - var roomId = RoomID.Value ?? 0; - - request?.Cancel(); - - // nice little progressive fade - int time = 500; - - foreach (var c in fill.Children) - { - c.Delay(500 - time).FadeOut(time, Easing.Out); - time = Math.Max(20, time - 20); - c.Expire(); - } - - if (roomId == 0) return; - - request = new GetRoomScoresRequest(roomId); - request.Success += scores => - { - if (roomId != RoomID.Value) - return; - - fill.Clear(); - foreach (var s in scores) - fill.Add(new UserTile(s.User)); - - fill.FadeInFromZero(1000, Easing.OutQuint); - }; - - api.Queue(request); - } - - protected override void Dispose(bool isDisposing) - { - request?.Cancel(); - base.Dispose(isDisposing); - } - - private class UserTile : CompositeDrawable, IHasTooltip - { - private readonly User user; - - public string TooltipText => user.Username; - - public UserTile(User user) - { - this.user = user; - Size = new Vector2(70f); - CornerRadius = 5f; - Masking = true; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"27252d"), - }, - new UpdateableAvatar - { - RelativeSizeAxes = Axes.Both, - User = user, - }, - }; - } - } } } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs index 607b081653..447c99039a 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs @@ -9,13 +9,17 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.Multi.Lounge.Components { - public class RoomsContainer : CompositeDrawable + public class RoomsContainer : CompositeDrawable, IKeyBindingHandler { public Action JoinRequested; @@ -28,7 +32,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components private Bindable filter { get; set; } [Resolved] - private Bindable currentRoom { get; set; } + private Bindable selectedRoom { get; set; } [Resolved] private IRoomManager roomManager { get; set; } @@ -47,22 +51,15 @@ namespace osu.Game.Screens.Multi.Lounge.Components }; } - [BackgroundDependencyLoader] - private void load() - { - rooms.BindTo(roomManager.Rooms); - - rooms.ItemsAdded += addRooms; - rooms.ItemsRemoved += removeRooms; - - roomManager.RoomsUpdated += updateSorting; - - addRooms(rooms); - } - protected override void LoadComplete() { - filter?.BindValueChanged(f => Filter(f.NewValue), true); + rooms.ItemsAdded += addRooms; + rooms.ItemsRemoved += removeRooms; + roomManager.RoomsUpdated += updateSorting; + + rooms.BindTo(roomManager.Rooms); + + filter?.BindValueChanged(criteria => Filter(criteria.NewValue)); } public void Filter(FilterCriteria criteria) @@ -74,15 +71,11 @@ namespace osu.Game.Screens.Multi.Lounge.Components else { bool matchingFilter = true; - matchingFilter &= r.FilterTerms.Any(term => term.IndexOf(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase) >= 0); - switch (criteria.SecondaryFilter) - { - default: - case SecondaryFilter.Public: - matchingFilter &= r.Room.Availability.Value == RoomAvailability.Public; - break; - } + matchingFilter &= r.Room.Playlist.Count == 0 || r.Room.Playlist.Any(i => i.Ruleset.Value.Equals(criteria.Ruleset)); + + if (!string.IsNullOrEmpty(criteria.SearchString)) + matchingFilter &= r.FilterTerms.Any(term => term.IndexOf(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase) >= 0); r.MatchingFilter = matchingFilter; } @@ -91,11 +84,24 @@ namespace osu.Game.Screens.Multi.Lounge.Components private void addRooms(IEnumerable rooms) { - foreach (var r in rooms) - roomFlow.Add(new DrawableRoom(r) { Action = () => selectRoom(r) }); + foreach (var room in rooms) + { + roomFlow.Add(new DrawableRoom(room) + { + Action = () => + { + if (room == selectedRoom.Value) + { + joinSelected(); + return; + } - if (filter != null) - Filter(filter.Value); + selectRoom(room); + } + }); + } + + Filter(filter?.Value); } private void removeRooms(IEnumerable rooms) @@ -119,16 +125,100 @@ namespace osu.Game.Screens.Multi.Lounge.Components private void selectRoom(Room room) { - var drawable = roomFlow.FirstOrDefault(r => r.Room == room); - - if (drawable != null && drawable.State == SelectionState.Selected) - JoinRequested?.Invoke(room); - else - roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); - - currentRoom.Value = room; + roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); + selectedRoom.Value = room; } + private void joinSelected() + { + if (selectedRoom.Value == null) return; + + JoinRequested?.Invoke(selectedRoom.Value); + } + + #region Key selection logic (shared with BeatmapCarousel) + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + joinSelected(); + return true; + + case GlobalAction.SelectNext: + beginRepeatSelection(() => selectNext(1), action); + return true; + + case GlobalAction.SelectPrevious: + beginRepeatSelection(() => selectNext(-1), action); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + case GlobalAction.SelectPrevious: + endRepeatSelection(action); + break; + } + } + + private ScheduledDelegate repeatDelegate; + private object lastRepeatSource; + + /// + /// Begin repeating the specified selection action. + /// + /// The action to perform. + /// The source of the action. Used in conjunction with to only cancel the correct action (most recently pressed key). + private void beginRepeatSelection(Action action, object source) + { + endRepeatSelection(); + + lastRepeatSource = source; + repeatDelegate = this.BeginKeyRepeat(Scheduler, action); + } + + private void endRepeatSelection(object source = null) + { + // only the most recent source should be able to cancel the current action. + if (source != null && !EqualityComparer.Default.Equals(lastRepeatSource, source)) + return; + + repeatDelegate?.Cancel(); + repeatDelegate = null; + lastRepeatSource = null; + } + + private void selectNext(int direction) + { + var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); + + Room room; + + if (selectedRoom.Value == null) + room = visibleRooms.FirstOrDefault()?.Room; + else + { + if (direction < 0) + visibleRooms = visibleRooms.Reverse(); + + room = visibleRooms.SkipWhile(r => r.Room != selectedRoom.Value).Skip(1).FirstOrDefault()?.Room; + } + + // we already have a valid selection only change selection if we still have a room to switch to. + if (room != null) + selectRoom(room); + } + + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs index 0a48f761cf..ff7d56a95b 100644 --- a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,6 +11,7 @@ using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; using osu.Game.Overlays.SearchableList; using osu.Game.Screens.Multi.Lounge.Components; using osu.Game.Screens.Multi.Match; @@ -20,16 +22,27 @@ namespace osu.Game.Screens.Multi.Lounge { public override string Title => "Lounge"; - protected readonly FilterControl Filter; + protected FilterControl Filter; - private readonly Container content; - private readonly ProcessingOverlay processingOverlay; + private readonly Bindable initialRoomsReceived = new Bindable(); + + private Container content; + private LoadingLayer loadingLayer; [Resolved] - private Bindable currentRoom { get; set; } + private Bindable selectedRoom { get; set; } - public LoungeSubScreen() + [Resolved(canBeNull: true)] + private MusicController music { get; set; } + + private bool joiningRoom; + + [BackgroundDependencyLoader] + private void load() { + RoomsContainer roomsContainer; + OsuScrollContainer scrollContainer; + InternalChildren = new Drawable[] { Filter = new FilterControl { Depth = -1 }, @@ -44,19 +57,14 @@ namespace osu.Game.Screens.Multi.Lounge Width = 0.55f, Children = new Drawable[] { - new OsuScrollContainer + scrollContainer = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, Padding = new MarginPadding(10), - Child = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new RoomsContainer { JoinRequested = joinRequested } - }, + Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(roomsContainer), } }, new RoomInspector @@ -69,6 +77,22 @@ namespace osu.Game.Screens.Multi.Lounge }, }, }; + + // scroll selected room into view on selection. + selectedRoom.BindValueChanged(val => + { + var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); + if (drawable != null) + scrollContainer.ScrollIntoView(drawable); + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + initialRoomsReceived.BindTo(RoomManager.InitialRoomsReceived); + initialRoomsReceived.BindValueChanged(onInitialRoomsReceivedChanged, true); } protected override void UpdateAfterChildren() @@ -91,6 +115,24 @@ namespace osu.Game.Screens.Multi.Lounge public override void OnEntering(IScreen last) { base.OnEntering(last); + + onReturning(); + } + + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + + if (selectedRoom.Value?.RoomID.Value == null) + selectedRoom.Value = new Room(); + + music.EnsurePlayingSomething(); + + onReturning(); + } + + private void onReturning() + { Filter.Search.HoldFocus = true; } @@ -106,22 +148,31 @@ namespace osu.Game.Screens.Multi.Lounge Filter.Search.HoldFocus = false; } - public override void OnResuming(IScreen last) - { - base.OnResuming(last); - - if (currentRoom.Value?.RoomID.Value == null) - currentRoom.Value = new Room(); - } - private void joinRequested(Room room) { - processingOverlay.Show(); + joiningRoom = true; + updateLoadingLayer(); + RoomManager?.JoinRoom(room, r => { Open(room); - processingOverlay.Hide(); - }, _ => processingOverlay.Hide()); + joiningRoom = false; + updateLoadingLayer(); + }, _ => + { + joiningRoom = false; + updateLoadingLayer(); + }); + } + + private void onInitialRoomsReceivedChanged(ValueChangedEvent received) => updateLoadingLayer(); + + private void updateLoadingLayer() + { + if (joiningRoom || !initialRoomsReceived.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); } /// @@ -133,7 +184,7 @@ namespace osu.Game.Screens.Multi.Lounge if (!this.IsCurrentScreen()) return; - currentRoom.Value = room; + selectedRoom.Value = room; this.Push(new MatchSubScreen(room)); } diff --git a/osu.Game/Screens/Multi/Match/Components/Footer.cs b/osu.Game/Screens/Multi/Match/Components/Footer.cs new file mode 100644 index 0000000000..be4ee873fa --- /dev/null +++ b/osu.Game/Screens/Multi/Match/Components/Footer.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.Multi.Match.Components +{ + public class Footer : CompositeDrawable + { + public const float HEIGHT = 50; + + public Action OnStart; + public readonly Bindable SelectedItem = new Bindable(); + + private readonly Drawable background; + + public Footer() + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + + InternalChildren = new[] + { + background = new Box { RelativeSizeAxes = Axes.Both }, + new ReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(600, 50), + SelectedItem = { BindTarget = SelectedItem }, + Action = () => OnStart?.Invoke() + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = Color4Extensions.FromHex(@"28242d"); + } + } +} diff --git a/osu.Game/Screens/Multi/Match/Components/Header.cs b/osu.Game/Screens/Multi/Match/Components/Header.cs index a52d43acf4..134a0b3f2e 100644 --- a/osu.Game/Screens/Multi/Match/Components/Header.cs +++ b/osu.Game/Screens/Multi/Match/Components/Header.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.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.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Multi.Components; -using osu.Game.Screens.Play.HUD; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Users.Drawables; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Multi.Match.Components { public class Header : MultiplayerComposite { - public const float HEIGHT = 200; + public const float HEIGHT = 50; - public readonly BindableBool ShowBeatmapPanel = new BindableBool(); - - public MatchTabControl Tabs { get; private set; } - - public Action RequestBeatmapSelection; - - private MatchBeatmapPanel beatmapPanel; + private UpdateableAvatar avatar; + private LinkFlowContainer hostText; public Header() { @@ -42,119 +28,52 @@ namespace osu.Game.Screens.Multi.Match.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - BeatmapSelectButton beatmapButton; - ModDisplay modDisplay; - - InternalChildren = new Drawable[] + InternalChild = new FillFlowContainer { - new Container + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] + avatar = new UpdateableAvatar { - new HeaderBackgroundSprite { RelativeSizeAxes = Axes.Both }, - new Box + Size = new Vector2(50), + Masking = true, + CornerRadius = 10, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.7f), Color4.Black.Opacity(0.8f)), - }, - beatmapPanel = new MatchBeatmapPanel - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = 100 }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30), + Current = { BindTarget = RoomName } + }, + hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20)) + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + } } } - }, - new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = colours.Yellow - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 20 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new BeatmapTypeInfo(), - modDisplay = new ModDisplay - { - Scale = new Vector2(0.75f), - DisplayUnrankedText = false - }, - } - }, - new Container - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 200, - Padding = new MarginPadding { Vertical = 10 }, - Child = beatmapButton = new BeatmapSelectButton - { - RelativeSizeAxes = Axes.Both, - Height = 1, - }, - }, - Tabs = new MatchTabControl - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X - }, - }, - }, + } }; - CurrentItem.BindValueChanged(item => modDisplay.Current.Value = item.NewValue?.RequiredMods?.ToArray() ?? Array.Empty(), true); - - beatmapButton.Action = () => RequestBeatmapSelection?.Invoke(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - ShowBeatmapPanel.BindValueChanged(value => beatmapPanel.FadeTo(value.NewValue ? 1 : 0, 200, Easing.OutQuint), true); - } - - private class BeatmapSelectButton : HeaderButton - { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } - - public BeatmapSelectButton() + Host.BindValueChanged(host => { - Text = "Select beatmap"; - } + avatar.User = host.NewValue; - [BackgroundDependencyLoader] - private void load() - { - roomId.BindValueChanged(id => this.FadeTo(id.NewValue.HasValue ? 0 : 1), true); - } - } + hostText.Clear(); - private class HeaderBackgroundSprite : MultiplayerBackgroundSprite - { - protected override UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new BackgroundSprite { RelativeSizeAxes = Axes.Both }; - - private class BackgroundSprite : UpdateableBeatmapBackgroundSprite - { - protected override double TransformDuration => 200; - } + if (host.NewValue != null) + { + hostText.AddText("hosted by "); + hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }, true); } } } diff --git a/osu.Game/Screens/Multi/Match/Components/HeaderButton.cs b/osu.Game/Screens/Multi/Match/Components/HeaderButton.cs deleted file mode 100644 index de6ece6a05..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/HeaderButton.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.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class HeaderButton : TriangleButton - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundColour = OsuColour.FromHex(@"1187aa"); - - Triangles.ColourLight = OsuColour.FromHex(@"277b9c"); - Triangles.ColourDark = OsuColour.FromHex(@"1f6682"); - Triangles.TriangleScale = 1.5f; - - Add(new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 1f, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.15f, - Blending = BlendingParameters.Additive, - }, - }); - } - - protected override SpriteText CreateText() => new OsuSpriteText - { - Depth = -1, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 30), - }; - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/HostInfo.cs b/osu.Game/Screens/Multi/Match/Components/HostInfo.cs deleted file mode 100644 index 8851a96605..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/HostInfo.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 osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class HostInfo : CompositeDrawable - { - public readonly IBindable Host = new Bindable(); - - private readonly LinkFlowContainer linkContainer; - private readonly UpdateableAvatar avatar; - - public HostInfo() - { - AutoSizeAxes = Axes.X; - Height = 50; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - avatar = new UpdateableAvatar { Size = new Vector2(50) }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Child = linkContainer = new LinkFlowContainer { AutoSizeAxes = Axes.Both } - } - } - }; - - Host.BindValueChanged(host => updateHost(host.NewValue)); - } - - private void updateHost(User host) - { - avatar.User = host; - - if (host != null) - { - linkContainer.AddText("hosted by"); - linkContainer.NewLine(); - linkContainer.AddUserLink(host, s => s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold, italics: true)); - } - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/Info.cs b/osu.Game/Screens/Multi/Match/Components/Info.cs deleted file mode 100644 index 74f000c21f..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/Info.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 osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays.SearchableList; -using osu.Game.Screens.Multi.Components; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class Info : MultiplayerComposite - { - public Action OnStart; - - public Info() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [BackgroundDependencyLoader] - private void load() - { - ReadyButton readyButton; - HostInfo hostInfo; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Padding = new MarginPadding { Vertical = 20 }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30), - Current = RoomName - }, - new RoomStatusInfo(), - } - }, - hostInfo = new HostInfo(), - }, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.X, - Height = 70, - Spacing = new Vector2(10, 0), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - readyButton = new ReadyButton - { - Action = () => OnStart?.Invoke() - } - } - } - }, - }, - }; - - CurrentItem.BindValueChanged(item => readyButton.Beatmap.Value = item.NewValue?.Beatmap, true); - - hostInfo.Host.BindTo(Host); - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchBeatmapPanel.cs b/osu.Game/Screens/Multi/Match/Components/MatchBeatmapPanel.cs deleted file mode 100644 index 7c1fe91393..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/MatchBeatmapPanel.cs +++ /dev/null @@ -1,62 +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; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.Direct; -using osu.Game.Rulesets; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class MatchBeatmapPanel : MultiplayerComposite - { - [Resolved] - private IAPIProvider api { get; set; } - - [Resolved] - private RulesetStore rulesets { get; set; } - - private CancellationTokenSource loadCancellation; - private GetBeatmapSetRequest request; - private DirectGridPanel panel; - - public MatchBeatmapPanel() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - CurrentItem.BindValueChanged(item => loadNewPanel(item.NewValue?.Beatmap), true); - } - - private void loadNewPanel(BeatmapInfo beatmap) - { - loadCancellation?.Cancel(); - request?.Cancel(); - - panel?.FadeOut(200); - panel?.Expire(); - panel = null; - - if (beatmap?.OnlineBeatmapID == null) - return; - - loadCancellation = new CancellationTokenSource(); - - request = new GetBeatmapSetRequest(beatmap.OnlineBeatmapID.Value, BeatmapSetLookupType.BeatmapId); - request.Success += res => Schedule(() => - { - panel = new DirectGridPanel(res.ToBeatmapSet(rulesets)); - LoadComponentAsync(panel, AddInternal, loadCancellation.Token); - }); - - api.Queue(request); - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs index aa92451c77..73a40d9579 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Multi.Match.Components protected override IEnumerable GetStatistics(ScoreInfo model) => new[] { - new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", string.Format(model.Accuracy % 1 == 0 ? @"{0:P0}" : @"{0:P2}", model.Accuracy)), + new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", model.DisplayAccuracy), new LeaderboardScoreStatistic(FontAwesome.Solid.Sync, "Total Attempts", score.TotalAttempts.ToString()), new LeaderboardScoreStatistic(FontAwesome.Solid.Check, "Completed Beatmaps", score.CompletedBeatmaps.ToString()), }; diff --git a/osu.Game/Screens/Multi/Match/Components/MatchPage.cs b/osu.Game/Screens/Multi/Match/Components/MatchPage.cs deleted file mode 100644 index fc98db157b..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/MatchPage.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public abstract class MatchPage - { - public abstract string Name { get; } - - public readonly BindableBool Enabled = new BindableBool(true); - - public override string ToString() => Name; - public override int GetHashCode() => GetType().GetHashCode(); - public override bool Equals(object obj) => GetType() == obj?.GetType(); - } - - public class SettingsMatchPage : MatchPage - { - public override string Name => "Settings"; - } - - public class RoomMatchPage : MatchPage - { - public override string Name => "Room"; - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs index 410aaed788..49a0fc434b 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs @@ -25,6 +25,8 @@ namespace osu.Game.Screens.Multi.Match.Components private const float transition_duration = 350; private const float field_padding = 45; + public Action EditPlaylist; + protected MatchSettings Settings { get; private set; } [BackgroundDependencyLoader] @@ -35,7 +37,8 @@ namespace osu.Game.Screens.Multi.Match.Components Child = Settings = new MatchSettings { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Y + RelativePositionAxes = Axes.Y, + EditPlaylist = () => EditPlaylist?.Invoke() }; } @@ -53,6 +56,8 @@ namespace osu.Game.Screens.Multi.Match.Components { private const float disabled_alpha = 0.2f; + public Action EditPlaylist; + public OsuTextBox NameField, MaxParticipantsField; public OsuDropdown DurationField; public RoomAvailabilityPicker AvailabilityPicker; @@ -62,7 +67,8 @@ namespace osu.Game.Screens.Multi.Match.Components public OsuSpriteText ErrorText; private OsuSpriteText typeLabel; - private ProcessingOverlay processingOverlay; + private LoadingLayer loadingLayer; + private DrawableRoomPlaylist playlist; [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } @@ -73,196 +79,232 @@ namespace osu.Game.Screens.Multi.Match.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + Container dimContent; + InternalChildren = new Drawable[] { - new Box + dimContent = new Container { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Children = new Drawable[] { - new Dimension(GridSizeMode.Distributed), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] + new Box { - new OsuScrollContainer + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d"), + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Padding = new MarginPadding + new Dimension(GridSizeMode.Distributed), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING, - Vertical = 10 - }, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new Container + new OsuScrollContainer { - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + Padding = new MarginPadding { - new SectionContainer + Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING, + Vertical = 10 + }, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Container { - Padding = new MarginPadding { Right = field_padding / 2 }, - Children = new[] + Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - new Section("Room name") + new SectionContainer { - Child = NameField = new SettingsTextBox + Padding = new MarginPadding { Right = field_padding / 2 }, + Children = new[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - OnCommit = (sender, text) => apply(), - }, - }, - new Section("Room visibility") - { - Alpha = disabled_alpha, - Child = AvailabilityPicker = new RoomAvailabilityPicker - { - Enabled = { Value = false } - }, - }, - new Section("Game type") - { - Alpha = disabled_alpha, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(7), - Children = new Drawable[] + new Section("Room name") { - TypePicker = new GameTypePicker + Child = NameField = new SettingsTextBox { RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + }, + new Section("Duration") + { + Child = DurationField = new DurationDropdown + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1), + TimeSpan.FromHours(2), + TimeSpan.FromHours(4), + TimeSpan.FromHours(8), + TimeSpan.FromHours(12), + //TimeSpan.FromHours(16), + TimeSpan.FromHours(24), + TimeSpan.FromDays(3), + TimeSpan.FromDays(7) + } + } + }, + new Section("Room visibility") + { + Alpha = disabled_alpha, + Child = AvailabilityPicker = new RoomAvailabilityPicker + { Enabled = { Value = false } }, - typeLabel = new OsuSpriteText + }, + new Section("Game type") + { + Alpha = disabled_alpha, + Child = new FillFlowContainer { - Font = OsuFont.GetFont(size: 14), - Colour = colours.Yellow + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(7), + Children = new Drawable[] + { + TypePicker = new GameTypePicker + { + RelativeSizeAxes = Axes.X, + Enabled = { Value = false } + }, + typeLabel = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14), + Colour = colours.Yellow + }, + }, + }, + }, + new Section("Max participants") + { + Alpha = disabled_alpha, + Child = MaxParticipantsField = new SettingsNumberTextBox + { + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + ReadOnly = true, + }, + }, + new Section("Password (optional)") + { + Alpha = disabled_alpha, + Child = new SettingsPasswordTextBox + { + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + ReadOnly = true, }, }, }, }, - }, - }, - new SectionContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Padding = new MarginPadding { Left = field_padding / 2 }, - Children = new[] - { - new Section("Max participants") + new SectionContainer { - Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Padding = new MarginPadding { Left = field_padding / 2 }, + Children = new[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, - OnCommit = (sender, text) => apply() - }, - }, - new Section("Duration") - { - Child = DurationField = new DurationDropdown - { - RelativeSizeAxes = Axes.X, - Items = new[] + new Section("Playlist") { - TimeSpan.FromMinutes(30), - TimeSpan.FromHours(1), - TimeSpan.FromHours(2), - TimeSpan.FromHours(4), - TimeSpan.FromHours(8), - TimeSpan.FromHours(12), - //TimeSpan.FromHours(16), - TimeSpan.FromHours(24), - TimeSpan.FromDays(3), - TimeSpan.FromDays(7) - } - } - }, - new Section("Password (optional)") - { - Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox - { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, - OnCommit = (sender, text) => apply() + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + Height = 300, + Content = new[] + { + new Drawable[] + { + playlist = new DrawableRoomPlaylist(true, true) { RelativeSizeAxes = Axes.Both } + }, + new Drawable[] + { + new PurpleTriangleButton + { + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Edit playlist", + Action = () => EditPlaylist?.Invoke() + } + } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + } + }, }, }, }, - }, + } }, - } - }, - }, - }, - new Drawable[] - { - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Y = 2, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d").Darken(0.5f).Opacity(1f), }, - new FillFlowContainer + }, + new Drawable[] + { + new Container { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Y = 2, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding { Vertical = 20 }, - Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { - ApplyButton = new CreateRoomButton + new Box { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(230, 55), - Enabled = { Value = false }, - Action = apply, + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d").Darken(0.5f).Opacity(1f), }, - ErrorText = new OsuSpriteText + new FillFlowContainer { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Alpha = 0, - Depth = 1, - Colour = colours.RedDark + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Margin = new MarginPadding { Vertical = 20 }, + Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Children = new Drawable[] + { + ApplyButton = new CreateRoomButton + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(230, 55), + Enabled = { Value = false }, + Action = apply, + }, + ErrorText = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + Depth = 1, + Colour = colours.RedDark + } + } } } } } } - } + }, } }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(dimContent) }; TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue?.Name ?? string.Empty, true); @@ -271,6 +313,8 @@ namespace osu.Game.Screens.Multi.Match.Components Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue, true); + + playlist.Items.BindTo(Playlist); } protected override void Update() @@ -284,6 +328,9 @@ namespace osu.Game.Screens.Multi.Match.Components private void apply() { + if (!ApplyButton.Enabled.Value) + return; + hideError(); RoomName.Value = NameField.Text; @@ -299,19 +346,19 @@ namespace osu.Game.Screens.Multi.Match.Components manager?.CreateRoom(currentRoom.Value, onSuccess, onError); - processingOverlay.Show(); + loadingLayer.Show(); } private void hideError() => ErrorText.FadeOut(50); - private void onSuccess(Room room) => processingOverlay.Hide(); + private void onSuccess(Room room) => loadingLayer.Hide(); private void onError(string text) { ErrorText.Text = text; ErrorText.FadeIn(50); - processingOverlay.Hide(); + loadingLayer.Hide(); } } @@ -386,7 +433,7 @@ namespace osu.Game.Screens.Multi.Match.Components } } - private class CreateRoomButton : TriangleButton + public class CreateRoomButton : TriangleButton { public CreateRoomButton() { @@ -409,10 +456,7 @@ namespace osu.Game.Screens.Multi.Match.Components Menu.MaxHeight = 100; } - protected override string GenerateItemText(TimeSpan item) - { - return item.Humanize(); - } + protected override string GenerateItemText(TimeSpan item) => item.Humanize(); } } } diff --git a/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs b/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs deleted file mode 100644 index c700d7b88a..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Multiplayer; -using osuTK.Graphics; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class MatchTabControl : PageTabControl - { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } - - public MatchTabControl() - { - AddItem(new RoomMatchPage()); - AddItem(new SettingsMatchPage()); - } - - [BackgroundDependencyLoader] - private void load() - { - roomId.BindValueChanged(id => - { - if (id.NewValue.HasValue) - { - Items.ForEach(t => t.Enabled.Value = !(t is SettingsMatchPage)); - Current.Value = new RoomMatchPage(); - } - else - { - Items.ForEach(t => t.Enabled.Value = t is SettingsMatchPage); - Current.Value = new SettingsMatchPage(); - } - }, true); - } - - protected override TabItem CreateTabItem(MatchPage value) => new TabItem(value); - - private class TabItem : PageTabItem - { - private readonly IBindable enabled = new BindableBool(); - - public TabItem(MatchPage value) - : base(value) - { - enabled.BindTo(value.Enabled); - enabled.BindValueChanged(enabled => Colour = enabled.NewValue ? Color4.White : Color4.Gray, true); - } - - protected override bool OnClick(ClickEvent e) - { - if (!enabled.Value) - return true; - - return base.OnClick(e); - } - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/Participants.cs b/osu.Game/Screens/Multi/Match/Components/Participants.cs deleted file mode 100644 index ad38ec6a99..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/Participants.cs +++ /dev/null @@ -1,72 +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.Graphics.Containers; -using osu.Game.Overlays.SearchableList; -using osu.Game.Screens.Multi.Components; -using osu.Game.Users; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class Participants : MultiplayerComposite - { - [BackgroundDependencyLoader] - private void load() - { - FillFlowContainer usersFlow; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING }, - Children = new Drawable[] - { - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 10 }, - Children = new Drawable[] - { - new ParticipantCountDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - usersFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(5), - Padding = new MarginPadding { Top = 40 }, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - }, - }, - }, - }, - }; - - Participants.BindValueChanged(participants => - { - usersFlow.Children = participants.NewValue.Select(u => - { - var panel = new UserPanel(u) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Width = 300, - }; - - panel.OnLoadComplete += d => d.FadeInFromZero(60); - - return panel; - }).ToList(); - }, true); - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.cs b/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.cs new file mode 100644 index 0000000000..1d93116d07 --- /dev/null +++ b/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.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.Extensions.Color4Extensions; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Multi.Match.Components +{ + public class PurpleTriangleButton : TriangleButton + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundColour = Color4Extensions.FromHex(@"593790"); + Triangles.ColourLight = Color4Extensions.FromHex(@"7247b6"); + Triangles.ColourDark = Color4Extensions.FromHex(@"593790"); + } + } +} diff --git a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs b/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs index 8ab0b8f61f..a64f24dd7e 100644 --- a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs +++ b/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs @@ -3,18 +3,19 @@ using System; using System.Linq; +using System.Linq.Expressions; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -using osuTK; namespace osu.Game.Screens.Multi.Match.Components { - public class ReadyButton : HeaderButton + public class ReadyButton : TriangleButton { - public readonly Bindable Beatmap = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); [Resolved(typeof(Room), nameof(Room.EndDate))] private Bindable endDate { get; set; } @@ -29,44 +30,59 @@ namespace osu.Game.Screens.Multi.Match.Components public ReadyButton() { - RelativeSizeAxes = Axes.Y; - Size = new Vector2(200, 1); - Text = "Start"; } + private IBindable> managerUpdated; + private IBindable> managerRemoved; + [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { - beatmaps.ItemAdded += beatmapAdded; - beatmaps.ItemRemoved += beatmapRemoved; + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(beatmapRemoved); - Beatmap.BindValueChanged(b => updateBeatmap(b.NewValue), true); + SelectedItem.BindValueChanged(item => updateSelectedItem(item.NewValue), true); + + BackgroundColour = colours.Green; + Triangles.ColourDark = colours.Green; + Triangles.ColourLight = colours.GreenLight; } - private void updateBeatmap(BeatmapInfo beatmap) + private void updateSelectedItem(PlaylistItem item) { - hasBeatmap = false; - - if (beatmap?.OnlineBeatmapID == null) - return; - - hasBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmap.OnlineBeatmapID) != null; + hasBeatmap = findBeatmap(expr => beatmaps.QueryBeatmap(expr)); } - private void beatmapAdded(BeatmapSetInfo model) + private void beatmapUpdated(ValueChangedEvent> weakSet) { - if (model.Beatmaps.Any(b => b.OnlineBeatmapID == Beatmap.Value.OnlineBeatmapID)) - Schedule(() => hasBeatmap = true); + if (weakSet.NewValue.TryGetTarget(out var set)) + { + if (findBeatmap(expr => set.Beatmaps.AsQueryable().FirstOrDefault(expr))) + Schedule(() => hasBeatmap = true); + } } - private void beatmapRemoved(BeatmapSetInfo model) + private void beatmapRemoved(ValueChangedEvent> weakSet) { - if (Beatmap.Value == null) - return; + if (weakSet.NewValue.TryGetTarget(out var set)) + { + if (findBeatmap(expr => set.Beatmaps.AsQueryable().FirstOrDefault(expr))) + Schedule(() => hasBeatmap = false); + } + } - if (model.OnlineBeatmapSetID == Beatmap.Value.BeatmapSet.OnlineBeatmapSetID) - Schedule(() => hasBeatmap = false); + private bool findBeatmap(Func>, BeatmapInfo> expression) + { + int? beatmapId = SelectedItem.Value?.Beatmap.Value?.OnlineBeatmapID; + string checksum = SelectedItem.Value?.Beatmap.Value?.MD5Hash; + + if (beatmapId == null || checksum == null) + return false; + + return expression(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum) != null; } protected override void Update() @@ -78,7 +94,7 @@ namespace osu.Game.Screens.Multi.Match.Components private void updateEnabledState() { - if (gameBeatmap.Value == null) + if (gameBeatmap.Value == null || SelectedItem.Value == null) { Enabled.Value = false; return; @@ -88,13 +104,5 @@ namespace osu.Game.Screens.Multi.Match.Components Enabled.Value = hasBeatmap && hasEnoughTime; } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmaps != null) - beatmaps.ItemAdded -= beatmapAdded; - } } } diff --git a/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs b/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs index 9de4a61cde..7ef39c2a74 100644 --- a/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs +++ b/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; @@ -52,7 +53,7 @@ namespace osu.Game.Screens.Multi.Match.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"3d3943"), + Colour = Color4Extensions.FromHex(@"3d3943"), }, selection = new Box { diff --git a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs index c2bb7da6b5..7c2d5cf85d 100644 --- a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs +++ b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs @@ -2,6 +2,8 @@ // 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; @@ -9,13 +11,17 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.GameTypes; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Multi.Components; using osu.Game.Screens.Multi.Match.Components; using osu.Game.Screens.Multi.Play; +using osu.Game.Screens.Multi.Ranking; +using osu.Game.Screens.Play; using osu.Game.Screens.Select; -using PlaylistItem = osu.Game.Online.Multiplayer.PlaylistItem; +using Footer = osu.Game.Screens.Multi.Match.Components.Footer; namespace osu.Game.Screens.Multi.Match { @@ -31,29 +37,26 @@ namespace osu.Game.Screens.Multi.Match [Resolved(typeof(Room), nameof(Room.RoomID))] private Bindable roomId { get; set; } - [Resolved(typeof(Room), nameof(Room.Name))] - private Bindable name { get; set; } - [Resolved(typeof(Room), nameof(Room.Type))] private Bindable type { get; set; } - [Resolved(typeof(Room))] - protected BindableList Playlist { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable CurrentItem { get; private set; } + [Resolved(typeof(Room), nameof(Room.Playlist))] + private BindableList playlist { get; set; } [Resolved] private BeatmapManager beatmapManager { get; set; } - [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } + [Resolved(canBeNull: true)] + private Multiplayer multiplayer { get; set; } - [Resolved(CanBeNull = true)] - private OsuGame game { get; set; } + protected readonly Bindable SelectedItem = new Bindable(); + private MatchSettingsOverlay settingsOverlay; private MatchLeaderboard leaderboard; + private IBindable> managerUpdated; + private OverlinedHeader participantsHeader; + public MatchSubScreen(Room room) { Title = room.RoomID.Value == null ? "New room" : room.Name.Value; @@ -62,11 +65,6 @@ namespace osu.Game.Screens.Multi.Match [BackgroundDependencyLoader] private void load() { - Components.Header header; - Info info; - GridContainer bottomRow; - MatchSettingsOverlay settings; - InternalChildren = new Drawable[] { new GridContainer @@ -76,167 +74,219 @@ namespace osu.Game.Screens.Multi.Match { new Drawable[] { - header = new Components.Header + new Container { - Depth = -1, - RequestBeatmapSelection = () => + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { - this.Push(new MatchSongSelect + Horizontal = 105, + Vertical = 20 + }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Selected = item => + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { new Components.Header() }, + new Drawable[] { - Playlist.Clear(); - Playlist.Add(item); + participantsHeader = new OverlinedHeader("Participants") + { + ShowLine = false + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 5 }, + Child = new ParticipantsDisplay(Direction.Horizontal) + { + Details = { BindTarget = participantsHeader.Details } + } + } + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] + { + new DrawableRoomPlaylistWithResults + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = playlist }, + SelectedItem = { BindTarget = SelectedItem }, + RequestShowResults = item => + { + Debug.Assert(roomId.Value != null); + multiplayer?.Push(new TimeshiftResultsScreen(null, roomId.Value.Value, item, false)); + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Leaderboard"), }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + new Drawable[] { new OverlinedHeader("Chat"), }, + new Drawable[] { new MatchChatDisplay { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Relative, size: 0.4f, minSize: 240), + } + }, + null + }, + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + new Dimension(), + } + } } - }); + }, } } }, - new Drawable[] { info = new Info { OnStart = onStart } }, new Drawable[] { - bottomRow = new GridContainer + new Footer { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - leaderboard = new MatchLeaderboard - { - Padding = new MarginPadding - { - Left = 10 + HORIZONTAL_OVERFLOW_PADDING, - Right = 10, - Vertical = 10, - }, - RelativeSizeAxes = Axes.Both - }, - new Container - { - Padding = new MarginPadding - { - Left = 10, - Right = 10 + HORIZONTAL_OVERFLOW_PADDING, - Vertical = 10, - }, - RelativeSizeAxes = Axes.Both, - Child = new MatchChatDisplay - { - RelativeSizeAxes = Axes.Both - } - }, - }, - }, + OnStart = onStart, + SelectedItem = { BindTarget = SelectedItem } } - }, + } }, RowDimensions = new[] { + new Dimension(), new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed), } }, - new Container + settingsOverlay = new MatchSettingsOverlay { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = Components.Header.HEIGHT }, - Child = settings = new MatchSettingsOverlay { RelativeSizeAxes = Axes.Both }, - }, + EditPlaylist = () => this.Push(new MatchSongSelect()), + State = { Value = roomId.Value == null ? Visibility.Visible : Visibility.Hidden } + } }; - - header.Tabs.Current.BindValueChanged(tab => - { - const float fade_duration = 500; - - var settingsDisplayed = tab.NewValue is SettingsMatchPage; - - header.ShowBeatmapPanel.Value = !settingsDisplayed; - settings.State.Value = settingsDisplayed ? Visibility.Visible : Visibility.Hidden; - info.FadeTo(settingsDisplayed ? 0 : 1, fade_duration, Easing.OutQuint); - bottomRow.FadeTo(settingsDisplayed ? 0 : 1, fade_duration, Easing.OutQuint); - }, true); - - beatmapManager.ItemAdded += beatmapAdded; } + [Resolved] + private IAPIProvider api { get; set; } + protected override void LoadComplete() { base.LoadComplete(); - CurrentItem.BindValueChanged(currentItemChanged, true); + roomId.BindValueChanged(id => + { + if (id.NewValue == null) + settingsOverlay.Show(); + else + { + settingsOverlay.Hide(); + + // Set the first playlist item. + // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). + Schedule(() => SelectedItem.Value = playlist.FirstOrDefault()); + } + }, true); + + SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); + SelectedItem.Value = playlist.FirstOrDefault(); + + managerUpdated = beatmapManager.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); } public override bool OnExiting(IScreen next) { RoomManager?.PartRoom(); Mods.Value = Array.Empty(); - previewTrackManager.StopAnyPlaying(this); return base.OnExiting(next); } - /// - /// Handles propagation of the current playlist item's content to game-wide mechanisms. - /// - private void currentItemChanged(ValueChangedEvent e) + private void selectedItemChanged() { - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = e.NewValue?.Beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == e.NewValue.Beatmap.OnlineBeatmapID); + updateWorkingBeatmap(); - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - Mods.Value = e.NewValue?.RequiredMods?.ToArray() ?? Array.Empty(); + var item = SelectedItem.Value; - if (e.NewValue?.Ruleset != null) - Ruleset.Value = e.NewValue.Ruleset; + Mods.Value = item?.RequiredMods?.ToArray() ?? Array.Empty(); - previewTrackManager.StopAnyPlaying(this); + if (item?.Ruleset != null) + Ruleset.Value = item.Ruleset.Value; } - /// - /// Handle the case where a beatmap is imported (and can be used by this match). - /// - private void beatmapAdded(BeatmapSetInfo model) => Schedule(() => + private void beatmapUpdated(ValueChangedEvent> weakSet) => Schedule(updateWorkingBeatmap); + + private void updateWorkingBeatmap() { - if (Beatmap.Value != beatmapManager.DefaultBeatmap) - return; + var beatmap = SelectedItem.Value?.Beatmap.Value; - if (CurrentItem.Value == null) - return; + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == beatmap.OnlineBeatmapID); - // Try to retrieve the corresponding local beatmap - var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == CurrentItem.Value.Beatmap.OnlineBeatmapID); - - if (localBeatmap != null) - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - }); - - [Resolved(canBeNull: true)] - private Multiplayer multiplayer { get; set; } + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + } private void onStart() { - previewTrackManager.StopAnyPlaying(this); - switch (type.Value) { default: case GameTypeTimeshift _: - multiplayer?.Start(() => new TimeshiftPlayer(CurrentItem.Value) + multiplayer?.Push(new PlayerLoader(() => new TimeshiftPlayer(SelectedItem.Value) { Exited = () => leaderboard.RefreshScores() - }); + })); break; } } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmapManager != null) - beatmapManager.ItemAdded -= beatmapAdded; - } } } diff --git a/osu.Game/Screens/Multi/Multiplayer.cs b/osu.Game/Screens/Multi/Multiplayer.cs index 86d52ff791..269eab5772 100644 --- a/osu.Game/Screens/Multi/Multiplayer.cs +++ b/osu.Game/Screens/Multi/Multiplayer.cs @@ -1,28 +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 osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.BeatmapSet.Buttons; using osu.Game.Screens.Menu; +using osu.Game.Screens.Multi.Components; using osu.Game.Screens.Multi.Lounge; using osu.Game.Screens.Multi.Lounge.Components; using osu.Game.Screens.Multi.Match; -using osu.Game.Screens.Play; +using osu.Game.Screens.Multi.Match.Components; using osuTK; namespace osu.Game.Screens.Multi @@ -32,6 +32,8 @@ namespace osu.Game.Screens.Multi { public override bool CursorVisible => (screenStack.CurrentScreen as IMultiplayerSubScreen)?.CursorVisible ?? true; + // this is required due to PlayerLoader eventually being pushed to the main stack + // while leases may be taken out by a subscreen. public override bool DisallowExternalBeatmapRulesetChanges => true; private readonly MultiplayerWaveContainer waves; @@ -43,7 +45,7 @@ namespace osu.Game.Screens.Multi private readonly IBindable isIdle = new BindableBool(); [Cached] - private readonly Bindable currentRoom = new Bindable(); + private readonly Bindable selectedRoom = new Bindable(); [Cached] private readonly Bindable currentFilter = new Bindable(new FilterCriteria()); @@ -60,6 +62,9 @@ namespace osu.Game.Screens.Multi [Resolved(CanBeNull = true)] private OsuLogo logo { get; set; } + private readonly Drawable header; + private readonly Drawable headerBackground; + public Multiplayer() { Anchor = Anchor.Centre; @@ -67,59 +72,72 @@ namespace osu.Game.Screens.Multi RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + var backgroundColour = Color4Extensions.FromHex(@"3e3a44"); + InternalChild = waves = new MultiplayerWaveContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Container + new Box { RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"3e3a44"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"3c3842"), - ColourDark = OsuColour.FromHex(@"393540"), - TriangleScale = 5, - }, - }, + Colour = backgroundColour, }, new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = Header.HEIGHT }, - Child = screenStack = new OsuScreenStack(loungeSubScreen = new LoungeSubScreen()) { RelativeSizeAxes = Axes.Both } + Children = new[] + { + header = new Container + { + RelativeSizeAxes = Axes.X, + Height = 400, + Children = new[] + { + headerBackground = new Container + { + RelativeSizeAxes = Axes.Both, + Width = 1.25f, + Masking = true, + Children = new Drawable[] + { + new HeaderBackgroundSprite + { + RelativeSizeAxes = Axes.X, + Height = 400 // Keep a static height so the header doesn't change as it's resized between subscreens + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = -1 }, // 1px padding to avoid a 1px gap due to masking + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(backgroundColour.Opacity(0.5f), backgroundColour) + }, + } + } + }, + screenStack = new MultiplayerSubScreenStack { RelativeSizeAxes = Axes.Both } + } }, new Header(screenStack), - createButton = new HeaderButton + createButton = new CreateRoomButton { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.None, - Size = new Vector2(150, Header.HEIGHT - 20), - Margin = new MarginPadding - { - Top = 10, - Right = 10 + HORIZONTAL_OVERFLOW_PADDING, - }, - Text = "Create room", - Action = () => loungeSubScreen.Open(new Room - { - Name = { Value = $"{api.LocalUser}'s awesome room" } - }), + Action = createRoom }, roomManager = new RoomManager() } }; + screenStack.Push(loungeSubScreen = new LoungeSubScreen()); + screenStack.ScreenPushed += screenPushed; screenStack.ScreenExited += screenExited; } @@ -142,26 +160,39 @@ namespace osu.Game.Screens.Multi protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Model.BindTo(currentRoom); + dependencies.Model.BindTo(selectedRoom); return dependencies; } private void updatePollingRate(bool idle) - { - roomManager.TimeBetweenPolls = !this.IsCurrentScreen() || !(screenStack.CurrentScreen is LoungeSubScreen) ? 0 : (idle ? 120000 : 15000); - Logger.Log($"Polling adjusted to {roomManager.TimeBetweenPolls}"); - } - - /// - /// Push a to the main screen stack to begin gameplay. - /// Generally called from a via DI resolution. - /// - public void Start(Func player) { if (!this.IsCurrentScreen()) - return; + { + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = 0; + } + else + { + switch (screenStack.CurrentScreen) + { + case LoungeSubScreen _: + roomManager.TimeBetweenListingPolls = idle ? 120000 : 15000; + roomManager.TimeBetweenSelectionPolls = idle ? 120000 : 15000; + break; - this.Push(new PlayerLoader(player)); + case MatchSubScreen _: + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = idle ? 30000 : 5000; + break; + + default: + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = 0; + break; + } + } + + Logger.Log($"Polling adjusted (listing: {roomManager.TimeBetweenListingPolls}, selection: {roomManager.TimeBetweenSelectionPolls})"); } public void APIStateChanged(IAPIProvider api, APIState state) @@ -201,6 +232,8 @@ namespace osu.Game.Screens.Multi base.OnResuming(last); beginHandlingTrack(); + + updatePollingRate(isIdle.Value); } public override void OnSuspending(IScreen next) @@ -210,19 +243,13 @@ namespace osu.Game.Screens.Multi endHandlingTrack(); - roomManager.TimeBetweenPolls = 0; + updatePollingRate(isIdle.Value); } public override bool OnExiting(IScreen next) { roomManager.PartRoom(); - if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) - { - screenStack.Exit(); - return true; - } - waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); @@ -236,6 +263,20 @@ namespace osu.Game.Screens.Multi return false; } + public override bool OnBackButton() + { + if ((screenStack.CurrentScreen as IMultiplayerSubScreen)?.OnBackButton() == true) + return true; + + if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) + { + screenStack.Exit(); + return true; + } + + return false; + } + protected override void LogoExiting(OsuLogo logo) { base.LogoExiting(logo); @@ -244,6 +285,11 @@ namespace osu.Game.Screens.Multi logo.Delay(WaveContainer.DISAPPEAR_DURATION / 2).FadeOut(); } + private void createRoom() + { + loungeSubScreen.Open(new Room { Name = { Value = $"{api.LocalUser}'s awesome room" } }); + } + private void beginHandlingTrack() { Beatmap.BindValueChanged(updateTrack, true); @@ -255,7 +301,10 @@ namespace osu.Game.Screens.Multi Beatmap.ValueChanged -= updateTrack; } - private void screenPushed(IScreen lastScreen, IScreen newScreen) => subScreenChanged(newScreen); + private void screenPushed(IScreen lastScreen, IScreen newScreen) + { + subScreenChanged(newScreen); + } private void screenExited(IScreen lastScreen, IScreen newScreen) { @@ -267,6 +316,19 @@ namespace osu.Game.Screens.Multi private void subScreenChanged(IScreen newScreen) { + switch (newScreen) + { + case LoungeSubScreen _: + header.Delay(MultiplayerSubScreen.RESUME_TRANSITION_DELAY).ResizeHeightTo(400, MultiplayerSubScreen.APPEAR_DURATION, Easing.OutQuint); + headerBackground.MoveToX(0, MultiplayerSubScreen.X_MOVE_DURATION, Easing.OutQuint); + break; + + case MatchSubScreen _: + header.ResizeHeightTo(135, MultiplayerSubScreen.APPEAR_DURATION, Easing.OutQuint); + headerBackground.MoveToX(-MultiplayerSubScreen.X_SHIFT, MultiplayerSubScreen.X_MOVE_DURATION, Easing.OutQuint); + break; + } + updatePollingRate(isIdle.Value); createButton.FadeTo(newScreen is LoungeSubScreen ? 1 : 0, 200); @@ -275,11 +337,7 @@ namespace osu.Game.Screens.Multi private void updateTrack(ValueChangedEvent _ = null) { - bool isMatch = screenStack.CurrentScreen is MatchSubScreen; - - Beatmap.Disabled = isMatch; - - if (isMatch) + if (screenStack.CurrentScreen is MatchSubScreen) { var track = Beatmap.Value?.Track; @@ -321,10 +379,41 @@ namespace osu.Game.Screens.Multi public MultiplayerWaveContainer() { - FirstWaveColour = OsuColour.FromHex(@"654d8c"); - SecondWaveColour = OsuColour.FromHex(@"554075"); - ThirdWaveColour = OsuColour.FromHex(@"44325e"); - FourthWaveColour = OsuColour.FromHex(@"392850"); + FirstWaveColour = Color4Extensions.FromHex(@"654d8c"); + SecondWaveColour = Color4Extensions.FromHex(@"554075"); + ThirdWaveColour = Color4Extensions.FromHex(@"44325e"); + FourthWaveColour = Color4Extensions.FromHex(@"392850"); + } + } + + private class HeaderBackgroundSprite : MultiplayerBackgroundSprite + { + protected override UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new BackgroundSprite { RelativeSizeAxes = Axes.Both }; + + private class BackgroundSprite : UpdateableBeatmapBackgroundSprite + { + protected override double TransformDuration => 200; + } + } + + public class CreateRoomButton : PurpleTriangleButton + { + public CreateRoomButton() + { + Size = new Vector2(150, Header.HEIGHT - 20); + Margin = new MarginPadding + { + Top = 10, + Right = 10 + HORIZONTAL_OVERFLOW_PADDING, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + Triangles.TriangleScale = 1.5f; + + Text = "Create room"; } } } diff --git a/osu.Game/Screens/Multi/MultiplayerComposite.cs b/osu.Game/Screens/Multi/MultiplayerComposite.cs index 8c09d576ff..e612e77748 100644 --- a/osu.Game/Screens/Multi/MultiplayerComposite.cs +++ b/osu.Game/Screens/Multi/MultiplayerComposite.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -32,10 +31,7 @@ namespace osu.Game.Screens.Multi protected BindableList Playlist { get; private set; } [Resolved(typeof(Room))] - protected Bindable CurrentItem { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable> Participants { get; private set; } + protected BindableList RecentParticipants { get; private set; } [Resolved(typeof(Room))] protected Bindable ParticipantCount { get; private set; } diff --git a/osu.Game/Screens/Multi/MultiplayerSubScreen.cs b/osu.Game/Screens/Multi/MultiplayerSubScreen.cs index ff94f63f01..8e46de1a95 100644 --- a/osu.Game/Screens/Multi/MultiplayerSubScreen.cs +++ b/osu.Game/Screens/Multi/MultiplayerSubScreen.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Multi { @@ -24,31 +23,41 @@ namespace osu.Game.Screens.Multi RelativeSizeAxes = Axes.Both; } + public const float X_SHIFT = 200; + + public const double X_MOVE_DURATION = 800; + + public const double RESUME_TRANSITION_DELAY = DISAPPEAR_DURATION / 2; + + public const double APPEAR_DURATION = 800; + + public const double DISAPPEAR_DURATION = 500; + public override void OnEntering(IScreen last) { - this.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(200).MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); + this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); + this.MoveToX(X_SHIFT).MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); } public override bool OnExiting(IScreen next) { - this.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); + this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); + this.MoveToX(X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); return false; } public override void OnResuming(IScreen last) { - this.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + this.Delay(RESUME_TRANSITION_DELAY).FadeIn(APPEAR_DURATION, Easing.OutQuint); + this.MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); } public override void OnSuspending(IScreen next) { - this.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(-200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); + this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); + this.MoveToX(-X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); } public override string ToString() => Title; diff --git a/osu.Game/Screens/Multi/MultiplayerSubScreenStack.cs b/osu.Game/Screens/Multi/MultiplayerSubScreenStack.cs new file mode 100644 index 0000000000..3b0ed0dba1 --- /dev/null +++ b/osu.Game/Screens/Multi/MultiplayerSubScreenStack.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.Screens; + +namespace osu.Game.Screens.Multi +{ + public class MultiplayerSubScreenStack : OsuScreenStack + { + protected override void ScreenChanged(IScreen prev, IScreen next) + { + base.ScreenChanged(prev, next); + + // because this is a screen stack within a screen stack, let's manually handle disabled changes to simplify things. + var osuScreen = ((OsuScreen)next); + + bool disallowChanges = osuScreen.DisallowExternalBeatmapRulesetChanges; + + osuScreen.Beatmap.Disabled = disallowChanges; + osuScreen.Ruleset.Disabled = disallowChanges; + osuScreen.Mods.Disabled = disallowChanges; + } + } +} diff --git a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs index 88c6fc5e2e..cf0197d26b 100644 --- a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs +++ b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs @@ -50,10 +50,10 @@ namespace osu.Game.Screens.Multi.Play bool failed = false; // Sanity checks to ensure that TimeshiftPlayer matches the settings for the current PlaylistItem - if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != playlistItem.Beatmap.OnlineBeatmapID) + if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != playlistItem.Beatmap.Value.OnlineBeatmapID) throw new InvalidOperationException("Current Beatmap does not match PlaylistItem's Beatmap"); - if (ruleset.Value.ID != playlistItem.Ruleset.ID) + if (ruleset.Value.ID != playlistItem.Ruleset.Value.ID) throw new InvalidOperationException("Current Ruleset does not match PlaylistItem's Ruleset"); if (!playlistItem.RequiredMods.All(m => Mods.Value.Any(m.Equals))) @@ -90,23 +90,25 @@ namespace osu.Game.Screens.Multi.Play return false; } - protected override ScoreInfo CreateScore() + protected override ResultsScreen CreateResults(ScoreInfo score) { - submitScore(); - return base.CreateScore(); + Debug.Assert(roomId.Value != null); + return new TimeshiftResultsScreen(score, roomId.Value.Value, playlistItem); } - private void submitScore() + protected override ScoreInfo CreateScore() { var score = base.CreateScore(); - score.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); Debug.Assert(token != null); var request = new SubmitRoomScoreRequest(token.Value, roomId.Value ?? 0, playlistItem.ID, score); + request.Success += s => score.OnlineScoreID = s.ID; request.Failure += e => Logger.Error(e, "Failed to submit score"); api.Queue(request); + + return score; } protected override void Dispose(bool isDisposing) @@ -115,7 +117,5 @@ namespace osu.Game.Screens.Multi.Play Exited = null; } - - protected override Results CreateResults(ScoreInfo score) => new MatchResults(score); } } diff --git a/osu.Game/Screens/Multi/Ranking/MatchResults.cs b/osu.Game/Screens/Multi/Ranking/MatchResults.cs deleted file mode 100644 index fe68d7e849..0000000000 --- a/osu.Game/Screens/Multi/Ranking/MatchResults.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Ranking.Types; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Types; - -namespace osu.Game.Screens.Multi.Ranking -{ - public class MatchResults : Results - { - public MatchResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable CreateResultPages() => new IResultPageInfo[] - { - new ScoreOverviewPageInfo(Score, Beatmap.Value), - new LocalLeaderboardPageInfo(Score, Beatmap.Value), - new RoomLeaderboardPageInfo(Score, Beatmap.Value), - }; - } -} diff --git a/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs b/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs deleted file mode 100644 index ff5471cf4a..0000000000 --- a/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Internal; -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.Lists; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Leaderboards; -using osu.Game.Online.Multiplayer; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Screens.Ranking; - -namespace osu.Game.Screens.Multi.Ranking.Pages -{ - public class RoomLeaderboardPage : ResultsPage - { - private OsuColour colours; - private TextFlowContainer rankText; - - [Resolved(typeof(Room), nameof(Room.Name))] - private Bindable name { get; set; } - - public RoomLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - this.colours = colours; - - MatchLeaderboard leaderboard; - - Children = new Drawable[] - { - new Box - { - Colour = colours.Gray6, - RelativeSizeAxes = Axes.Both, - }, - new BufferedContainer - { - RelativeSizeAxes = Axes.Both, - BackgroundColour = colours.Gray6, - Child = leaderboard = CreateLeaderboard() - }, - rankText = new TextFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Width = 0.5f, - AutoSizeAxes = Axes.Y, - Y = 50, - TextAnchor = Anchor.TopCentre - }, - }; - - leaderboard.Origin = Anchor.Centre; - leaderboard.Anchor = Anchor.Centre; - leaderboard.RelativeSizeAxes = Axes.Both; - leaderboard.Height = 0.8f; - leaderboard.Y = 55; - leaderboard.ScoresLoaded = scoresLoaded; - } - - private void scoresLoaded(IEnumerable scores) - { - void gray(SpriteText s) => s.Colour = colours.GrayC; - - void white(SpriteText s) - { - s.Font = s.Font.With(size: s.Font.Size * 1.4f); - s.Colour = colours.GrayF; - } - - rankText.AddText(name + "\n", white); - rankText.AddText("You are placed ", gray); - - int index = scores.IndexOf(new APIUserScoreAggregate { User = Score.User }, new FuncEqualityComparer((s1, s2) => s1.User.Id.Equals(s2.User.Id))); - - rankText.AddText($"#{index + 1} ", s => - { - s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold); - s.Colour = colours.YellowDark; - }); - - rankText.AddText("in the room!", gray); - } - - protected virtual MatchLeaderboard CreateLeaderboard() => new ResultsMatchLeaderboard(); - - public class ResultsMatchLeaderboard : MatchLeaderboard - { - protected override bool FadeTop => true; - - protected override LeaderboardScore CreateDrawableScore(APIUserScoreAggregate model, int index) - => new ResultsMatchLeaderboardScore(model, index); - - protected override FillFlowContainer CreateScoreFlow() - { - var flow = base.CreateScoreFlow(); - flow.Padding = new MarginPadding - { - Top = LeaderboardScore.HEIGHT * 2, - Bottom = LeaderboardScore.HEIGHT * 3, - }; - return flow; - } - - private class ResultsMatchLeaderboardScore : MatchLeaderboardScore - { - public ResultsMatchLeaderboardScore(APIUserScoreAggregate score, int rank) - : base(score, rank) - { - } - - [BackgroundDependencyLoader] - private void load() - { - } - } - } - } -} diff --git a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs new file mode 100644 index 0000000000..5cafc974f1 --- /dev/null +++ b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Multiplayer; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.Multi.Ranking +{ + public class TimeshiftResultsScreen : ResultsScreen + { + private readonly int roomId; + private readonly PlaylistItem playlistItem; + + private LoadingSpinner loadingLayer; + + public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + : base(score, allowRetry) + { + this.roomId = roomId; + this.playlistItem = playlistItem; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(loadingLayer = new LoadingLayer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -10, + State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, + Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y } + }); + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + var req = new GetRoomPlaylistScoresRequest(roomId, playlistItem.ID); + + req.Success += r => + { + scoresCallback?.Invoke(r.Scores.Where(s => s.ID != Score?.OnlineScoreID).Select(s => s.CreateScoreInfo(playlistItem))); + loadingLayer.Hide(); + }; + + req.Failure += _ => loadingLayer.Hide(); + + return req; + } + } +} diff --git a/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.cs b/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.cs deleted file mode 100644 index dcfad8458f..0000000000 --- a/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.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 osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Ranking.Pages; -using osu.Game.Screens.Ranking; - -namespace osu.Game.Screens.Multi.Ranking.Types -{ - public class RoomLeaderboardPageInfo : IResultPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public RoomLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public IconUsage Icon => FontAwesome.Solid.Users; - - public string Name => "Room Leaderboard"; - - public virtual ResultsPage CreatePage() => new RoomLeaderboardPage(score, beatmap); - } -} diff --git a/osu.Game/Screens/Multi/RoomManager.cs b/osu.Game/Screens/Multi/RoomManager.cs index cdaba85b9e..491be2e946 100644 --- a/osu.Game/Screens/Multi/RoomManager.cs +++ b/osu.Game/Screens/Multi/RoomManager.cs @@ -2,10 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online; @@ -17,20 +21,27 @@ using osu.Game.Screens.Multi.Lounge.Components; namespace osu.Game.Screens.Multi { - public class RoomManager : PollingComponent, IRoomManager + public class RoomManager : CompositeDrawable, IRoomManager { public event Action RoomsUpdated; private readonly BindableList rooms = new BindableList(); + + public Bindable InitialRoomsReceived { get; } = new Bindable(); + public IBindableList Rooms => rooms; - private Room joinedRoom; + public double TimeBetweenListingPolls + { + get => listingPollingComponent.TimeBetweenPolls; + set => listingPollingComponent.TimeBetweenPolls = value; + } - [Resolved] - private Bindable currentFilter { get; set; } - - [Resolved] - private IAPIProvider api { get; set; } + public double TimeBetweenSelectionPolls + { + get => selectionPollingComponent.TimeBetweenPolls; + set => selectionPollingComponent.TimeBetweenPolls = value; + } [Resolved] private RulesetStore rulesets { get; set; } @@ -38,14 +49,30 @@ namespace osu.Game.Screens.Multi [Resolved] private BeatmapManager beatmaps { get; set; } - [BackgroundDependencyLoader] - private void load() + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable selectedRoom { get; set; } + + private readonly ListingPollingComponent listingPollingComponent; + private readonly SelectionPollingComponent selectionPollingComponent; + + private Room joinedRoom; + + public RoomManager() { - currentFilter.BindValueChanged(_ => + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] { - if (IsLoaded) - PollImmediately(); - }); + listingPollingComponent = new ListingPollingComponent + { + InitialRoomsReceived = { BindTarget = InitialRoomsReceived }, + RoomsReceived = onListingReceived + }, + selectionPollingComponent = new SelectionPollingComponent { RoomReceived = onSelectedRoomReceived } + }; } protected override void Dispose(bool isDisposing) @@ -87,7 +114,7 @@ namespace osu.Game.Screens.Multi public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) { currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room, api.LocalUser.Value); + currentJoinRoomRequest = new JoinRoomRequest(room); currentJoinRoomRequest.Success += () => { @@ -112,49 +139,74 @@ namespace osu.Game.Screens.Multi if (joinedRoom == null) return; - api.Queue(new PartRoomRequest(joinedRoom, api.LocalUser.Value)); + api.Queue(new PartRoomRequest(joinedRoom)); joinedRoom = null; } - private GetRoomsRequest pollReq; + private readonly HashSet ignoredRooms = new HashSet(); - protected override Task Poll() + /// + /// Invoked when the listing of all s is received from the server. + /// + /// The listing. + private void onListingReceived(List listing) { - if (!api.IsLoggedIn) - return base.Poll(); - - var tcs = new TaskCompletionSource(); - - pollReq?.Cancel(); - pollReq = new GetRoomsRequest(currentFilter.Value.PrimaryFilter); - - pollReq.Success += result => + // Remove past matches + foreach (var r in rooms.ToList()) { - // Remove past matches - foreach (var r in rooms.ToList()) + if (listing.All(e => e.RoomID.Value != r.RoomID.Value)) + rooms.Remove(r); + } + + for (int i = 0; i < listing.Count; i++) + { + if (selectedRoom.Value?.RoomID?.Value == listing[i].RoomID.Value) { - if (result.All(e => e.RoomID.Value != r.RoomID.Value)) - rooms.Remove(r); + // The listing request contains less data than the selection request, so data from the selection request is always preferred while the room is selected. + continue; } - for (int i = 0; i < result.Count; i++) + var room = listing[i]; + + Debug.Assert(room.RoomID.Value != null); + + if (ignoredRooms.Contains(room.RoomID.Value.Value)) + continue; + + room.Position.Value = i; + + try { - var r = result[i]; - r.Position.Value = i; - - update(r, r); - addRoom(r); + update(room, room); + addRoom(room); } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); - RoomsUpdated?.Invoke(); - tcs.SetResult(true); - }; + ignoredRooms.Add(room.RoomID.Value.Value); + rooms.Remove(room); + } + } - pollReq.Failure += _ => tcs.SetResult(false); + RoomsUpdated?.Invoke(); + } - api.Queue(pollReq); - - return tcs.Task; + /// + /// Invoked when a is received from the server. + /// + /// The received . + private void onSelectedRoomReceived(Room toUpdate) + { + foreach (var room in rooms) + { + if (room.RoomID.Value == toUpdate.RoomID.Value) + { + toUpdate.Position.Value = room.Position.Value; + update(room, toUpdate); + break; + } + } } /// @@ -182,5 +234,105 @@ namespace osu.Game.Screens.Multi else existing.CopyFrom(room); } + + private class SelectionPollingComponent : PollingComponent + { + public Action RoomReceived; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable selectedRoom { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + selectedRoom.BindValueChanged(_ => + { + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomRequest pollReq; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + if (selectedRoom.Value?.RoomID.Value == null) + return base.Poll(); + + var tcs = new TaskCompletionSource(); + + pollReq?.Cancel(); + pollReq = new GetRoomRequest(selectedRoom.Value.RoomID.Value.Value); + + pollReq.Success += result => + { + RoomReceived?.Invoke(result); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + api.Queue(pollReq); + + return tcs.Task; + } + } + + private class ListingPollingComponent : PollingComponent + { + public Action> RoomsReceived; + + public readonly Bindable InitialRoomsReceived = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable currentFilter { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + currentFilter.BindValueChanged(_ => + { + InitialRoomsReceived.Value = false; + + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomsRequest pollReq; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + var tcs = new TaskCompletionSource(); + + pollReq?.Cancel(); + pollReq = new GetRoomsRequest(currentFilter.Value.StatusFilter, currentFilter.Value.RoomCategoryFilter); + + pollReq.Success += result => + { + InitialRoomsReceived.Value = true; + RoomsReceived?.Invoke(result); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + api.Queue(pollReq); + + return tcs.Task; + } + } } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 6394fb8d23..872a1cd39a 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Internal; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -23,14 +23,14 @@ namespace osu.Game.Screens { /// /// The amount of negative padding that should be applied to game background content which touches both the left and right sides of the screen. - /// This allows for the game content to be pushed byt he options/notification overlays without causing black areas to appear. + /// This allows for the game content to be pushed by the options/notification overlays without causing black areas to appear. /// public const float HORIZONTAL_OVERFLOW_PADDING = 50; /// /// A user-facing title for this screen. /// - public virtual string Title => GetType().ShortDisplayName(); + public virtual string Title => GetType().Name; public string Description => Title; @@ -95,15 +95,30 @@ namespace osu.Game.Screens public Bindable> Mods { get; private set; } + private OsuScreenDependencies screenDependencies; + + internal void CreateLeasedDependencies(IReadOnlyDependencyContainer dependencies) => createDependencies(dependencies); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var screenDependencies = new OsuScreenDependencies(DisallowExternalBeatmapRulesetChanges, parent); + if (screenDependencies == null) + { + if (DisallowExternalBeatmapRulesetChanges) + throw new InvalidOperationException($"Screens that specify {nameof(DisallowExternalBeatmapRulesetChanges)} must be pushed immediately."); + + createDependencies(parent); + } + + return base.CreateChildDependencies(screenDependencies); + } + + private void createDependencies(IReadOnlyDependencyContainer dependencies) + { + screenDependencies = new OsuScreenDependencies(DisallowExternalBeatmapRulesetChanges, dependencies); Beatmap = screenDependencies.Beatmap; Ruleset = screenDependencies.Ruleset; Mods = screenDependencies.Mods; - - return base.CreateChildDependencies(screenDependencies); } protected BackgroundScreen Background => backgroundStack?.CurrentScreen as BackgroundScreen; @@ -243,5 +258,7 @@ namespace osu.Game.Screens /// Note that the instance created may not be the used instance if it matches the BackgroundMode equality clause. /// protected virtual BackgroundScreen CreateBackground() => null; + + public virtual bool OnBackButton() => false; } } diff --git a/osu.Game/Screens/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index 0844e32d46..e2a0414df7 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -13,22 +13,11 @@ namespace osu.Game.Screens [Cached] private BackgroundScreenStack backgroundScreenStack; - private ParallaxContainer parallaxContainer; + private readonly ParallaxContainer parallaxContainer; protected float ParallaxAmount => parallaxContainer.ParallaxAmount; public OsuScreenStack() - { - initializeStack(); - } - - public OsuScreenStack(IScreen baseScreen) - : base(baseScreen) - { - initializeStack(); - } - - private void initializeStack() { InternalChild = parallaxContainer = new ParallaxContainer { @@ -36,13 +25,32 @@ namespace osu.Game.Screens Child = backgroundScreenStack = new BackgroundScreenStack { RelativeSizeAxes = Axes.Both }, }; - ScreenPushed += onScreenChange; - ScreenExited += onScreenChange; + ScreenPushed += screenPushed; + ScreenExited += ScreenChanged; } - private void onScreenChange(IScreen prev, IScreen next) + private void screenPushed(IScreen prev, IScreen next) { - parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; + if (LoadState < LoadState.Ready) + { + // dependencies must be present to stay in a sane state. + // this is generally only ever hit by test scenes. + Schedule(() => screenPushed(prev, next)); + return; + } + + // create dependencies synchronously to ensure leases are in a sane state. + ((OsuScreen)next).CreateLeasedDependencies((prev as OsuScreen)?.Dependencies ?? Dependencies); + + ScreenChanged(prev, next); } + + protected virtual void ScreenChanged(IScreen prev, IScreen next) + { + setParallax(next); + } + + private void setParallax(IScreen next) => + parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; } } diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs new file mode 100644 index 0000000000..a84a85ea47 --- /dev/null +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.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.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.Play +{ + /// + /// Displays beatmap metadata inside + /// + public class BeatmapMetadataDisplay : Container + { + private class MetadataLine : Container + { + public MetadataLine(string left, string right) + { + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 5 }, + Colour = OsuColour.Gray(0.8f), + Text = left, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft, + Margin = new MarginPadding { Left = 5 }, + Text = string.IsNullOrEmpty(right) ? @"-" : right, + } + }; + } + } + + private readonly WorkingBeatmap beatmap; + private readonly Bindable> mods; + private readonly Drawable facade; + private LoadingSpinner loading; + private Sprite backgroundSprite; + + public IBindable> Mods => mods; + + public bool Loading + { + set + { + if (value) + loading.Show(); + else + loading.Hide(); + } + } + + public BeatmapMetadataDisplay(WorkingBeatmap beatmap, Bindable> mods, Drawable facade) + { + this.beatmap = beatmap; + this.facade = facade; + + this.mods = new Bindable>(); + this.mods.BindTo(mods); + } + + [BackgroundDependencyLoader] + private void load() + { + var metadata = beatmap.BeatmapInfo?.Metadata ?? new BeatmapMetadata(); + + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Direction = FillDirection.Vertical, + Children = new[] + { + facade.With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + }), + new OsuSpriteText + { + Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Font = OsuFont.GetFont(size: 36, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding { Top = 15 }, + }, + new OsuSpriteText + { + Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Font = OsuFont.GetFont(size: 26, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new Container + { + Size = new Vector2(300, 60), + Margin = new MarginPadding(10), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + CornerRadius = 10, + Masking = true, + Children = new Drawable[] + { + backgroundSprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = beatmap?.Background, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + FillMode = FillMode.Fill, + }, + loading = new LoadingLayer(backgroundSprite) + } + }, + new OsuSpriteText + { + Text = beatmap?.BeatmapInfo?.Version, + Font = OsuFont.GetFont(size: 26, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Bottom = 40 + }, + }, + new MetadataLine("Source", metadata.Source) + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new MetadataLine("Mapper", metadata.AuthorString) + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new ModDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 20 }, + Current = mods + } + }, + } + }; + + Loading = true; + } + } +} diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index a3d64d05a3..6e129b20ea 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.cs @@ -30,7 +30,7 @@ namespace osu.Game.Screens.Play.Break Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "current progress".ToUpperInvariant(), - Font = OsuFont.GetFont(weight: FontWeight.Black, size: 15), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15), }, new FillFlowContainer { diff --git a/osu.Game/Screens/Play/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs index 70e7b8f297..18aab394f8 100644 --- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs +++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Utils; namespace osu.Game.Screens.Play.Break { @@ -85,6 +86,6 @@ namespace osu.Game.Screens.Play.Break { } - protected override string Format(double count) => $@"{count:P2}"; + protected override string Format(double count) => count.FormatAccuracy(); } } diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index ee8be87352..36f825b8f6 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -2,8 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -16,8 +14,6 @@ namespace osu.Game.Screens.Play { public class BreakOverlay : Container { - private readonly ScoreProcessor scoreProcessor; - /// /// The duration of the break overlay fading. /// @@ -37,10 +33,6 @@ namespace osu.Game.Screens.Play { breaks = value; - // reset index in case the new breaks list is smaller than last one - isBreakTime.Value = false; - CurrentBreakIndex = 0; - if (IsLoaded) initializeBreaks(); } @@ -48,27 +40,17 @@ namespace osu.Game.Screens.Play public override bool RemoveCompletedTransforms => false; - /// - /// Whether the gameplay is currently in a break. - /// - public IBindable IsBreakTime => isBreakTime; - - protected int CurrentBreakIndex; - - private readonly BindableBool isBreakTime = new BindableBool(); - private readonly Container remainingTimeAdjustmentBox; private readonly Container remainingTimeBox; private readonly RemainingTimeCounter remainingTimeCounter; - private readonly BreakInfo info; private readonly BreakArrows breakArrows; - private readonly double gameplayStartTime; - public BreakOverlay(bool letterboxing, double gameplayStartTime = 0, ScoreProcessor scoreProcessor = null) + public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor) { - this.gameplayStartTime = gameplayStartTime; - this.scoreProcessor = scoreProcessor; RelativeSizeAxes = Axes.Both; + + BreakInfo info; + Child = fadeContainer = new Container { Alpha = 0, @@ -119,13 +101,11 @@ namespace osu.Game.Screens.Play } }; - if (scoreProcessor != null) bindProcessor(scoreProcessor); - } - - [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock) - { - if (clock != null) Clock = clock; + if (scoreProcessor != null) + { + info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); + info.GradeDisplay.Current.BindTo(scoreProcessor.Rank); + } } protected override void LoadComplete() @@ -134,48 +114,12 @@ namespace osu.Game.Screens.Play initializeBreaks(); } - protected override void Update() - { - base.Update(); - updateBreakTimeBindable(); - } - - private void updateBreakTimeBindable() => - isBreakTime.Value = getCurrentBreak()?.HasEffect == true - || Clock.CurrentTime < gameplayStartTime - || scoreProcessor?.HasCompleted == true; - - private BreakPeriod getCurrentBreak() - { - if (breaks?.Count > 0) - { - var time = Clock.CurrentTime; - - if (time > breaks[CurrentBreakIndex].EndTime) - { - while (time > breaks[CurrentBreakIndex].EndTime && CurrentBreakIndex < breaks.Count - 1) - CurrentBreakIndex++; - } - else - { - while (time < breaks[CurrentBreakIndex].StartTime && CurrentBreakIndex > 0) - CurrentBreakIndex--; - } - - var closest = breaks[CurrentBreakIndex]; - - return closest.Contains(time) ? closest : null; - } - - return null; - } - private void initializeBreaks() { FinishTransforms(true); Scheduler.CancelDelayedTasks(); - if (breaks == null) return; //we need breaks. + if (breaks == null) return; // we need breaks. foreach (var b in breaks) { @@ -207,11 +151,5 @@ namespace osu.Game.Screens.Play } } } - - private void bindProcessor(ScoreProcessor processor) - { - info.AccuracyDisplay.Current.BindTo(processor.Accuracy); - info.GradeDisplay.Current.BindTo(processor.Rank); - } } } diff --git a/osu.Game/Screens/Play/BreakTracker.cs b/osu.Game/Screens/Play/BreakTracker.cs new file mode 100644 index 0000000000..51e21656e1 --- /dev/null +++ b/osu.Game/Screens/Play/BreakTracker.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.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; + +namespace osu.Game.Screens.Play +{ + public class BreakTracker : Component + { + private readonly ScoreProcessor scoreProcessor; + private readonly double gameplayStartTime; + + private PeriodTracker breaks; + + /// + /// Whether the gameplay is currently in a break. + /// + public IBindable IsBreakTime => isBreakTime; + + private readonly BindableBool isBreakTime = new BindableBool(); + + public IReadOnlyList Breaks + { + set + { + isBreakTime.Value = false; + + breaks = new PeriodTracker(value.Where(b => b.HasEffect) + .Select(b => new Period(b.StartTime, b.EndTime - BreakOverlay.BREAK_FADE_DURATION))); + } + } + + public BreakTracker(double gameplayStartTime = 0, ScoreProcessor scoreProcessor = null) + { + this.gameplayStartTime = gameplayStartTime; + this.scoreProcessor = scoreProcessor; + } + + protected override void Update() + { + base.Update(); + + var time = Clock.CurrentTime; + + isBreakTime.Value = breaks?.IsInAny(time) == true + || time < gameplayStartTime + || scoreProcessor?.HasCompleted.Value == true; + } + } +} diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs index 1c4ac921f0..5bcda50399 100644 --- a/osu.Game/Screens/Play/ComboEffects.cs +++ b/osu.Game/Screens/Play/ComboEffects.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; @@ -16,27 +17,34 @@ namespace osu.Game.Screens.Play private SkinnableSound comboBreakSample; + private Bindable alwaysPlay; + private bool firstTime = true; + public ComboEffects(ScoreProcessor processor) { this.processor = processor; } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { InternalChild = comboBreakSample = new SkinnableSound(new SampleInfo("combobreak")); + alwaysPlay = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak); } protected override void LoadComplete() { base.LoadComplete(); - processor.Combo.BindValueChanged(onComboChange, true); + processor.Combo.BindValueChanged(onComboChange); } private void onComboChange(ValueChangedEvent combo) { - if (combo.NewValue == 0 && combo.OldValue > 20) + if (combo.NewValue == 0 && (combo.OldValue > 20 || (alwaysPlay.Value && firstTime))) + { comboBreakSample?.Play(); + firstTime = false; + } } } } diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 0fe315fbab..58eb95b7c6 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; @@ -13,6 +14,8 @@ namespace osu.Game.Screens.Play /// public class DimmableStoryboard : UserDimContainer { + public Container OverlayLayerContainer { get; private set; } + private readonly Storyboard storyboard; private DrawableStoryboard drawableStoryboard; @@ -24,6 +27,8 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader] private void load() { + Add(OverlayLayerContainer = new Container()); + initializeStoryboard(false); } @@ -44,12 +49,17 @@ namespace osu.Game.Screens.Play return; drawableStoryboard = storyboard.CreateDrawable(); - drawableStoryboard.Masking = true; if (async) - LoadComponentAsync(drawableStoryboard, Add); + LoadComponentAsync(drawableStoryboard, onStoryboardCreated); else - Add(drawableStoryboard); + onStoryboardCreated(drawableStoryboard); + } + + private void onStoryboardCreated(DrawableStoryboard storyboard) + { + Add(storyboard); + OverlayLayerContainer.Add(storyboard.OverlayLayer.CreateProxy()); } } } diff --git a/osu.Game/Screens/Play/DimmableVideo.cs b/osu.Game/Screens/Play/DimmableVideo.cs deleted file mode 100644 index 1a01cace17..0000000000 --- a/osu.Game/Screens/Play/DimmableVideo.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Video; -using osu.Game.Graphics.Containers; -using osuTK.Graphics; - -namespace osu.Game.Screens.Play -{ - public class DimmableVideo : UserDimContainer - { - private readonly VideoSprite video; - private DrawableVideo drawableVideo; - - public DimmableVideo(VideoSprite video) - { - this.video = video; - } - - [BackgroundDependencyLoader] - private void load() - { - initializeVideo(false); - } - - protected override void LoadComplete() - { - ShowVideo.BindValueChanged(_ => initializeVideo(true), true); - base.LoadComplete(); - } - - protected override bool ShowDimContent => IgnoreUserSettings.Value || (ShowVideo.Value && DimLevel < 1); - - private void initializeVideo(bool async) - { - if (video == null) - return; - - if (drawableVideo != null) - return; - - if (!ShowVideo.Value && !IgnoreUserSettings.Value) - return; - - drawableVideo = new DrawableVideo(video); - - if (async) - LoadComponentAsync(drawableVideo, Add); - else - Add(drawableVideo); - } - - private class DrawableVideo : Container - { - public DrawableVideo(VideoSprite video) - { - RelativeSizeAxes = Axes.Both; - Masking = true; - - video.RelativeSizeAxes = Axes.Both; - video.FillMode = FillMode.Fit; - video.Anchor = Anchor.Centre; - video.Origin = Anchor.Centre; - - AddRangeInternal(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - video, - }); - } - - [BackgroundDependencyLoader] - private void load(GameplayClock clock) - { - if (clock != null) - Clock = clock; - } - } - } -} diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index a3caffb620..54c644c999 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -10,7 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; using osuTK; diff --git a/osu.Game/Screens/Play/GameplayBeatmap.cs b/osu.Game/Screens/Play/GameplayBeatmap.cs new file mode 100644 index 0000000000..64894544f4 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayBeatmap.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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Play +{ + public class GameplayBeatmap : Component, IBeatmap + { + public readonly IBeatmap PlayableBeatmap; + + public GameplayBeatmap(IBeatmap playableBeatmap) + { + PlayableBeatmap = playableBeatmap; + } + + public BeatmapInfo BeatmapInfo + { + get => PlayableBeatmap.BeatmapInfo; + set => PlayableBeatmap.BeatmapInfo = value; + } + + public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; + + public ControlPointInfo ControlPointInfo => PlayableBeatmap.ControlPointInfo; + + public List Breaks => PlayableBeatmap.Breaks; + + public double TotalBreakTime => PlayableBeatmap.TotalBreakTime; + + public IReadOnlyList HitObjects => PlayableBeatmap.HitObjects; + + public IEnumerable GetStatistics() => PlayableBeatmap.GetStatistics(); + + public IBeatmap Clone() => PlayableBeatmap.Clone(); + + private readonly Bindable lastJudgementResult = new Bindable(); + + public IBindable LastJudgementResult => lastJudgementResult; + + public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result; + } +} diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs index d5f75f6ad1..4f2cf5005c 100644 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ b/osu.Game/Screens/Play/GameplayClock.cs @@ -31,6 +31,11 @@ namespace osu.Game.Screens.Play public bool IsRunning => underlyingClock.IsRunning; + /// + /// Whether an ongoing seek operation is active. + /// + public virtual bool IsSeeking => false; + public void ProcessFrame() { // we do not want to process the underlying clock. diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 9f46fddc5e..0653373c91 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -78,10 +78,10 @@ namespace osu.Game.Screens.Play // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new FramedOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 22 : 0 }; + platformOffsetClock = new HardwareCorrectionOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new FramedOffsetClock(platformOffsetClock); + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); // the clock to be exposed via DI to children. GameplayClock = new GameplayClock(userOffsetClock); @@ -123,6 +123,10 @@ namespace osu.Game.Screens.Play public void Restart() { + // The Reset() call below causes speed adjustments to be reset in an async context, leading to deadlocks. + // The deadlock can be prevented by resetting the track synchronously before entering the async context. + track.ResetSpeedAdjustments(); + Task.Run(() => { track.Reset(); @@ -244,5 +248,17 @@ namespace osu.Game.Screens.Play speedAdjustmentsApplied = false; } } + + private class HardwareCorrectionOffsetClock : FramedOffsetClock + { + // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. + // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. + public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + + public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) + : base(source, processSource) + { + } + } } } diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index adfbe977a4..6b37135c86 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics; using osu.Framework.Allocation; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Shapes; -using osuTK.Input; using System.Collections.Generic; using System.Linq; using osu.Framework.Input.Bindings; @@ -163,8 +162,6 @@ namespace osu.Game.Screens.Play // Don't let mouse down events through the overlay or people can click circles while paused. protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnMouseUp(MouseUpEvent e) => true; - protected override bool OnMouseMove(MouseMoveEvent e) => true; protected void AddButton(string text, Color4 colour, Action action) @@ -206,35 +203,24 @@ namespace osu.Game.Screens.Play InternalButtons[selectionIndex].Selected.Value = true; } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (!e.Repeat) - { - switch (e.Key) - { - case Key.Up: - if (selectionIndex == -1 || selectionIndex == 0) - setSelected(InternalButtons.Count - 1); - else - setSelected(selectionIndex - 1); - return true; - - case Key.Down: - if (selectionIndex == -1 || selectionIndex == InternalButtons.Count - 1) - setSelected(0); - else - setSelected(selectionIndex + 1); - return true; - } - } - - return base.OnKeyDown(e); - } - public bool OnPressed(GlobalAction action) { switch (action) { + case GlobalAction.SelectPrevious: + if (selectionIndex == -1 || selectionIndex == 0) + setSelected(InternalButtons.Count - 1); + else + setSelected(selectionIndex - 1); + return true; + + case GlobalAction.SelectNext: + if (selectionIndex == -1 || selectionIndex == InternalButtons.Count - 1) + setSelected(0); + else + setSelected(selectionIndex + 1); + return true; + case GlobalAction.Back: BackAction.Invoke(); return true; @@ -247,16 +233,8 @@ namespace osu.Game.Screens.Play return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - switch (action) - { - case GlobalAction.Back: - case GlobalAction.Select: - return true; - } - - return false; } private void buttonSelectionChanged(DialogButton button, bool isSelected) diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs new file mode 100644 index 0000000000..84dbb35f68 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/FailingLayer.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; +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.Configuration; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; +using osuTK.Graphics; + +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 + { + /// + /// Whether the current player health should be shown on screen. + /// + public readonly Bindable ShowHealth = new Bindable(); + + private const float max_alpha = 0.4f; + private const int fade_time = 400; + private const float gradient_size = 0.3f; + + /// + /// The threshold under which the current player life should be considered low and the layer should start fading in. + /// + private const double low_health_threshold = 0.20f; + + private readonly Container boxes; + + private Bindable fadePlayfieldWhenHealthLow; + private HealthProcessor healthProcessor; + + public FailingLayer() + { + RelativeSizeAxes = Axes.Both; + Children = new Drawable[] + { + boxes = new Container + { + Alpha = 0, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0)), + Height = gradient_size, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Height = gradient_size, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0), Color4.White), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour color, OsuConfigManager config) + { + boxes.Colour = color.Red; + + fadePlayfieldWhenHealthLow = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow); + fadePlayfieldWhenHealthLow.BindValueChanged(_ => updateState()); + ShowHealth.BindValueChanged(_ => updateState()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + public override void BindHealthProcessor(HealthProcessor processor) + { + base.BindHealthProcessor(processor); + + healthProcessor = processor; + updateState(); + } + + private void updateState() + { + // Don't display ever if the ruleset is not using a draining health display. + var showLayer = healthProcessor is DrainingHealthProcessor && fadePlayfieldWhenHealthLow.Value && ShowHealth.Value; + this.FadeTo(showLayer ? 1 : 0, fade_time, Easing.OutQuint); + } + + protected override void Update() + { + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); + + boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); + + base.Update(); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index acd8656fb2..edc9dedf24 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -3,22 +3,29 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; 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 : Container { - public readonly BindableDouble Current = new BindableDouble + public readonly BindableDouble Current = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; - protected HealthDisplay() + /// + /// Bind the tracked fields of to this health display. + /// + public virtual void BindHealthProcessor(HealthProcessor processor) { - Current.ValueChanged += health => SetHealth((float)health.NewValue); + Current.BindTo(processor.Health); } - - protected abstract void SetHealth(float value); } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs index 6196ce4026..4d28f00f39 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs @@ -77,6 +77,19 @@ namespace osu.Game.Screens.Play.HUD case ScoreMeterType.HitErrorRight: createBar(true); break; + + case ScoreMeterType.ColourBoth: + createColour(false); + createColour(true); + break; + + case ScoreMeterType.ColourLeft: + createColour(false); + break; + + case ScoreMeterType.ColourRight: + createColour(true); + break; } } @@ -90,6 +103,24 @@ namespace osu.Game.Screens.Play.HUD Alpha = 0, }; + completeDisplayLoading(display); + } + + private void createColour(bool rightAligned) + { + var display = new ColourHitErrorMeter(hitWindows) + { + Margin = new MarginPadding(margin), + Anchor = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, + Origin = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, + Alpha = 0, + }; + + completeDisplayLoading(display); + } + + private void completeDisplayLoading(HitErrorMeter display) + { Add(display); display.FadeInFromZero(fade_duration, Easing.OutQuint); } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 03a0f23fb6..f99c84fc01 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -163,30 +164,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters centre.Width = 2.5f; colourBars.Add(centre); - Color4 getColour(HitResult result) - { - switch (result) - { - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Ok: - return colours.Green; - - case HitResult.Good: - return colours.GreenLight; - - case HitResult.Great: - return colours.Blue; - - default: - return colours.BlueLight; - } - } - Drawable createColourBar(HitResult result, float height, bool first = false) { - var colour = getColour(result); + var colour = GetColourForHitResult(result); if (first) { @@ -201,7 +181,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters new Box { RelativeSizeAxes = Axes.Both, - Colour = getColour(result), + Colour = colour, Height = height * gradient_start }, new Box @@ -228,11 +208,27 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private double floatingAverage; private Container colourBars; + private const int max_concurrent_judgements = 50; + public override void OnNewJudgement(JudgementResult judgement) { if (!judgement.IsHit) return; + if (judgementsContainer.Count > max_concurrent_judgements) + { + const double quick_fade_time = 100; + + // check with a bit of lenience to avoid precision error in comparison. + var old = judgementsContainer.FirstOrDefault(j => j.LifetimeEnd > Clock.CurrentTime + quick_fade_time * 1.1); + + if (old != null) + { + old.ClearTransforms(); + old.FadeOut(quick_fade_time).Expire(); + } + } + judgementsContainer.Add(new JudgementLine { Y = getRelativeJudgementPosition(judgement.TimeOffset), @@ -245,11 +241,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters , arrow_move_duration, Easing.Out); } - private float getRelativeJudgementPosition(double value) => (float)((value / maxHitWindow) + 1) / 2; + private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); private class JudgementLine : CompositeDrawable { - private const int judgement_fade_duration = 10000; + private const int judgement_fade_duration = 5000; public JudgementLine() { @@ -276,7 +272,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Width = 0; this.ResizeWidthTo(1, 200, Easing.OutElasticHalf); - this.FadeTo(0.8f, 150).Then().FadeOut(judgement_fade_duration, Easing.OutQuint).Expire(); + this.FadeTo(0.8f, 150).Then().FadeOut(judgement_fade_duration).Expire(); } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs new file mode 100644 index 0000000000..657235bfd4 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD.HitErrorMeters +{ + public class ColourHitErrorMeter : HitErrorMeter + { + private const int animation_duration = 200; + + private readonly JudgementFlow judgementsFlow; + + public ColourHitErrorMeter(HitWindows hitWindows) + : base(hitWindows) + { + AutoSizeAxes = Axes.Both; + InternalChild = judgementsFlow = new JudgementFlow(); + } + + public override void OnNewJudgement(JudgementResult judgement) => judgementsFlow.Push(GetColourForHitResult(HitWindows.ResultFor(judgement.TimeOffset))); + + private 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 JudgementFlow() + { + AutoSizeAxes = Axes.X; + Height = max_available_judgements * (drawable_judgement_size + spacing) - spacing; + Spacing = new Vector2(0, spacing); + 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(); + } + } + + private 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); + } + + public void Remove() + { + IsRemoved = true; + + this.FadeOut(animation_duration, Easing.OutQuint).Expire(); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index dee25048ed..b3edfdedec 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { @@ -11,11 +14,38 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { protected readonly HitWindows HitWindows; + [Resolved] + private OsuColour colours { get; set; } + protected HitErrorMeter(HitWindows hitWindows) { HitWindows = hitWindows; } public abstract void OnNewJudgement(JudgementResult judgement); + + protected Color4 GetColourForHitResult(HitResult result) + { + switch (result) + { + 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.Great: + return colours.Blue; + + default: + return colours.BlueLight; + } + } } } diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 640224c057..387c0e587b 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -12,7 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -251,6 +251,7 @@ namespace osu.Game.Screens.Play.HUD switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: // in the future this behaviour will differ for replays etc. if (!pendingAnimation) BeginConfirm(); return true; @@ -259,16 +260,15 @@ namespace osu.Game.Screens.Play.HUD return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: AbortConfirm(); - return true; + break; } - - return false; } protected override bool OnMouseDown(MouseDownEvent e) @@ -278,11 +278,10 @@ namespace osu.Game.Screens.Play.HUD return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (!e.HasAnyButtonPressed) AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 0f3c92a962..99c31241f1 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Play.HUD public bool DisplayUnrankedText = true; - public bool AllowExpand = true; + public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; private readonly Bindable> current = new Bindable>(); @@ -56,7 +56,6 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = 10, Right = 10 }, }, unrankedText = new OsuSpriteText { @@ -110,11 +109,15 @@ namespace osu.Game.Screens.Play.HUD private void expand() { - if (AllowExpand) + if (ExpansionMode != ExpansionMode.AlwaysContracted) iconsContainer.TransformSpacingTo(new Vector2(5, 0), 500, Easing.OutQuint); } - private void contract() => iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + private void contract() + { + if (ExpansionMode != ExpansionMode.AlwaysExpanded) + iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + } protected override bool OnHover(HoverEvent e) { @@ -128,4 +131,22 @@ namespace osu.Game.Screens.Play.HUD base.OnHoverLost(e); } } + + public enum ExpansionMode + { + /// + /// The will expand only when hovered. + /// + ExpandOnHover, + + /// + /// The will always be expanded. + /// + AlwaysExpanded, + + /// + /// The will always be contracted. + /// + AlwaysContracted + } } diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index d201b5d30e..fc80983834 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -50,7 +50,7 @@ namespace osu.Game.Screens.Play.HUD protected override void PopIn() => this.FadeIn(fade_duration); protected override void PopOut() => this.FadeOut(fade_duration); - //We want to handle keyboard inputs all the time in order to trigger ToggleVisibility() when not visible + // We want to handle keyboard inputs all the time in order to trigger ToggleVisibility() when not visible public override bool PropagateNonPositionalInputSubTree => true; protected override bool OnKeyDown(KeyDownEvent e) diff --git a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs b/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs index 315bc27a79..7736541c92 100644 --- a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Judgements; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD @@ -108,11 +109,23 @@ namespace osu.Game.Screens.Play.HUD if (result.Type == HitResult.Miss) return; + Scheduler.AddOnce(flash); + } + + private void flash() + { fill.FadeEdgeEffectTo(Math.Min(1, fill.EdgeEffect.Colour.Linear.A + (1f - base_glow_opacity) / glow_max_hits), 50, Easing.OutQuint) .Delay(glow_fade_delay) .FadeEdgeEffectTo(base_glow_opacity, glow_fade_time, Easing.OutQuint); } - protected override void SetHealth(float value) => fill.ResizeTo(new Vector2(value, 1), 200, Easing.OutQuint); + protected override void Update() + { + base.Update(); + + fill.Width = Interpolation.ValueAt( + Math.Clamp(Clock.ElapsedFrameTime, 0, 200), + fill.Width, (float)Current.Value, 0, 200, Easing.OutQuint); + } } } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 236bdc8442..f09745cf71 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -37,6 +37,7 @@ namespace osu.Game.Screens.Play public readonly HitErrorDisplay HitErrorDisplay; public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; + public readonly FailingLayer FailingLayer; public Bindable ShowHealthbar = new Bindable(true); @@ -75,6 +76,7 @@ namespace osu.Game.Screens.Play Children = new Drawable[] { + FailingLayer = CreateFailingLayer(), visibilityContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -131,7 +133,6 @@ namespace osu.Game.Screens.Play BindDrawableRuleset(drawableRuleset); Progress.Objects = drawableRuleset.Objects; - Progress.AllowSeeking = drawableRuleset.HasReplayLoaded.Value; Progress.RequestSeek = time => RequestSeek(time); Progress.ReferenceClock = drawableRuleset.FrameStableClock; } @@ -261,6 +262,11 @@ namespace osu.Game.Screens.Play Margin = new MarginPadding { Top = 20 } }; + protected virtual FailingLayer CreateFailingLayer() => new FailingLayer + { + ShowHealth = { BindTarget = ShowHealthbar } + }; + protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay { Anchor = Anchor.BottomRight, @@ -286,7 +292,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 20, Right = 10 }, + Margin = new MarginPadding { Top = 20, Right = 20 }, }; protected virtual HitErrorDisplay CreateHitErrorDisplayOverlay() => new HitErrorDisplay(scoreProcessor, drawableRuleset?.FirstAvailableHitWindows); @@ -305,7 +311,8 @@ namespace osu.Game.Screens.Play protected virtual void BindHealthProcessor(HealthProcessor processor) { - HealthDisplay?.Current.BindTo(processor.Health); + HealthDisplay?.BindHealthProcessor(processor); + FailingLayer?.BindHealthProcessor(processor); } } } diff --git a/osu.Game/Screens/Play/HotkeyExitOverlay.cs b/osu.Game/Screens/Play/HotkeyExitOverlay.cs index c18aecda55..8d7e2481bf 100644 --- a/osu.Game/Screens/Play/HotkeyExitOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyExitOverlay.cs @@ -17,12 +17,11 @@ namespace osu.Game.Screens.Play return true; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - if (action != GlobalAction.QuickExit) return false; + if (action != GlobalAction.QuickExit) return; AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs index f1b851f2d5..58fd941f36 100644 --- a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs @@ -17,12 +17,11 @@ namespace osu.Game.Screens.Play return true; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - if (action != GlobalAction.QuickRetry) return false; + if (action != GlobalAction.QuickRetry) return; AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/KeyCounter.cs b/osu.Game/Screens/Play/KeyCounter.cs index f4109a63d0..98df73a5e6 100644 --- a/osu.Game/Screens/Play/KeyCounter.cs +++ b/osu.Game/Screens/Play/KeyCounter.cs @@ -124,8 +124,8 @@ namespace osu.Game.Screens.Play } } }; - //Set this manually because an element with Alpha=0 won't take it size to AutoSizeContainer, - //so the size can be changing between buttonSprite and glowSprite. + // Set this manually because an element with Alpha=0 won't take it size to AutoSizeContainer, + // so the size can be changing between buttonSprite and glowSprite. Height = buttonSprite.DrawHeight; Width = buttonSprite.DrawWidth; } diff --git a/osu.Game/Screens/Play/KeyCounterAction.cs b/osu.Game/Screens/Play/KeyCounterAction.cs index 33d675358c..00eddcc776 100644 --- a/osu.Game/Screens/Play/KeyCounterAction.cs +++ b/osu.Game/Screens/Play/KeyCounterAction.cs @@ -27,15 +27,14 @@ namespace osu.Game.Screens.Play return false; } - public bool OnReleased(T action, bool forwards) + public void OnReleased(T action, bool forwards) { if (!EqualityComparer.Default.Equals(action, Action)) - return false; + return; IsLit = false; if (!forwards) Decrement(); - return false; } } } diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs index 9c107f0293..2ed4afafd3 100644 --- a/osu.Game/Screens/Play/KeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/KeyCounterDisplay.cs @@ -14,18 +14,32 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public class KeyCounterDisplay : FillFlowContainer + public class KeyCounterDisplay : Container { private const int duration = 100; private const double key_fade_time = 80; - public readonly Bindable Visible = new Bindable(true); 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() { - Direction = FillDirection.Horizontal; AutoSizeAxes = Axes.Both; + + InternalChild = KeyFlow = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + }; } public override void Add(KeyCounter key) @@ -49,7 +63,7 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - Visible.BindValueChanged(_ => updateVisibility()); + AlwaysVisible.BindValueChanged(_ => updateVisibility()); configVisibility.BindValueChanged(_ => updateVisibility(), true); } @@ -100,7 +114,9 @@ namespace osu.Game.Screens.Play } } - private void updateVisibility() => this.FadeTo(Visible.Value || configVisibility.Value ? 1 : 0, duration); + 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; diff --git a/osu.Game/Screens/Play/KeyCounterKeyboard.cs b/osu.Game/Screens/Play/KeyCounterKeyboard.cs index 29188b6b59..187dcc1264 100644 --- a/osu.Game/Screens/Play/KeyCounterKeyboard.cs +++ b/osu.Game/Screens/Play/KeyCounterKeyboard.cs @@ -27,10 +27,10 @@ namespace osu.Game.Screens.Play return base.OnKeyDown(e); } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { if (e.Key == Key) IsLit = false; - return base.OnKeyUp(e); + base.OnKeyUp(e); } } } diff --git a/osu.Game/Screens/Play/KeyCounterMouse.cs b/osu.Game/Screens/Play/KeyCounterMouse.cs index 828441de6e..e55525c5e8 100644 --- a/osu.Game/Screens/Play/KeyCounterMouse.cs +++ b/osu.Game/Screens/Play/KeyCounterMouse.cs @@ -45,10 +45,10 @@ namespace osu.Game.Screens.Play return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (e.Button == Button) IsLit = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 7228e22382..541275cf55 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,13 +18,16 @@ using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; +using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; @@ -33,6 +37,11 @@ namespace osu.Game.Screens.Play [Cached] public class Player : ScreenWithBeatmapBackground { + /// + /// The delay upon completion of the beatmap before displaying the results screen. + /// + public const double RESULTS_DISPLAY_DELAY = 1000.0; + public override bool AllowBackButton => false; // handled by HoldForMenuButton protected override UserActivity InitialActivity => new UserActivity.SoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); @@ -65,12 +74,17 @@ namespace osu.Game.Screens.Play private Ruleset ruleset; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } private SampleChannel sampleRestart; public BreakOverlay BreakOverlay; + private BreakTracker breakTracker; + + private SkipOverlay skipOverlay; + protected ScoreProcessor ScoreProcessor { get; private set; } protected HealthProcessor HealthProcessor { get; private set; } @@ -84,7 +98,6 @@ namespace osu.Game.Screens.Play protected GameplayClockContainer GameplayClockContainer { get; private set; } public DimmableStoryboard DimmableStoryboard { get; private set; } - public DimmableVideo DimmableVideo { get; private set; } [Cached] [Cached(Type = typeof(IBindable>))] @@ -94,7 +107,7 @@ namespace osu.Game.Screens.Play /// Whether failing should be allowed. /// By default, this checks whether all selected mods allow failing. /// - protected virtual bool AllowFail => Mods.Value.OfType().All(m => m.AllowFail); + protected virtual bool CheckModsAllowFailure() => Mods.Value.OfType().All(m => m.PerformFail()); private readonly bool allowPause; private readonly bool showResults; @@ -110,11 +123,35 @@ namespace osu.Game.Screens.Play this.showResults = showResults; } - [BackgroundDependencyLoader] - private void load(AudioManager audio, IAPIProvider api, OsuConfigManager config) - { - this.api = api; + private GameplayBeatmap gameplayBeatmap; + private ScreenSuspensionHandler screenSuspension; + + private DependencyContainer dependencies; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + protected override void LoadComplete() + { + base.LoadComplete(); + + PrepareReplay(); + } + + private Replay recordingReplay; + + /// + /// Run any recording / playback setup for replays. + /// + protected virtual void PrepareReplay() + { + DrawableRuleset.SetRecordTarget(recordingReplay = new Replay()); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuConfigManager config) + { Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); if (Beatmap.Value is DummyWorkingBeatmap) @@ -143,10 +180,23 @@ namespace osu.Game.Screens.Play InternalChild = GameplayClockContainer = new GameplayClockContainer(Beatmap.Value, Mods.Value, DrawableRuleset.GameplayStartTime); + AddInternal(gameplayBeatmap = new GameplayBeatmap(playableBeatmap)); + AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); + + dependencies.CacheAs(gameplayBeatmap); + addUnderlayComponents(GameplayClockContainer); - addGameplayComponents(GameplayClockContainer, Beatmap.Value); + addGameplayComponents(GameplayClockContainer, Beatmap.Value, playableBeatmap); addOverlayComponents(GameplayClockContainer, Beatmap.Value); + if (!DrawableRuleset.AllowGameplayOverlays) + { + HUDOverlay.ShowHud.Value = false; + HUDOverlay.ShowHud.Disabled = true; + BreakOverlay.Hide(); + skipOverlay.Hide(); + } + DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updatePauseOnFocusLostState(), true); // bind clock into components that require it @@ -156,6 +206,7 @@ namespace osu.Game.Screens.Play { HealthProcessor.ApplyResult(r); ScoreProcessor.ApplyResult(r); + gameplayBeatmap.ApplyResult(r); }; DrawableRuleset.OnRevertResult += r => @@ -165,7 +216,7 @@ namespace osu.Game.Screens.Play }; // Bind the judgement processors to ourselves - ScoreProcessor.AllJudged += onCompletion; + ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState; HealthProcessor.Failed += onFail; foreach (var mod in Mods.Value.OfType()) @@ -174,22 +225,21 @@ namespace osu.Game.Screens.Play foreach (var mod in Mods.Value.OfType()) mod.ApplyToHealthProcessor(HealthProcessor); - BreakOverlay.IsBreakTime.ValueChanged += _ => updatePauseOnFocusLostState(); + breakTracker.IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } private void addUnderlayComponents(Container target) { - target.Add(DimmableVideo = new DimmableVideo(Beatmap.Value.Video) { RelativeSizeAxes = Axes.Both }); target.Add(DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both }); } - private void addGameplayComponents(Container target, WorkingBeatmap working) + private void addGameplayComponents(Container target, WorkingBeatmap working, IBeatmap playableBeatmap) { var beatmapSkinProvider = new BeatmapSkinProvidingContainer(working.Skin); // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation // full access to all skin sources. - var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider)); + var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, playableBeatmap)); // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. @@ -202,12 +252,29 @@ namespace osu.Game.Screens.Play DrawableRuleset, new ComboEffects(ScoreProcessor) }); + + DrawableRuleset.FrameStableComponents.AddRange(new Drawable[] + { + ScoreProcessor, + HealthProcessor, + breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) + { + Breaks = working.Beatmap.Breaks + } + }); } private void addOverlayComponents(Container target, WorkingBeatmap working) { target.AddRange(new[] { + DimmableStoryboard.OverlayLayerContainer.CreateProxy(), + BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) + { + Clock = DrawableRuleset.FrameStableClock, + ProcessCustomClock = false, + Breaks = working.Beatmap.Breaks + }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), @@ -219,12 +286,16 @@ namespace osu.Game.Screens.Play IsPaused = { BindTarget = GameplayClockContainer.IsPaused } }, PlayerSettingsOverlay = { PlaybackSettings = { UserPlaybackRate = { BindTarget = GameplayClockContainer.UserPlaybackRate } } }, - KeyCounter = { Visible = { BindTarget = DrawableRuleset.HasReplayLoaded } }, + KeyCounter = + { + AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, + IsCounting = false + }, RequestSeek = GameplayClockContainer.Seek, Anchor = Anchor.Centre, Origin = Anchor.Centre }, - new SkipOverlay(DrawableRuleset.GameplayStartTime) + skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { RequestSkip = GameplayClockContainer.Skip }, @@ -260,26 +331,20 @@ namespace osu.Game.Screens.Play performImmediateExit(); }, }, - failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, } + failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, }, }); + } - DrawableRuleset.Overlays.Add(BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, DrawableRuleset.GameplayStartTime, ScoreProcessor) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Breaks = working.Beatmap.Breaks - }); - - DrawableRuleset.Overlays.Add(ScoreProcessor); - DrawableRuleset.Overlays.Add(HealthProcessor); - - HealthProcessor.IsBreakTime.BindTo(BreakOverlay.IsBreakTime); + private void onBreakTimeChanged(ValueChangedEvent isBreakTime) + { + updatePauseOnFocusLostState(); + HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue; } private void updatePauseOnFocusLostState() => HUDOverlay.HoldToQuit.PauseOnFocusLost = PauseOnFocusLost && !DrawableRuleset.HasReplayLoaded.Value - && !BreakOverlay.IsBreakTime.Value; + && !breakTracker.IsBreakTime.Value; private IBeatmap loadPlayableBeatmap() { @@ -365,30 +430,49 @@ namespace osu.Game.Screens.Play private ScheduledDelegate completionProgressDelegate; - private void onCompletion() + private void updateCompletionState(ValueChangedEvent completionState) { + // screen may be in the exiting transition phase. + if (!this.IsCurrentScreen()) + return; + + if (!completionState.NewValue) + { + completionProgressDelegate?.Cancel(); + completionProgressDelegate = null; + ValidForResume = true; + return; + } + + if (completionProgressDelegate != null) + throw new InvalidOperationException($"{nameof(updateCompletionState)} was fired more than once"); + // Only show the completion screen if the player hasn't failed - if (HealthProcessor.HasFailed || completionProgressDelegate != null) + if (HealthProcessor.HasFailed) return; ValidForResume = false; if (!showResults) return; - using (BeginDelayedSequence(1000)) - scheduleGotoRanking(); + using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) + completionProgressDelegate = Schedule(GotoRanking); } protected virtual ScoreInfo CreateScore() { - var score = DrawableRuleset.ReplayScore?.ScoreInfo ?? new ScoreInfo + var score = new ScoreInfo { Beatmap = Beatmap.Value.BeatmapInfo, Ruleset = rulesetInfo, Mods = Mods.Value.ToArray(), - User = api.LocalUser.Value, }; + if (DrawableRuleset.ReplayScore != null) + score.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); + else + score.User = api.LocalUser.Value; + ScoreProcessor.PopulateScore(score); return score; @@ -396,7 +480,7 @@ namespace osu.Game.Screens.Play protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; - protected virtual Results CreateResults(ScoreInfo score) => new SoloResults(score); + protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score); #region Fail Logic @@ -406,7 +490,7 @@ namespace osu.Game.Screens.Play private bool onFail() { - if (!AllowFail) + if (!CheckModsAllowFailure()) return false; HasFailed = true; @@ -493,7 +577,7 @@ namespace osu.Game.Screens.Play PauseOverlay.Hide(); // breaks and time-based conditions may allow instant resume. - if (BreakOverlay.IsBreakTime.Value) + if (breakTracker.IsBreakTime.Value) completeResume(); else DrawableRuleset.RequestResume(completeResume); @@ -527,9 +611,8 @@ namespace osu.Game.Screens.Play Background.BlurAmount.Value = 0; // bind component bindables. - Background.IsBreakTime.BindTo(BreakOverlay.IsBreakTime); - DimmableStoryboard.IsBreakTime.BindTo(BreakOverlay.IsBreakTime); - DimmableVideo.IsBreakTime.BindTo(BreakOverlay.IsBreakTime); + Background.IsBreakTime.BindTo(breakTracker.IsBreakTime); + DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); Background.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); @@ -548,16 +631,20 @@ namespace osu.Game.Screens.Play public override void OnSuspending(IScreen next) { + screenSuspension?.Expire(); + fadeOut(); base.OnSuspending(next); } public override bool OnExiting(IScreen next) { + screenSuspension?.Expire(); + if (completionProgressDelegate != null && !completionProgressDelegate.Cancelled && !completionProgressDelegate.Completed) { // proceed to result screen if beatmap already finished playing - scheduleGotoRanking(); + completionProgressDelegate.RunTask(); return true; } @@ -569,12 +656,6 @@ namespace osu.Game.Screens.Play return true; } - if (canPause) - { - Pause(); - return true; - } - // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. GameplayClockContainer?.StopUsingBeatmapClock(); @@ -583,6 +664,39 @@ namespace osu.Game.Screens.Play return base.OnExiting(next); } + protected virtual void GotoRanking() + { + if (DrawableRuleset.ReplayScore != null) + { + // if a replay is present, we likely don't want to import into the local database. + this.Push(CreateResults(CreateScore())); + return; + } + + LegacyByteArrayReader replayReader = null; + + var score = new Score { ScoreInfo = CreateScore() }; + + if (recordingReplay?.Frames.Count > 0) + { + score.Replay = recordingReplay; + + using (var stream = new MemoryStream()) + { + new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); + replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); + } + } + + scoreManager.Import(score.ScoreInfo, replayReader) + .ContinueWith(imported => Schedule(() => + { + // screen may be in the exiting transition phase. + if (this.IsCurrentScreen()) + this.Push(CreateResults(imported.Result)); + })); + } + private void fadeOut(bool instant = false) { float fadeOutDuration = instant ? 0 : 250; @@ -592,19 +706,6 @@ namespace osu.Game.Screens.Play storyboardReplacesBackground.Value = false; } - private void scheduleGotoRanking() - { - completionProgressDelegate?.Cancel(); - completionProgressDelegate = Schedule(delegate - { - var score = CreateScore(); - if (DrawableRuleset.ReplayScore == null) - scoreManager.Import(score).Wait(); - - this.Push(CreateResults(score)); - }); - } - #endregion } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 64fcc48004..93a734589c 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -12,21 +11,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; -using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Framework.Threading; -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.Input; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Users; using osuTK; @@ -38,30 +31,65 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; + public override bool HideOverlaysOnEnter => hideOverlays; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + // Here because IsHovered will not update unless we do so. + public override bool HandlePositionalInput => true; + + // We show the previous screen status + protected override UserActivity InitialActivity => null; + + protected override bool PlayResumeSound => false; + + protected BeatmapMetadataDisplay MetadataInfo; + + protected VisualSettings VisualSettings; + + protected Task LoadTask { get; private set; } + + protected Task DisposalTask { get; private set; } + + private bool backgroundBrightnessReduction; + + protected bool BackgroundBrightnessReduction + { + set + { + if (value == backgroundBrightnessReduction) + return; + + backgroundBrightnessReduction = value; + + Background.FadeColour(OsuColour.Gray(backgroundBrightnessReduction ? 0.8f : 1), 200); + } + } + + private bool readyForPush => + // don't push unless the player is completely loaded + player.LoadState == LoadState.Ready + // don't push if the user is hovering one of the panes, unless they are idle. + && (IsHovered || idleTracker.IsIdle.Value) + // don't push if the user is dragging a slider or otherwise. + && inputManager?.DraggedDrawable == null + // don't push if a focused overlay is visible, like settings. + && inputManager?.FocusedDrawable == null; + private readonly Func createPlayer; private Player player; private LogoTrackingContainer content; - private BeatmapMetadataDisplay info; - private bool hideOverlays; - public override bool HideOverlaysOnEnter => hideOverlays; - - protected override UserActivity InitialActivity => null; //shows the previous screen status - - public override bool DisallowExternalBeatmapRulesetChanges => true; - - protected override bool PlayResumeSound => false; - - protected Task LoadTask { get; private set; } - - protected Task DisposalTask { get; private set; } private InputManager inputManager; + private IdleTracker idleTracker; + private ScheduledDelegate scheduledPushPlayer; + [Resolved(CanBeNull = true)] private NotificationOverlay notificationOverlay { get; set; } @@ -71,19 +99,11 @@ namespace osu.Game.Screens.Play [Resolved] private AudioManager audioManager { get; set; } - private Bindable muteWarningShownOnce; - public PlayerLoader(Func createPlayer) { this.createPlayer = createPlayer; } - private void restartRequested() - { - hideOverlays = true; - ValidForResume = true; - } - [BackgroundDependencyLoader] private void load(SessionStatics sessionStatics) { @@ -96,7 +116,7 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, }).WithChildren(new Drawable[] { - info = new BeatmapMetadataDisplay(Beatmap.Value, Mods.Value, content.LogoFacade) + MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) { Alpha = 0, Anchor = Anchor.Centre, @@ -127,29 +147,23 @@ namespace osu.Game.Screens.Play inputManager = GetContainingInputManager(); } + #region Screen handling + public override void OnEntering(IScreen last) { base.OnEntering(last); - loadNewPlayer(); + prepareNewPlayer(); content.ScaleTo(0.7f); Background?.FadeColour(Color4.White, 800, Easing.OutQuint); contentIn(); - info.Delay(750).FadeIn(500); + MetadataInfo.Delay(750).FadeIn(500); this.Delay(1800).Schedule(pushWhenLoaded); - if (!muteWarningShownOnce.Value) - { - //Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted. - if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue) - { - notificationOverlay?.Post(new MutedNotification()); - muteWarningShownOnce.Value = true; - } - } + showMuteWarningIfNeeded(); } public override void OnResuming(IScreen last) @@ -158,38 +172,34 @@ namespace osu.Game.Screens.Play contentIn(); - info.Loading = true; + MetadataInfo.Loading = true; - //we will only be resumed if the player has requested a re-run (see ValidForResume setting above) - loadNewPlayer(); + // we will only be resumed if the player has requested a re-run (see restartRequested). + prepareNewPlayer(); this.Delay(400).Schedule(pushWhenLoaded); } - private void loadNewPlayer() + public override void OnSuspending(IScreen next) { - var restartCount = player?.RestartCount + 1 ?? 0; + base.OnSuspending(next); - player = createPlayer(); - player.RestartCount = restartCount; - player.RestartRequested = restartRequested; + cancelLoad(); - LoadTask = LoadComponentAsync(player, _ => info.Loading = false); + BackgroundBrightnessReduction = false; } - private void contentIn() + public override bool OnExiting(IScreen next) { - content.ScaleTo(1, 650, Easing.OutQuint); - content.FadeInFromZero(400); - } + cancelLoad(); - private void contentOut() - { - // Ensure the logo is no longer tracking before we scale the content - content.StopTracking(); + content.ScaleTo(0.7f, 150, Easing.InQuint); + this.FadeOut(150); - content.ScaleTo(0.7f, 300, Easing.InQuint); - content.FadeOut(250); + Background.EnableUserDim.Value = false; + BackgroundBrightnessReduction = false; + + return base.OnExiting(next); } protected override void LogoArriving(OsuLogo logo, bool resuming) @@ -198,10 +208,7 @@ namespace osu.Game.Screens.Play const double duration = 300; - if (!resuming) - { - logo.MoveTo(new Vector2(0.5f), duration, Easing.In); - } + if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.In); logo.ScaleTo(new Vector2(0.15f), duration, Easing.In); logo.FadeIn(350); @@ -219,109 +226,7 @@ namespace osu.Game.Screens.Play content.StopTracking(); } - private ScheduledDelegate pushDebounce; - protected VisualSettings VisualSettings; - - // Here because IsHovered will not update unless we do so. - public override bool HandlePositionalInput => true; - - private bool readyForPush => player.LoadState == LoadState.Ready && (IsHovered || idleTracker.IsIdle.Value) && inputManager?.DraggedDrawable == null; - - private void pushWhenLoaded() - { - if (!this.IsCurrentScreen()) return; - - try - { - if (!readyForPush) - { - // as the pushDebounce below has a delay, we need to keep checking and cancel a future debounce - // if we become unready for push during the delay. - cancelLoad(); - return; - } - - if (pushDebounce != null) - return; - - pushDebounce = Scheduler.AddDelayed(() => - { - contentOut(); - - this.Delay(250).Schedule(() => - { - if (!this.IsCurrentScreen()) return; - - LoadTask = null; - - //By default, we want to load the player and never be returned to. - //Note that this may change if the player we load requested a re-run. - ValidForResume = false; - - if (player.LoadedBeatmapSuccessfully) - this.Push(player); - else - this.Exit(); - }); - }, 500); - } - finally - { - Schedule(pushWhenLoaded); - } - } - - private void cancelLoad() - { - pushDebounce?.Cancel(); - pushDebounce = null; - } - - public override void OnSuspending(IScreen next) - { - BackgroundBrightnessReduction = false; - base.OnSuspending(next); - cancelLoad(); - } - - public override bool OnExiting(IScreen next) - { - content.ScaleTo(0.7f, 150, Easing.InQuint); - this.FadeOut(150); - cancelLoad(); - - Background.EnableUserDim.Value = false; - BackgroundBrightnessReduction = false; - - return base.OnExiting(next); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (isDisposing) - { - // if the player never got pushed, we should explicitly dispose it. - DisposalTask = LoadTask?.ContinueWith(_ => player.Dispose()); - } - } - - private bool backgroundBrightnessReduction; - - protected bool BackgroundBrightnessReduction - { - get => backgroundBrightnessReduction; - set - { - if (value == backgroundBrightnessReduction) - return; - - backgroundBrightnessReduction = value; - - Background.FadeColour(OsuColour.Gray(backgroundBrightnessReduction ? 0.8f : 1), 200); - } - } + #endregion protected override void Update() { @@ -350,167 +255,129 @@ namespace osu.Game.Screens.Play } } - private class BeatmapMetadataDisplay : Container + private void prepareNewPlayer() { - private class MetadataLine : Container + var restartCount = player?.RestartCount + 1 ?? 0; + + player = createPlayer(); + player.RestartCount = restartCount; + player.RestartRequested = restartRequested; + + LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); + } + + private void restartRequested() + { + hideOverlays = true; + ValidForResume = true; + } + + private void contentIn() + { + content.ScaleTo(1, 650, Easing.OutQuint); + content.FadeInFromZero(400); + } + + private void contentOut() + { + // Ensure the logo is no longer tracking before we scale the content + content.StopTracking(); + + content.ScaleTo(0.7f, 300, Easing.InQuint); + content.FadeOut(250); + } + + private void pushWhenLoaded() + { + if (!this.IsCurrentScreen()) return; + + try { - public MetadataLine(string left, string right) + if (!readyForPush) { - AutoSizeAxes = Axes.Both; - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Right = 5 }, - Colour = OsuColour.Gray(0.8f), - Text = left, - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopLeft, - Margin = new MarginPadding { Left = 5 }, - Text = string.IsNullOrEmpty(right) ? @"-" : right, - } - }; + // as the pushDebounce below has a delay, we need to keep checking and cancel a future debounce + // if we become unready for push during the delay. + cancelLoad(); + return; } - } - private readonly WorkingBeatmap beatmap; - private readonly IReadOnlyList mods; - private readonly Drawable facade; - private LoadingAnimation loading; - private Sprite backgroundSprite; + if (scheduledPushPlayer != null) + return; - public bool Loading - { - set + scheduledPushPlayer = Scheduler.AddDelayed(() => { - if (value) + contentOut(); + + this.Delay(250).Schedule(() => { - loading.Show(); - backgroundSprite.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); - } - else - { - loading.Hide(); - backgroundSprite.FadeColour(Color4.White, 400, Easing.OutQuint); - } + if (!this.IsCurrentScreen()) return; + + LoadTask = null; + + // By default, we want to load the player and never be returned to. + // Note that this may change if the player we load requested a re-run. + ValidForResume = false; + + if (player.LoadedBeatmapSuccessfully) + this.Push(player); + else + this.Exit(); + }); + }, 500); + } + finally + { + Schedule(pushWhenLoaded); + } + } + + private void cancelLoad() + { + scheduledPushPlayer?.Cancel(); + scheduledPushPlayer = null; + } + + #region Disposal + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (isDisposing) + { + // if the player never got pushed, we should explicitly dispose it. + DisposalTask = LoadTask?.ContinueWith(_ => player.Dispose()); + } + } + + #endregion + + #region Mute warning + + private Bindable muteWarningShownOnce; + + private void showMuteWarningIfNeeded() + { + if (!muteWarningShownOnce.Value) + { + // Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted. + if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue) + { + notificationOverlay?.Post(new MutedNotification()); + muteWarningShownOnce.Value = true; } } - - public BeatmapMetadataDisplay(WorkingBeatmap beatmap, IReadOnlyList mods, Drawable facade) - { - this.beatmap = beatmap; - this.mods = mods; - this.facade = facade; - } - - [BackgroundDependencyLoader] - private void load() - { - var metadata = beatmap.BeatmapInfo?.Metadata ?? new BeatmapMetadata(); - - AutoSizeAxes = Axes.Both; - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Children = new[] - { - facade.With(d => - { - d.Anchor = Anchor.TopCentre; - d.Origin = Anchor.TopCentre; - }), - new OsuSpriteText - { - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), - Font = OsuFont.GetFont(size: 36, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Margin = new MarginPadding { Top = 15 }, - }, - new OsuSpriteText - { - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), - Font = OsuFont.GetFont(size: 26, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new Container - { - Size = new Vector2(300, 60), - Margin = new MarginPadding(10), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - CornerRadius = 10, - Masking = true, - Children = new Drawable[] - { - backgroundSprite = new Sprite - { - RelativeSizeAxes = Axes.Both, - Texture = beatmap?.Background, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - FillMode = FillMode.Fill, - }, - loading = new LoadingAnimation { Scale = new Vector2(1.3f) } - } - }, - new OsuSpriteText - { - Text = beatmap?.BeatmapInfo?.Version, - Font = OsuFont.GetFont(size: 26, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Margin = new MarginPadding - { - Bottom = 40 - }, - }, - new MetadataLine("Source", metadata.Source) - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new MetadataLine("Mapper", metadata.AuthorString) - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new ModDisplay - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 20 }, - Current = { Value = mods } - } - }, - } - }; - - Loading = true; - } } private class MutedNotification : SimpleNotification { + public override bool IsImportant => true; + public MutedNotification() { Text = "Your music volume is set to 0%! Click here to restore it."; } - public override bool IsImportant => true; - [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audioManager, NotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay) { @@ -529,5 +396,7 @@ namespace osu.Game.Screens.Play }; } } + + #endregion } } diff --git a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs index ff64f35a18..d6c66d0751 100644 --- a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs @@ -15,7 +15,6 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly PlayerSliderBar dimSliderBar; private readonly PlayerSliderBar blurSliderBar; private readonly PlayerCheckbox showStoryboardToggle; - private readonly PlayerCheckbox showVideoToggle; private readonly PlayerCheckbox beatmapSkinsToggle; private readonly PlayerCheckbox beatmapHitsoundsToggle; @@ -27,18 +26,23 @@ namespace osu.Game.Screens.Play.PlayerSettings { Text = "Background dim:" }, - dimSliderBar = new PlayerSliderBar(), + dimSliderBar = new PlayerSliderBar + { + DisplayAsPercentage = true + }, new OsuSpriteText { Text = "Background blur:" }, - blurSliderBar = new PlayerSliderBar(), + blurSliderBar = new PlayerSliderBar + { + DisplayAsPercentage = true + }, new OsuSpriteText { Text = "Toggles:" }, - showStoryboardToggle = new PlayerCheckbox { LabelText = "Storyboards" }, - showVideoToggle = new PlayerCheckbox { LabelText = "Video" }, + showStoryboardToggle = new PlayerCheckbox { LabelText = "Storyboard / Video" }, beatmapSkinsToggle = new PlayerCheckbox { LabelText = "Beatmap skins" }, beatmapHitsoundsToggle = new PlayerCheckbox { LabelText = "Beatmap hitsounds" } }; @@ -50,7 +54,6 @@ namespace osu.Game.Screens.Play.PlayerSettings dimSliderBar.Bindable = config.GetBindable(OsuSetting.DimLevel); blurSliderBar.Bindable = config.GetBindable(OsuSetting.BlurLevel); showStoryboardToggle.Current = config.GetBindable(OsuSetting.ShowStoryboard); - showVideoToggle.Current = config.GetBindable(OsuSetting.ShowVideoBackground); beatmapSkinsToggle.Current = config.GetBindable(OsuSetting.BeatmapSkins); beatmapHitsoundsToggle.Current = config.GetBindable(OsuSetting.BeatmapHitsounds); } diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index b040549efc..7f5c17a265 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Scoring; +using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { @@ -10,7 +11,7 @@ namespace osu.Game.Screens.Play private readonly Score score; // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) - protected override bool AllowFail => false; + protected override bool CheckModsAllowFailure() => false; public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true) : base(allowPause, showResults) @@ -18,12 +19,21 @@ namespace osu.Game.Screens.Play this.score = score; } - protected override void LoadComplete() + protected override void PrepareReplay() { - base.LoadComplete(); DrawableRuleset?.SetReplayScore(score); } - protected override ScoreInfo CreateScore() => score.ScoreInfo; + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); + + protected override ScoreInfo CreateScore() + { + var baseScore = base.CreateScore(); + + // Since the replay score doesn't contain statistics, we'll pass them through here. + score.ScoreInfo.HitEvents = baseScore.HitEvents; + + return score.ScoreInfo; + } } } diff --git a/osu.Game/Screens/Play/ReplayPlayerLoader.cs b/osu.Game/Screens/Play/ReplayPlayerLoader.cs index 4572570437..9eff4cb8fc 100644 --- a/osu.Game/Screens/Play/ReplayPlayerLoader.cs +++ b/osu.Game/Screens/Play/ReplayPlayerLoader.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.Play { public class ReplayPlayerLoader : PlayerLoader { - private readonly ScoreInfo scoreInfo; + public readonly ScoreInfo Score; public ReplayPlayerLoader(Score score) : base(() => new ReplayPlayer(score)) @@ -17,14 +17,14 @@ namespace osu.Game.Screens.Play if (score.Replay == null) throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); - scoreInfo = score.ScoreInfo; + Score = score.ScoreInfo; } public override void OnEntering(IScreen last) { // these will be reverted thanks to PlayerLoader's lease. - Mods.Value = scoreInfo.Mods; - Ruleset.Value = scoreInfo.Ruleset; + Mods.Value = Score.Mods; + Ruleset.Value = Score.Ruleset; base.OnEntering(last); } diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs new file mode 100644 index 0000000000..8585a5c309 --- /dev/null +++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.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.Diagnostics; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Platform; + +namespace osu.Game.Screens.Play +{ + /// + /// Ensures screen is not suspended / dimmed while gameplay is active. + /// + public class ScreenSuspensionHandler : Component + { + private readonly GameplayClockContainer gameplayClockContainer; + private Bindable isPaused; + + [Resolved] + private GameHost host { get; set; } + + public ScreenSuspensionHandler([NotNull] GameplayClockContainer gameplayClockContainer) + { + this.gameplayClockContainer = gameplayClockContainer ?? throw new ArgumentNullException(nameof(gameplayClockContainer)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // This is the only usage game-wide of suspension changes. + // Assert to ensure we don't accidentally forget this in the future. + Debug.Assert(host.AllowScreenSuspension.Value); + + isPaused = gameplayClockContainer.IsPaused.GetBoundCopy(); + isPaused.BindValueChanged(paused => host.AllowScreenSuspension.Value = paused.NewValue, true); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + isPaused?.UnbindAll(); + + if (host != null) + host.AllowScreenSuspension.Value = true; + } + } +} diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 1a5ed20953..b123757ded 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -19,18 +19,19 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Input.Bindings; namespace osu.Game.Screens.Play { - public class SkipOverlay : VisibilityContainer, IKeyBindingHandler + public class SkipOverlay : CompositeDrawable, IKeyBindingHandler { private readonly double startTime; public Action RequestSkip; private Button button; + private ButtonContainer buttonContainer; private Box remainingTimeBox; private FadeContainer fadeContainer; @@ -61,9 +62,10 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader(true)] private void load(OsuColour colours) { - Children = new Drawable[] + InternalChild = buttonContainer = new ButtonContainer { - fadeContainer = new FadeContainer + RelativeSizeAxes = Axes.Both, + Child = fadeContainer = new FadeContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -104,30 +106,25 @@ namespace osu.Game.Screens.Play button.Action = () => RequestSkip?.Invoke(); displayTime = gameplayClock.CurrentTime; - - Show(); } - protected override void PopIn() => this.FadeIn(fade_time); - - protected override void PopOut() => this.FadeOut(fade_time); - protected override void Update() { base.Update(); - var progress = Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); + var progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); button.Enabled.Value = progress > 0; - State.Value = progress > 0 ? Visibility.Visible : Visibility.Hidden; + buttonContainer.State.Value = progress > 0 ? Visibility.Visible : Visibility.Hidden; } protected override bool OnMouseMove(MouseMoveEvent e) { if (!e.HasAnyButtonPressed) fadeContainer.Show(); + return base.OnMouseMove(e); } @@ -143,7 +140,9 @@ namespace osu.Game.Screens.Play return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private class FadeContainer : Container, IStateful { @@ -202,10 +201,9 @@ namespace osu.Game.Screens.Play return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Show(); - return true; } public override void Hide() => State = Visibility.Hidden; @@ -213,6 +211,13 @@ namespace osu.Game.Screens.Play public override void Show() => State = Visibility.Visible; } + private class ButtonContainer : VisibilityContainer + { + protected override void PopIn() => this.FadeIn(fade_time); + + protected override void PopOut() => this.FadeOut(fade_time); + } + private class Button : OsuClickableContainer { private Color4 colourNormal; @@ -311,10 +316,10 @@ namespace osu.Game.Screens.Play return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { aspect.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Play/SoloResults.cs b/osu.Game/Screens/Play/SoloResults.cs deleted file mode 100644 index 2b9aec257c..0000000000 --- a/osu.Game/Screens/Play/SoloResults.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Types; - -namespace osu.Game.Screens.Play -{ - public class SoloResults : Results - { - public SoloResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable CreateResultPages() => new IResultPageInfo[] - { - new ScoreOverviewPageInfo(Score, Beatmap.Value), - new LocalLeaderboardPageInfo(Score, Beatmap.Value) - }; - } -} diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index 713d27bd16..aa745f5ba2 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Timing; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -18,8 +19,9 @@ namespace osu.Game.Screens.Play { public class SongProgress : OverlayContainer { + private const int info_height = 20; private const int bottom_bar_height = 5; - + private const float graph_height = SquareGraph.Column.WIDTH * 6; private static readonly Vector2 handle_size = new Vector2(10, 18); private const float transition_duration = 200; @@ -30,12 +32,19 @@ namespace osu.Game.Screens.Play public Action RequestSeek; - public override bool HandleNonPositionalInput => AllowSeeking; - public override bool HandlePositionalInput => AllowSeeking; + /// + /// Whether seeking is allowed and the progress bar should be shown. + /// + public readonly Bindable AllowSeeking = new Bindable(); + + public readonly Bindable ShowGraph = new Bindable(); //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; + public override bool HandleNonPositionalInput => AllowSeeking.Value; + public override bool HandlePositionalInput => AllowSeeking.Value; + private double firstHitTime => objects.First().StartTime; private IEnumerable objects; @@ -54,27 +63,14 @@ namespace osu.Game.Screens.Play } } - private readonly BindableBool replayLoaded = new BindableBool(); - public IClock ReferenceClock; private IClock gameplayClock; - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock) - { - if (clock != null) - gameplayClock = clock; - - graph.FillColour = bar.FillColour = colours.BlueLighter; - } - public SongProgress() { - const float graph_height = SquareGraph.Column.WIDTH * 6; - - Height = bottom_bar_height + graph_height + handle_size.Y; - Y = bottom_bar_height; + Masking = true; + Height = bottom_bar_height + graph_height + handle_size.Y + info_height; Children = new Drawable[] { @@ -83,8 +79,7 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Bottom = bottom_bar_height + graph_height }, + Height = info_height, }, graph = new SongProgressGraph { @@ -96,7 +91,6 @@ namespace osu.Game.Screens.Play }, bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size) { - Alpha = 0, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, OnSeek = time => RequestSeek?.Invoke(time), @@ -104,46 +98,34 @@ namespace osu.Game.Screens.Play }; } - protected override void LoadComplete() + [BackgroundDependencyLoader(true)] + private void load(OsuColour colours, GameplayClock clock, OsuConfigManager config) { base.LoadComplete(); + if (clock != null) + gameplayClock = clock; + + config.BindWith(OsuSetting.ShowProgressGraph, ShowGraph); + + graph.FillColour = bar.FillColour = colours.BlueLighter; + } + + protected override void LoadComplete() + { Show(); - replayLoaded.ValueChanged += loaded => AllowSeeking = loaded.NewValue; - replayLoaded.TriggerChange(); + AllowSeeking.BindValueChanged(_ => updateBarVisibility(), true); + ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); } public void BindDrawableRuleset(DrawableRuleset drawableRuleset) { - replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); - } - - private bool allowSeeking; - - public bool AllowSeeking - { - get => allowSeeking; - set - { - if (allowSeeking == value) return; - - allowSeeking = value; - updateBarVisibility(); - } - } - - private void updateBarVisibility() - { - bar.FadeTo(allowSeeking ? 1 : 0, transition_duration, Easing.In); - this.MoveTo(new Vector2(0, allowSeeking ? 0 : bottom_bar_height), transition_duration, Easing.In); - - info.Margin = new MarginPadding { Bottom = Height - (allowSeeking ? 0 : handle_size.Y) }; + AllowSeeking.BindTo(drawableRuleset.HasReplayLoaded); } protected override void PopIn() { - updateBarVisibility(); this.FadeIn(500, Easing.OutQuint); } @@ -167,5 +149,28 @@ namespace osu.Game.Screens.Play bar.CurrentTime = gameplayTime; graph.Progress = (int)(graph.ColumnCount * progress); } + + 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.MoveToY(ShowGraph.Value ? 0 : bottom_bar_height + graph_height, 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/SongProgressBar.cs b/osu.Game/Screens/Play/SongProgressBar.cs index cdf495e257..939b5fad1f 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/SongProgressBar.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -19,6 +19,23 @@ namespace osu.Game.Screens.Play private readonly Box fill; private readonly Container handleBase; + private readonly Container handleContainer; + + private bool showHandle; + + public bool ShowHandle + { + get => showHandle; + set + { + if (value == showHandle) + return; + + showHandle = value; + + handleBase.FadeTo(showHandle ? 1 : 0, 200); + } + } public Color4 FillColour { @@ -40,6 +57,8 @@ namespace osu.Game.Screens.Play set => CurrentNumber.Value = value; } + protected override bool AllowKeyboardInputWhenNotHovered => true; + public SongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) { CurrentNumber.MinValue = 0; @@ -74,7 +93,7 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, Width = 2, - Height = barHeight + handleBarHeight, + Alpha = 0, Colour = Color4.White, Position = new Vector2(2, 0), Children = new Drawable[] @@ -84,7 +103,7 @@ namespace osu.Game.Screens.Play Name = "HandleBar box", RelativeSizeAxes = Axes.Both, }, - new Container + handleContainer = new Container { Name = "Handle container", Origin = Anchor.BottomCentre, @@ -116,6 +135,7 @@ namespace osu.Game.Screens.Play { base.Update(); + handleBase.Height = Height - handleContainer.Height; float newX = (float)Interpolation.Lerp(handleBase.X, NormalizedValue * UsableWidth, Math.Clamp(Time.Elapsed / 40, 0, 1)); fill.Width = newX; @@ -127,7 +147,11 @@ namespace osu.Game.Screens.Play protected override void OnUserChange(double value) { scheduledSeek?.Cancel(); - scheduledSeek = Schedule(() => OnSeek?.Invoke(value)); + scheduledSeek = Schedule(() => + { + if (showHandle) + OnSeek?.Invoke(value); + }); } } } diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index a667466965..36ce131411 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Framework; -using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,6 +13,7 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Allocation; +using osu.Framework.Layout; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -22,6 +22,11 @@ namespace osu.Game.Screens.Play { private BufferedContainer columns; + public SquareGraph() + { + AddLayout(layout); + } + public int ColumnCount => columns?.Children.Count ?? 0; private int progress; @@ -68,14 +73,7 @@ namespace osu.Game.Screens.Play } } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - layout.Invalidate(); - return base.Invalidate(invalidation, source, shallPropagate); - } - - private readonly Cached layout = new Cached(); + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize); private ScheduledDelegate scheduledCreate; protected override void Update() diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs new file mode 100644 index 0000000000..8cd0e7025e --- /dev/null +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking.Contracted +{ + /// + /// The content that appears in the middle of a contracted . + /// + public class ContractedPanelMiddleContent : CompositeDrawable + { + private readonly ScoreInfo score; + + /// + /// Creates a new . + /// + /// The to display. + public ContractedPanelMiddleContent(ScoreInfo score) + { + this.score = score; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 20, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 1, + Offset = new Vector2(0, 4) + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("444") + }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = score.User, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.5f), Color4Extensions.FromHex("#444").Opacity(0)) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new UpdateableAvatar(score.User) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(110), + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 20, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 8, + Offset = new Vector2(0, 4), + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = score.UserString, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + ChildrenEnumerable = score.SortedStatistics.Select(s => createStatistic(s.Key.GetDescription(), s.Value.ToString())) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new[] + { + createStatistic("Max Combo", $"x{score.MaxCombo}"), + createStatistic("Accuracy", $"{score.Accuracy.FormatAccuracy()}"), + } + }, + new ModDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + ExpansionMode = ExpansionMode.AlwaysExpanded, + DisplayUnrankedText = false, + Current = { Value = score.Mods }, + Scale = new Vector2(0.5f), + } + } + } + } + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = score.TotalScore.ToString("N0"), + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, fixedWidth: true), + Spacing = new Vector2(-1, 0) + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 2 }, + Child = new DrawableRank(score.Rank) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 45), + } + }; + } + + private Drawable createStatistic(string key, string value) => new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = key, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }, + new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Text = value, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Colour = Color4Extensions.FromHex("#FFDD55") + } + } + }; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs new file mode 100644 index 0000000000..45da23f1f9 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -0,0 +1,264 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// + /// The component that displays the player's accuracy on the results screen. + /// + public class AccuracyCircle : CompositeDrawable + { + /// + /// Duration for the transforms causing this component to appear. + /// + public const double APPEAR_DURATION = 200; + + /// + /// Delay before the accuracy circle starts filling. + /// + public const double ACCURACY_TRANSFORM_DELAY = 450; + + /// + /// Duration for the accuracy circle fill. + /// + public const double ACCURACY_TRANSFORM_DURATION = 3000; + + /// + /// Delay after for the rank text (A/B/C/D/S/SS) to appear. + /// + public const double TEXT_APPEAR_DELAY = ACCURACY_TRANSFORM_DURATION / 2; + + /// + /// Delay before the rank circles start filling. + /// + public const double RANK_CIRCLE_TRANSFORM_DELAY = 150; + + /// + /// Duration for the rank circle fills. + /// + public const double RANK_CIRCLE_TRANSFORM_DURATION = 800; + + /// + /// Relative width of the rank circles. + /// + public const float RANK_CIRCLE_RADIUS = 0.06f; + + /// + /// Relative width of the circle showing the accuracy. + /// + private const float accuracy_circle_radius = 0.2f; + + /// + /// SS is displayed as a 1% region, otherwise it would be invisible. + /// + private const double virtual_ss_percentage = 0.01; + + /// + /// The easing for the circle filling transforms. + /// + public static readonly Easing ACCURACY_TRANSFORM_EASING = Easing.OutPow10; + + private readonly ScoreInfo score; + + private SmoothCircularProgress accuracyCircle; + private SmoothCircularProgress innerMask; + private Container badges; + private RankText rankText; + + public AccuracyCircle(ScoreInfo score) + { + this.score = score; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new SmoothCircularProgress + { + Name = "Background circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(47), + Alpha = 0.5f, + InnerRadius = accuracy_circle_radius + 0.01f, // Extends a little bit into the circle + Current = { Value = 1 }, + }, + accuracyCircle = new SmoothCircularProgress + { + Name = "Accuracy circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#7CF6FF"), Color4Extensions.FromHex("#BAFFA9")), + InnerRadius = accuracy_circle_radius, + }, + new BufferedContainer + { + Name = "Graded circles", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Padding = new MarginPadding(2), + Children = new Drawable[] + { + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.X), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 1 } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.S), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 1 - virtual_ss_percentage } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.A), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.95f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.B), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.9f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.C), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.8f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.D), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.7f } + }, + 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 BufferedContainer + { + Name = "Graded circle mask", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(1), + Blending = new BlendingParameters + { + Source = BlendingType.DstColor, + Destination = BlendingType.OneMinusSrcAlpha, + SourceAlpha = BlendingType.One, + DestinationAlpha = BlendingType.SrcAlpha + }, + Child = innerMask = new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + InnerRadius = RANK_CIRCLE_RADIUS - 0.01f, + } + } + } + }, + badges = new Container + { + Name = "Rank badges", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = -15, Horizontal = -20 }, + Children = new[] + { + new RankBadge(1f, getRank(ScoreRank.X)), + new RankBadge(0.95f, getRank(ScoreRank.S)), + new RankBadge(0.9f, getRank(ScoreRank.A)), + new RankBadge(0.8f, getRank(ScoreRank.B)), + new RankBadge(0.7f, getRank(ScoreRank.C)), + new RankBadge(0.35f, getRank(ScoreRank.D)), + } + }, + rankText = new RankText(score.Rank) + }; + } + + private ScoreRank getRank(ScoreRank rank) + { + foreach (var mod in score.Mods.OfType()) + rank = mod.AdjustRank(rank, score.Accuracy); + + return rank; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.ScaleTo(0).Then().ScaleTo(1, APPEAR_DURATION, Easing.OutQuint); + + using (BeginDelayedSequence(RANK_CIRCLE_TRANSFORM_DELAY, true)) + innerMask.FillTo(1f, RANK_CIRCLE_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); + + using (BeginDelayedSequence(ACCURACY_TRANSFORM_DELAY, true)) + { + double targetAccuracy = score.Rank == ScoreRank.X || score.Rank == ScoreRank.XH ? 1 : Math.Min(1 - virtual_ss_percentage, score.Accuracy); + + accuracyCircle.FillTo(targetAccuracy, ACCURACY_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); + + foreach (var badge in badges) + { + if (badge.Accuracy > score.Accuracy) + continue; + + using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(1 - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION, true)) + badge.Appear(); + } + + using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true)) + rankText.Appear(); + } + } + + private double inverseEasing(Easing easing, double targetValue) + { + double test = 0; + double result = 0; + int count = 2; + + while (Math.Abs(result - targetValue) > 0.005) + { + int dir = Math.Sign(targetValue - result); + + test += dir * 1.0 / count; + result = Interpolation.ApplyEasing(easing, test); + + count++; + } + + return test; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs new file mode 100644 index 0000000000..76cd408daa --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs @@ -0,0 +1,99 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// + /// Contains a that is positioned around the . + /// + public class RankBadge : CompositeDrawable + { + /// + /// The accuracy value corresponding to the displayed by this badge. + /// + public readonly float Accuracy; + + private readonly ScoreRank rank; + + private Drawable rankContainer; + private Drawable overlay; + + /// + /// Creates a new . + /// + /// The accuracy value corresponding to . + /// The to be displayed in this . + public RankBadge(float accuracy, ScoreRank rank) + { + Accuracy = accuracy; + this.rank = rank; + + RelativeSizeAxes = Axes.Both; + Alpha = 0; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = rankContainer = new Container + { + Origin = Anchor.Centre, + Size = new Vector2(28, 14), + Children = new[] + { + new DrawableRank(rank), + overlay = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = OsuColour.ForRank(rank).Opacity(0.2f), + Radius = 10, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + } + }; + } + + /// + /// Shows this . + /// + public void Appear() + { + this.FadeIn(50); + overlay.FadeIn().FadeOut(500, Easing.In); + } + + protected override void Update() + { + base.Update(); + + // Starts at -90deg (top) and moves counter-clockwise by the accuracy + rankContainer.Position = circlePosition(-MathF.PI / 2 - (1 - Accuracy) * MathF.PI * 2); + } + + private Vector2 circlePosition(float t) + => DrawSize / 2 + new Vector2(MathF.Cos(t), MathF.Sin(t)) * DrawSize / 2; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs new file mode 100644 index 0000000000..894790b5b6 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +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 + { + private readonly float position; + + public RankNotch(float position) + { + this.position = position; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Rotation = position * 360f, + Child = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + Height = AccuracyCircle.RANK_CIRCLE_RADIUS, + Width = 1f, + 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 new file mode 100644 index 0000000000..cc732382f4 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +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 + { + private readonly ScoreRank rank; + + private BufferedContainer flash; + private BufferedContainer superFlash; + private GlowingSpriteText rankText; + + public RankText(ScoreRank rank) + { + this.rank = rank; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Alpha = 0; + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + rankText = new GlowingSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + GlowColour = OsuColour.ForRank(rank), + Spacing = new Vector2(-15, 0), + Text = DrawableRank.GetRankName(rank), + Font = OsuFont.Numeric.With(size: 76), + UseFullGlyphHeight = false + }, + superFlash = new BufferedContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BlurSigma = new Vector2(85), + Size = new Vector2(600), + CacheDrawnFrameBuffer = true, + Blending = BlendingParameters.Additive, + Alpha = 0, + Children = new[] + { + new Box + { + Colour = Color4.White, + Size = new Vector2(150), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }, + }, + flash = new BufferedContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BlurSigma = new Vector2(35), + BypassAutoSizeAxes = Axes.Both, + Size = new Vector2(200), + CacheDrawnFrameBuffer = true, + Blending = BlendingParameters.Additive, + Alpha = 0, + Scale = new Vector2(1.8f), + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-15, 0), + Text = DrawableRank.GetRankName(rank), + Font = OsuFont.Numeric.With(size: 76), + UseFullGlyphHeight = false, + Shadow = false + }, + }, + }, + }; + } + + public void Appear() + { + this.FadeIn(); + + if (rank < ScoreRank.A) + { + this + .MoveToOffset(new Vector2(0, -20)) + .MoveToOffset(new Vector2(0, 20), 200, Easing.OutBounce); + + if (rank <= ScoreRank.D) + { + this.Delay(700) + .RotateTo(5, 150, Easing.In) + .MoveToOffset(new Vector2(0, 3), 150, Easing.In); + } + + this.FadeInFromZero(200, Easing.OutQuint); + return; + } + + flash.Colour = OsuColour.ForRank(rank); + + if (rank >= ScoreRank.S) + rankText.ScaleTo(1.05f).ScaleTo(1, 3000, Easing.OutQuint); + + if (rank >= ScoreRank.X) + { + flash.FadeOutFromOne(3000); + superFlash.FadeOutFromOne(800, Easing.OutQuint); + } + else + { + flash.FadeOutFromOne(1200, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs new file mode 100644 index 0000000000..106af31cae --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.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 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 new file mode 100644 index 0000000000..01502c0913 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -0,0 +1,256 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Screens.Ranking.Expanded.Statistics; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// + /// The content that appears in the middle section of the . + /// + public class ExpandedPanelMiddleContent : CompositeDrawable + { + private readonly ScoreInfo score; + + private readonly List statisticDisplays = new List(); + + private FillFlowContainer starAndModDisplay; + + private RollingCounter scoreCounter; + + private const float padding = 10; + + /// + /// Creates a new . + /// + /// The score to display. + public ExpandedPanelMiddleContent(ScoreInfo score) + { + this.score = score; + + RelativeSizeAxes = Axes.Both; + Masking = true; + + Padding = new MarginPadding(padding); + } + + [BackgroundDependencyLoader] + private void load() + { + var beatmap = score.Beatmap; + var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; + var creator = metadata.Author?.Username; + + var topStatistics = new List + { + new AccuracyStatistic(score.Accuracy), + new ComboStatistic(score.MaxCombo, !score.Statistics.TryGetValue(HitResult.Miss, out var missCount) || missCount == 0), + new CounterStatistic("pp", (int)(score.PP ?? 0)), + }; + + var bottomStatistics = new List(); + foreach (var stat in score.SortedStatistics) + bottomStatistics.Add(new HitResultStatistic(stat.Key, stat.Value)); + + statisticDisplays.AddRange(topStatistics); + statisticDisplays.AddRange(bottomStatistics); + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 40 }, + RelativeSizeAxes = Axes.X, + Height = 230, + Child = new AccuracyCircle(score) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + } + }, + scoreCounter = new TotalScoreCounter + { + Margin = new MarginPadding { Top = 0, Bottom = 5 }, + Current = { Value = 0 }, + Alpha = 0, + AlwaysPresent = true + }, + starAndModDisplay = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new StarRatingDisplay(beatmap) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.Version, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(t => + { + if (!string.IsNullOrEmpty(creator)) + { + t.AddText("mapped by "); + t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }) + } + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { topStatistics.Cast().ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Cast().ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" + } + } + }; + + if (score.Mods.Any()) + { + starAndModDisplay.Add(new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + DisplayUnrankedText = false, + ExpansionMode = ExpansionMode.AlwaysExpanded, + Scale = new Vector2(0.5f), + Current = { Value = score.Mods } + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Score counter value setting must be scheduled so it isn't transferred instantaneously + ScheduleAfterChildren(() => + { + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DELAY, true)) + { + scoreCounter.FadeIn(); + scoreCounter.Current.Value = score.TotalScore; + + double delay = 0; + + foreach (var stat in statisticDisplays) + { + using (BeginDelayedSequence(delay, true)) + stat.Appear(); + + delay += 200; + } + } + }); + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs new file mode 100644 index 0000000000..5dfc43cc29 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// + /// The content that appears in the middle section of the . + /// + public class ExpandedPanelTopContent : CompositeDrawable + { + private readonly User user; + + /// + /// Creates a new . + /// + /// The to display. + public ExpandedPanelTopContent(User user) + { + this.user = user; + Anchor = Anchor.TopCentre; + Origin = Anchor.Centre; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new UpdateableAvatar(user) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(80), + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = user.Username, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold) + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs new file mode 100644 index 0000000000..4b38b298f1 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.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.Globalization; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// + /// A pill that displays the star rating of a . + /// + public class StarRatingDisplay : CompositeDrawable + { + private readonly BeatmapInfo beatmap; + + /// + /// Creates a new . + /// + /// The to display the star difficulty of. + public StarRatingDisplay(BeatmapInfo beatmap) + { + this.beatmap = beatmap; + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + var starRatingParts = beatmap.StarDifficulty.ToString("0.00", CultureInfo.InvariantCulture).Split('.'); + string wholePart = starRatingParts[0]; + string fractionPart = starRatingParts[1]; + string separator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; + + ColourInfo backgroundColour = beatmap.DifficultyRating == DifficultyRating.ExpertPlus + ? ColourInfo.GradientVertical(Color4Extensions.FromHex("#C1C1C1"), Color4Extensions.FromHex("#595959")) + : (ColourInfo)colours.ForDifficultyRating(beatmap.DifficultyRating); + + InternalChildren = new Drawable[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 8, Vertical = 4 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(7), + Icon = FontAwesome.Solid.Star, + Colour = Color4.Black + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Numeric.With(weight: FontWeight.Black)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + TextAnchor = Anchor.BottomLeft, + }.With(t => + { + t.AddText($"{wholePart}", s => + { + s.Colour = Color4.Black; + s.Font = s.Font.With(size: 14); + s.UseFullGlyphHeight = false; + }); + + t.AddText($"{separator}{fractionPart}", s => + { + s.Colour = Color4.Black; + s.Font = s.Font.With(size: 7); + s.UseFullGlyphHeight = false; + }); + }) + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs new file mode 100644 index 0000000000..2a0e33aab7 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.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.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// + /// A to display the player's accuracy. + /// + public class AccuracyStatistic : StatisticDisplay + { + private readonly double accuracy; + + private RollingCounter counter; + + /// + /// Creates a new . + /// + /// The accuracy to display. + public AccuracyStatistic(double accuracy) + : base("accuracy") + { + this.accuracy = accuracy; + } + + public override void Appear() + { + base.Appear(); + counter.Current.Value = accuracy; + } + + protected override Drawable CreateContent() => counter = new Counter(); + + private class Counter : RollingCounter + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + public Counter() + { + DisplayedCountSpriteText.Font = OsuFont.Torus.With(size: 20, fixedWidth: true); + DisplayedCountSpriteText.Spacing = new Vector2(-2, 0); + } + + protected override string FormatCount(double count) => count.FormatAccuracy(); + + public override void Increment(double amount) + => Current.Value += amount; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs new file mode 100644 index 0000000000..e13138c5a0 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// + /// A to display the player's combo. + /// + public class ComboStatistic : CounterStatistic + { + private readonly bool isPerfect; + + private Drawable perfectText; + + /// + /// Creates a new . + /// + /// The combo to be displayed. + /// Whether this is a perfect combo. + public ComboStatistic(int combo, bool isPerfect) + : base("combo", combo) + { + this.isPerfect = isPerfect; + } + + public override void Appear() + { + base.Appear(); + + if (isPerfect) + { + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DURATION / 2, true)) + perfectText.FadeIn(50); + } + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new[] + { + base.CreateContent().With(d => + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + }), + perfectText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "PERFECT", + Font = OsuFont.Torus.With(size: 11, weight: FontWeight.SemiBold), + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#66FFCC"), Color4Extensions.FromHex("#FF9AD7")), + Alpha = 0, + UseFullGlyphHeight = false, + } + } + }; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs new file mode 100644 index 0000000000..817cc9b8c2 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.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 osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// + /// A to display general numeric values. + /// + public class CounterStatistic : StatisticDisplay + { + private readonly int count; + + private RollingCounter counter; + + /// + /// Creates a new . + /// + /// The name of the statistic. + /// The value to display. + public CounterStatistic(string header, int count) + : base(header) + { + this.count = count; + } + + public override void Appear() + { + base.Appear(); + counter.Current.Value = count; + } + + protected override Drawable CreateContent() => counter = new Counter(); + + private class Counter : RollingCounter + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + public Counter() + { + DisplayedCountSpriteText.Font = OsuFont.Torus.With(size: 20, fixedWidth: true); + DisplayedCountSpriteText.Spacing = new Vector2(-2, 0); + } + + public override void Increment(int amount) + => Current.Value += amount; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs new file mode 100644 index 0000000000..faa4a6a96c --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + public class HitResultStatistic : CounterStatistic + { + private readonly HitResult result; + + public HitResultStatistic(HitResult result, int count) + : base(result.GetDescription(), count) + { + this.result = result; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + HeaderText.Colour = colours.ForHitResult(result); + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs new file mode 100644 index 0000000000..9206c58bc9 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.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 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.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// + /// A statistic from the score to be displayed in the . + /// + public abstract class StatisticDisplay : CompositeDrawable + { + protected SpriteText HeaderText { get; private set; } + + private readonly string header; + private Drawable content; + + /// + /// Creates a new . + /// + /// The name of the statistic. + protected StatisticDisplay(string header) + { + this.header = header; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.X, + Height = 12, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#222") + }, + HeaderText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + Text = header.ToUpperInvariant(), + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Children = new[] + { + content = CreateContent().With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + d.Alpha = 0; + d.AlwaysPresent = true; + }), + } + } + } + }; + } + + /// + /// Shows the statistic value. + /// + public virtual void Appear() => content.FadeIn(100); + + /// + /// Creates the content for this . + /// + protected abstract Drawable CreateContent(); + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs new file mode 100644 index 0000000000..cab04edb8b --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.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.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// + /// A counter for the player's total score to be displayed in the . + /// + public class TotalScoreCounter : RollingCounter + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + public TotalScoreCounter() + { + // Todo: AutoSize X removed here due to https://github.com/ppy/osu-framework/issues/3369 + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + DisplayedCountSpriteText.Anchor = Anchor.TopCentre; + DisplayedCountSpriteText.Origin = Anchor.TopCentre; + + DisplayedCountSpriteText.Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true); + DisplayedCountSpriteText.Spacing = new Vector2(-5, 0); + } + + protected override string FormatCount(long count) => count.ToString("N0"); + + public override void Increment(long amount) + => Current.Value += amount; + } +} diff --git a/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs b/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs deleted file mode 100644 index c997dd6d30..0000000000 --- a/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; -using osuTK; - -namespace osu.Game.Screens.Ranking.Pages -{ - public class LocalLeaderboardPage : ResultsPage - { - public LocalLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap = null) - : base(score, beatmap) - { - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new Box - { - Colour = colours.Gray6, - RelativeSizeAxes = Axes.Both, - }, - new BeatmapLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Beatmap = Beatmap.BeatmapInfo ?? Score.Beatmap, - Scale = new Vector2(0.7f) - } - }; - } - } -} diff --git a/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs b/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs deleted file mode 100644 index 43234c0b29..0000000000 --- a/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs +++ /dev/null @@ -1,428 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Users; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Ranking.Pages -{ - public class ScoreResultsPage : ResultsPage - { - private Container scoreContainer; - private ScoreCounter scoreCounter; - - private readonly ScoreInfo score; - - public ScoreResultsPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - this.score = score; - } - - private FillFlowContainer statisticsContainer; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - const float user_header_height = 120; - - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = user_header_height }, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - } - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new DelayedLoadWrapper(new UserHeader(Score.User) - { - RelativeSizeAxes = Axes.Both, - }) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = user_header_height, - }, - new UpdateableRank(Score.Rank) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Size = new Vector2(150, 60), - Margin = new MarginPadding(20), - }, - scoreContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = 60, - Children = new Drawable[] - { - new SongProgressGraph - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f, - Objects = Beatmap.Beatmap.HitObjects, - }, - scoreCounter = new SlowScoreCounter(6) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.PinkDarker, - Y = 10, - TextSize = 56, - }, - } - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = colours.PinkDarker, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = "total score", - Margin = new MarginPadding { Bottom = 15 }, - }, - new BeatmapDetails(Beatmap.BeatmapInfo) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Bottom = 10 }, - }, - new DateTimeDisplay(Score.Date.LocalDateTime) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - new Container - { - RelativeSizeAxes = Axes.X, - Size = new Vector2(0.75f, 1), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 10, Bottom = 10 }, - Children = new Drawable[] - { - new Box - { - Colour = ColourInfo.GradientHorizontal( - colours.GrayC.Opacity(0), - colours.GrayC.Opacity(0.9f)), - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f, 1), - }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = ColourInfo.GradientHorizontal( - colours.GrayC.Opacity(0.9f), - colours.GrayC.Opacity(0)), - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f, 1), - }, - } - }, - statisticsContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Horizontal, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint - }, - }, - }, - new FillFlowContainer - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Margin = new MarginPadding { Bottom = 10 }, - Spacing = new Vector2(5), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new ReplayDownloadButton(score), - new RetryButton() - } - }, - }; - - statisticsContainer.ChildrenEnumerable = Score.Statistics.OrderByDescending(p => p.Key).Select(s => new DrawableScoreStatistic(s)); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Schedule(() => - { - scoreCounter.Increment(Score.TotalScore); - - int delay = 0; - - foreach (var s in statisticsContainer.Children) - { - s.FadeOut() - .Then(delay += 200) - .FadeIn(300 + delay, Easing.Out); - } - }); - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - scoreCounter.Scale = new Vector2(Math.Min(1f, (scoreContainer.DrawWidth - 20) / scoreCounter.DrawWidth)); - } - - private class DrawableScoreStatistic : Container - { - private readonly KeyValuePair statistic; - - public DrawableScoreStatistic(KeyValuePair statistic) - { - this.statistic = statistic; - - AutoSizeAxes = Axes.Both; - Margin = new MarginPadding { Left = 5, Right = 5 }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new OsuSpriteText - { - Text = statistic.Value.ToString().PadLeft(4, '0'), - Colour = colours.Gray7, - Font = OsuFont.GetFont(size: 30), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = statistic.Key.GetDescription(), - Colour = colours.Gray7, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Y = 26, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - }; - } - } - - private class DateTimeDisplay : Container - { - private readonly DateTime date; - - public DateTimeDisplay(DateTime date) - { - this.date = date; - - AutoSizeAxes = Axes.Both; - - Masking = true; - CornerRadius = 5; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray6, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, - Spacing = new Vector2(10), - Children = new[] - { - new OsuSpriteText - { - Text = date.ToShortDateString(), - Colour = Color4.White, - }, - new OsuSpriteText - { - Text = date.ToShortTimeString(), - Colour = Color4.White, - } - } - }, - }; - } - } - - private class BeatmapDetails : Container - { - private readonly BeatmapInfo beatmap; - - private readonly OsuSpriteText title; - private readonly OsuSpriteText artist; - private readonly OsuSpriteText versionMapper; - - public BeatmapDetails(BeatmapInfo beatmap) - { - this.beatmap = beatmap; - - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - title = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 24, italics: true), - }, - artist = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 20, italics: true), - }, - versionMapper = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - }, - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - title.Colour = artist.Colour = colours.BlueDarker; - versionMapper.Colour = colours.Gray8; - - var creator = beatmap.Metadata.Author?.Username; - - if (!string.IsNullOrEmpty(creator)) - { - versionMapper.Text = $"mapped by {creator}"; - - if (!string.IsNullOrEmpty(beatmap.Version)) - versionMapper.Text = $"{beatmap.Version} - " + versionMapper.Text; - } - - title.Text = new LocalisedString((beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title)); - artist.Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)); - } - } - - [LongRunningLoad] - private class UserHeader : Container - { - private readonly User user; - private readonly Sprite cover; - - public UserHeader(User user) - { - this.user = user; - Children = new Drawable[] - { - cover = new Sprite - { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Text = user.Username, - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Regular, italics: true), - Padding = new MarginPadding { Bottom = 10 }, - } - }; - } - - [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) - { - if (!string.IsNullOrEmpty(user.CoverUrl)) - cover.Texture = textures.Get(user.CoverUrl); - } - } - - private class SlowScoreCounter : ScoreCounter - { - protected override double RollingDuration => 3000; - - protected override Easing RollingEasing => Easing.OutPow10; - - public SlowScoreCounter(uint leading = 0) - : base(leading) - { - DisplayedCountSpriteText.Shadow = false; - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(Typeface.Venera, weight: FontWeight.Light); - UseCommaSeparator = true; - } - } - } -} diff --git a/osu.Game/Screens/Ranking/IResultPageInfo.cs b/osu.Game/Screens/Ranking/PanelState.cs similarity index 53% rename from osu.Game/Screens/Ranking/IResultPageInfo.cs rename to osu.Game/Screens/Ranking/PanelState.cs index cc86e7441a..94e2c7cef4 100644 --- a/osu.Game/Screens/Ranking/IResultPageInfo.cs +++ b/osu.Game/Screens/Ranking/PanelState.cs @@ -1,16 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics.Sprites; - namespace osu.Game.Screens.Ranking { - public interface IResultPageInfo + public enum PanelState { - IconUsage Icon { get; } - - string Name { get; } - - ResultsPage CreatePage(); + Expanded, + Contracted } } diff --git a/osu.Game/Screens/Ranking/Pages/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs similarity index 85% rename from osu.Game/Screens/Ranking/Pages/ReplayDownloadButton.cs rename to osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 9cc6ea2628..d0142e57fe 100644 --- a/osu.Game/Screens/Ranking/Pages/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -2,6 +2,7 @@ // 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.Containers; using osu.Game.Graphics.UserInterface; @@ -9,10 +10,12 @@ using osu.Game.Online; using osu.Game.Scoring; using osuTK; -namespace osu.Game.Screens.Ranking.Pages +namespace osu.Game.Screens.Ranking { public class ReplayDownloadButton : DownloadTrackingComposite { + public Bindable Score => Model; + private DownloadButton button; private ShakeContainer shakeContainer; @@ -23,7 +26,7 @@ namespace osu.Game.Screens.Ranking.Pages if (State.Value == DownloadState.LocallyAvailable) return ReplayAvailability.Local; - if (!string.IsNullOrEmpty(Model.Value.Hash)) + if (!string.IsNullOrEmpty(Model.Value?.Hash)) return ReplayAvailability.Online; return ReplayAvailability.NotAvailable; @@ -53,7 +56,7 @@ namespace osu.Game.Screens.Ranking.Pages switch (State.Value) { case DownloadState.LocallyAvailable: - game?.PresentScore(Model.Value); + game?.PresentScore(Model.Value, ScorePresentType.Gameplay); break; case DownloadState.NotDownloaded: @@ -74,15 +77,15 @@ namespace osu.Game.Screens.Ranking.Pages switch (replayAvailability) { case ReplayAvailability.Local: - button.TooltipText = @"Watch replay"; + button.TooltipText = @"watch replay"; break; case ReplayAvailability.Online: - button.TooltipText = @"Download replay"; + button.TooltipText = @"download replay"; break; default: - button.TooltipText = @"Replay unavailable"; + button.TooltipText = @"replay unavailable"; break; } }, true); diff --git a/osu.Game/Screens/Ranking/ResultModeButton.cs b/osu.Game/Screens/Ranking/ResultModeButton.cs deleted file mode 100644 index 38636b0c3b..0000000000 --- a/osu.Game/Screens/Ranking/ResultModeButton.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 osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Screens.Ranking -{ - public class ResultModeButton : TabItem, IHasTooltip - { - private readonly IconUsage icon; - private Color4 activeColour; - private Color4 inactiveColour; - private CircularContainer colouredPart; - - public ResultModeButton(IResultPageInfo mode) - : base(mode) - { - icon = mode.Icon; - TooltipText = mode.Name; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Size = new Vector2(50); - - Masking = true; - - CornerRadius = 25; - CornerExponent = 2; - - activeColour = colours.PinkDarker; - inactiveColour = OsuColour.Gray(0.8f); - - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 5, - }; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - colouredPart = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f), - BorderThickness = 4, - BorderColour = Color4.White, - Colour = inactiveColour, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, //for border rendering - RelativeSizeAxes = Axes.Both, - Colour = Color4.Transparent, - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shadow = false, - Colour = OsuColour.Gray(0.95f), - Icon = icon, - Size = new Vector2(20), - } - } - } - }; - } - - protected override void OnActivated() => colouredPart.FadeColour(activeColour, 200, Easing.OutQuint); - - protected override void OnDeactivated() => colouredPart.FadeColour(inactiveColour, 200, Easing.OutQuint); - - public string TooltipText { get; private set; } - } -} diff --git a/osu.Game/Screens/Ranking/ResultModeTabControl.cs b/osu.Game/Screens/Ranking/ResultModeTabControl.cs deleted file mode 100644 index b0d94a4be6..0000000000 --- a/osu.Game/Screens/Ranking/ResultModeTabControl.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 osu.Framework.Graphics; -using osu.Framework.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Screens.Ranking -{ - public class ResultModeTabControl : TabControl - { - public ResultModeTabControl() - { - TabContainer.Anchor = Anchor.BottomCentre; - TabContainer.Origin = Anchor.BottomCentre; - TabContainer.Spacing = new Vector2(15); - - TabContainer.Masking = false; - TabContainer.Padding = new MarginPadding(5); - } - - protected override Dropdown CreateDropdown() => null; - - protected override TabItem CreateTabItem(IResultPageInfo value) => new ResultModeButton(value) - { - Anchor = TabContainer.Anchor, - Origin = TabContainer.Origin - }; - } -} diff --git a/osu.Game/Screens/Ranking/Results.cs b/osu.Game/Screens/Ranking/Results.cs deleted file mode 100644 index d063988b3f..0000000000 --- a/osu.Game/Screens/Ranking/Results.cs +++ /dev/null @@ -1,291 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Screens; -using osu.Game.Graphics.Containers; -using osu.Game.Screens.Backgrounds; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; -using osu.Game.Screens.Play; - -namespace osu.Game.Screens.Ranking -{ - public abstract class Results : OsuScreen - { - protected const float BACKGROUND_BLUR = 20; - - private Container circleOuterBackground; - private Container circleOuter; - private Container circleInner; - - private ParallaxContainer backgroundParallax; - - private ResultModeTabControl modeChangeButtons; - - [Resolved(canBeNull: true)] - private Player player { get; set; } - - public override bool DisallowExternalBeatmapRulesetChanges => true; - - protected readonly ScoreInfo Score; - - private Container currentPage; - - protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); - - private const float overscan = 1.3f; - - private const float circle_outer_scale = 0.96f; - - protected Results(ScoreInfo score) - { - Score = score; - } - - private const float transition_time = 800; - - private IEnumerable allCircles => new Drawable[] { circleOuterBackground, circleInner, circleOuter }; - - public override void OnEntering(IScreen last) - { - base.OnEntering(last); - ((BackgroundScreenBeatmap)Background).BlurAmount.Value = BACKGROUND_BLUR; - Background.ScaleTo(1.1f, transition_time, Easing.OutQuint); - - allCircles.ForEach(c => - { - c.FadeOut(); - c.ScaleTo(0); - }); - - backgroundParallax.FadeOut(); - modeChangeButtons.FadeOut(); - currentPage?.FadeOut(); - - circleOuterBackground - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.25f, true)) - { - circleOuter - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.3f, true)) - { - backgroundParallax.FadeIn(transition_time, Easing.OutQuint); - - circleInner - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.4f, true)) - { - modeChangeButtons.FadeIn(transition_time, Easing.OutQuint); - currentPage?.FadeIn(transition_time, Easing.OutQuint); - } - } - } - } - - public override bool OnExiting(IScreen next) - { - allCircles.ForEach(c => c.ScaleTo(0, transition_time, Easing.OutSine)); - - Background.ScaleTo(1f, transition_time / 4, Easing.OutQuint); - - this.FadeOut(transition_time / 4); - - return base.OnExiting(next); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChild = new AspectContainer - { - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Height = overscan, - Children = new Drawable[] - { - circleOuterBackground = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - Alpha = 0.2f, - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - } - } - }, - circleOuter = new CircularContainer - { - Size = new Vector2(circle_outer_scale), - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - backgroundParallax = new ParallaxContainer - { - RelativeSizeAxes = Axes.Both, - ParallaxAmount = 0.01f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new Sprite - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.2f, - Texture = Beatmap.Value.Background, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill - } - } - }, - modeChangeButtons = new ResultModeTabControl - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - Height = 50, - Margin = new MarginPadding { Bottom = 110 }, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomCentre, - Text = $"{Score.MaxCombo}x", - RelativePositionAxes = Axes.X, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 40), - X = 0.1f, - Colour = colours.BlueDarker, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopCentre, - Text = "max combo", - Font = OsuFont.GetFont(size: 20), - RelativePositionAxes = Axes.X, - X = 0.1f, - Colour = colours.Gray6, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomCentre, - Text = $"{Score.Accuracy:P2}", - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 40), - RelativePositionAxes = Axes.X, - X = 0.9f, - Colour = colours.BlueDarker, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopCentre, - Text = "accuracy", - Font = OsuFont.GetFont(size: 20), - RelativePositionAxes = Axes.X, - X = 0.9f, - Colour = colours.Gray6, - }, - } - }, - circleInner = new CircularContainer - { - Size = new Vector2(0.6f), - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - } - } - } - }; - - if (player != null) - { - AddInternal(new HotkeyRetryOverlay - { - Action = () => - { - if (!this.IsCurrentScreen()) return; - - player?.Restart(); - }, - }); - } - - var pages = CreateResultPages(); - - foreach (var p in pages) - modeChangeButtons.AddItem(p); - - modeChangeButtons.Current.Value = pages.FirstOrDefault(); - - modeChangeButtons.Current.BindValueChanged(page => - { - currentPage?.FadeOut(); - currentPage?.Expire(); - - currentPage = page.NewValue?.CreatePage(); - - if (currentPage != null) - LoadComponentAsync(currentPage, circleInner.Add); - }, true); - } - - protected abstract IEnumerable CreateResultPages(); - } -} diff --git a/osu.Game/Screens/Ranking/ResultsPage.cs b/osu.Game/Screens/Ranking/ResultsPage.cs deleted file mode 100644 index 8776c599dd..0000000000 --- a/osu.Game/Screens/Ranking/ResultsPage.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Scoring; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Ranking -{ - public abstract class ResultsPage : Container - { - protected readonly ScoreInfo Score; - protected readonly WorkingBeatmap Beatmap; - private CircularContainer content; - private Box fill; - - protected override Container Content => content; - - protected ResultsPage(ScoreInfo score, WorkingBeatmap beatmap) - { - Score = score; - Beatmap = beatmap; - RelativeSizeAxes = Axes.Both; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - fill.Delay(400).FadeInFromZero(600); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AddRangeInternal(new Drawable[] - { - fill = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray6 - }, - new CircularContainer - { - EdgeEffect = new EdgeEffectParameters - { - Colour = colours.GrayF.Opacity(0.8f), - Type = EdgeEffectType.Shadow, - Radius = 1, - }, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 20, - BorderColour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - } - }, - content = new CircularContainer - { - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.2f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Masking = true, - Size = new Vector2(0.88f), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }); - } - } -} diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs new file mode 100644 index 0000000000..44458d8c8e --- /dev/null +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -0,0 +1,296 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public abstract class ResultsScreen : OsuScreen + { + protected const float BACKGROUND_BLUR = 20; + private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + // Temporary for now to stop dual transitions. Should respect the current toolbar mode, but there's no way to do so currently. + public override bool HideOverlaysOnEnter => true; + + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); + + public readonly Bindable SelectedScore = new Bindable(); + + public readonly ScoreInfo Score; + private readonly bool allowRetry; + + [Resolved(CanBeNull = true)] + private Player player { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + private StatisticsPanel statisticsPanel; + private Drawable bottomPanel; + private ScorePanelList scorePanelList; + private Container detachedPanelContainer; + + protected ResultsScreen(ScoreInfo score, bool allowRetry = true) + { + Score = score; + this.allowRetry = allowRetry; + + SelectedScore.Value = score; + } + + [BackgroundDependencyLoader] + private void load() + { + FillFlowContainer buttons; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new VerticalScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + statisticsPanel = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + Score = { BindTarget = SelectedScore } + }, + scorePanelList = new ScorePanelList + { + RelativeSizeAxes = Axes.Both, + SelectedScore = { BindTarget = SelectedScore }, + PostExpandAction = () => statisticsPanel.ToggleVisibility() + }, + detachedPanelContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + }, + new[] + { + bottomPanel = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = TwoLayerButton.SIZE_EXTENDED.Y, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + buttons = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ReplayDownloadButton(null) + { + Score = { BindTarget = SelectedScore }, + Width = 300 + }, + } + } + } + } + } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + } + }; + + if (Score != null) + scorePanelList.AddScore(Score); + + if (player != null && allowRetry) + { + buttons.Add(new RetryButton { Width = 300 }); + + AddInternal(new HotkeyRetryOverlay + { + Action = () => + { + if (!this.IsCurrentScreen()) return; + + player?.Restart(); + }, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var req = FetchScores(scores => Schedule(() => + { + foreach (var s in scores) + addScore(s); + })); + + if (req != null) + api.Queue(req); + + statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + } + + /// + /// Performs a fetch/refresh of scores to be displayed. + /// + /// A callback which should be called when fetching is completed. Scheduling is not required. + /// An responsible for the fetch operation. This will be queued and performed automatically. + protected virtual APIRequest FetchScores(Action> scoresCallback) => null; + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + ((BackgroundScreenBeatmap)Background).BlurAmount.Value = BACKGROUND_BLUR; + + Background.FadeTo(0.5f, 250); + bottomPanel.FadeTo(1, 250); + } + + public override bool OnExiting(IScreen next) + { + Background.FadeTo(1, 250); + + return base.OnExiting(next); + } + + public override bool OnBackButton() + { + if (statisticsPanel.State.Value == Visibility.Visible) + { + statisticsPanel.Hide(); + return true; + } + + return false; + } + + private void addScore(ScoreInfo score) + { + var panel = scorePanelList.AddScore(score); + + if (detachedPanel != null) + panel.Alpha = 0; + } + + private ScorePanel detachedPanel; + + private void onStatisticsStateChanged(ValueChangedEvent state) + { + if (state.NewValue == Visibility.Visible) + { + // Detach the panel in its original location, and move into the desired location in the local container. + var expandedPanel = scorePanelList.GetPanelForScore(SelectedScore.Value); + var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Detach and move into the local container. + scorePanelList.Detach(expandedPanel); + detachedPanelContainer.Add(expandedPanel); + + // Move into its original location in the local container first, then to the final location. + var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos); + expandedPanel.MoveTo(origLocation) + .Then() + .MoveTo(new Vector2(StatisticsPanel.SIDE_PADDING, origLocation.Y), 150, Easing.OutQuint); + + // Hide contracted panels. + foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeOut(150, Easing.OutQuint); + scorePanelList.HandleInput = false; + + // Dim background. + Background.FadeTo(0.1f, 150); + + detachedPanel = expandedPanel; + } + else if (detachedPanel != null) + { + var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Remove from the local container and re-attach. + detachedPanelContainer.Remove(detachedPanel); + scorePanelList.Attach(detachedPanel); + + // Move into its original location in the attached container first, then to the final location. + var origLocation = detachedPanel.Parent.ToLocalSpace(screenSpacePos); + detachedPanel.MoveTo(origLocation) + .Then() + .MoveTo(new Vector2(0, origLocation.Y), 150, Easing.OutQuint); + + // Show contracted panels. + foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeIn(150, Easing.OutQuint); + scorePanelList.HandleInput = true; + + // Un-dim background. + Background.FadeTo(0.5f, 150); + + detachedPanel = null; + } + } + + private class VerticalScrollContainer : OsuScrollContainer + { + protected override Container Content => content; + + private readonly Container content; + + public VerticalScrollContainer() + { + base.Content.Add(content = new Container { RelativeSizeAxes = Axes.X }); + } + + protected override void Update() + { + base.Update(); + content.Height = Math.Max(screen_height, DrawHeight); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Pages/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs similarity index 94% rename from osu.Game/Screens/Ranking/Pages/RetryButton.cs rename to osu.Game/Screens/Ranking/RetryButton.cs index 2a281224c1..59b69bc949 100644 --- a/osu.Game/Screens/Ranking/Pages/RetryButton.cs +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play; using osuTK; -namespace osu.Game.Screens.Ranking.Pages +namespace osu.Game.Screens.Ranking { public class RetryButton : OsuAnimatedButton { @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Ranking.Pages }, }; - TooltipText = "Retry"; + TooltipText = "retry"; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs new file mode 100644 index 0000000000..9633f5c533 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -0,0 +1,295 @@ +// Copyright (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; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Contracted; +using osu.Game.Screens.Ranking.Expanded; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking +{ + public class ScorePanel : CompositeDrawable, IStateful + { + /// + /// Width of the panel when contracted. + /// + public const float CONTRACTED_WIDTH = 130; + + /// + /// Height of the panel when contracted. + /// + private const float contracted_height = 355; + + /// + /// Width of the panel when expanded. + /// + public const float EXPANDED_WIDTH = 360; + + /// + /// Height of the panel when expanded. + /// + private const float expanded_height = 560; + + /// + /// Height of the top layer when the panel is expanded. + /// + private const float expanded_top_layer_height = 53; + + /// + /// Height of the top layer when the panel is contracted. + /// + private const float contracted_top_layer_height = 30; + + /// + /// Duration for the panel to resize into its expanded/contracted size. + /// + private const double resize_duration = 200; + + /// + /// Delay after before the top layer is expanded. + /// + private const double top_layer_expand_delay = 100; + + /// + /// Duration for the top layer expansion. + /// + private const double top_layer_expand_duration = 200; + + /// + /// Duration for the panel contents to fade in. + /// + private const double content_fade_duration = 50; + + private static readonly ColourInfo expanded_top_layer_colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#444"), Color4Extensions.FromHex("#333")); + private static readonly ColourInfo expanded_middle_layer_colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#555"), Color4Extensions.FromHex("#333")); + private static readonly Color4 contracted_top_layer_colour = Color4Extensions.FromHex("#353535"); + private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535"); + + public event Action StateChanged; + + /// + /// An action to be invoked if this is clicked while in an expanded state. + /// + public Action PostExpandAction; + + public readonly ScoreInfo Score; + + private Container content; + + private Container topLayerContainer; + private Drawable topLayerBackground; + private Container topLayerContentContainer; + private Drawable topLayerContent; + + private Container middleLayerContainer; + private Drawable middleLayerBackground; + private Container middleLayerContentContainer; + private Drawable middleLayerContent; + + public ScorePanel(ScoreInfo score) + { + Score = score; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(40), + Children = new Drawable[] + { + topLayerContainer = new Container + { + Name = "Top layer", + RelativeSizeAxes = Axes.X, + Alpha = 0, + Height = 120, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + Child = topLayerBackground = new Box { RelativeSizeAxes = Axes.Both } + }, + topLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } + } + }, + middleLayerContainer = new Container + { + Name = "Middle layer", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + Child = middleLayerBackground = new Box { RelativeSizeAxes = Axes.Both } + }, + middleLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (state == PanelState.Expanded) + { + topLayerBackground.FadeColour(expanded_top_layer_colour); + middleLayerBackground.FadeColour(expanded_middle_layer_colour); + } + else + { + topLayerBackground.FadeColour(contracted_top_layer_colour); + middleLayerBackground.FadeColour(contracted_middle_layer_colour); + } + + updateState(); + } + + private PanelState state = PanelState.Contracted; + + public PanelState State + { + get => state; + set + { + if (state == value) + return; + + state = value; + + if (LoadState >= LoadState.Ready) + updateState(); + + StateChanged?.Invoke(value); + } + } + + private void updateState() + { + topLayerContent?.FadeOut(content_fade_duration).Expire(); + middleLayerContent?.FadeOut(content_fade_duration).Expire(); + + switch (state) + { + case PanelState.Expanded: + Size = new Vector2(EXPANDED_WIDTH, expanded_height); + + topLayerBackground.FadeColour(expanded_top_layer_colour, resize_duration, Easing.OutQuint); + middleLayerBackground.FadeColour(expanded_middle_layer_colour, resize_duration, Easing.OutQuint); + + topLayerContentContainer.Add(middleLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); + middleLayerContentContainer.Add(topLayerContent = new ExpandedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + break; + + case PanelState.Contracted: + Size = new Vector2(CONTRACTED_WIDTH, contracted_height); + + topLayerBackground.FadeColour(contracted_top_layer_colour, resize_duration, Easing.OutQuint); + middleLayerBackground.FadeColour(contracted_middle_layer_colour, resize_duration, Easing.OutQuint); + + middleLayerContentContainer.Add(topLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + break; + } + + content.ResizeTo(Size, resize_duration, Easing.OutQuint); + + bool topLayerExpanded = topLayerContainer.Y < 0; + + // If the top layer was already expanded, then we don't need to wait for the resize and can instead transform immediately. This looks better when changing the panel state. + using (BeginDelayedSequence(topLayerExpanded ? 0 : resize_duration + top_layer_expand_delay, true)) + { + topLayerContainer.FadeIn(); + + switch (state) + { + case PanelState.Expanded: + topLayerContainer.MoveToY(-expanded_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + middleLayerContainer.MoveToY(expanded_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + break; + + case PanelState.Contracted: + topLayerContainer.MoveToY(-contracted_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + middleLayerContainer.MoveToY(contracted_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + break; + } + + topLayerContent?.FadeIn(content_fade_duration); + middleLayerContent?.FadeIn(content_fade_duration); + } + } + + public override Vector2 Size + { + get => base.Size; + set + { + base.Size = value; + + // Auto-size isn't used to avoid 1-frame issues and because the score panel is removed/re-added to the container. + if (trackingContainer != null) + trackingContainer.Size = value; + } + } + + protected override bool OnClick(ClickEvent e) + { + if (State == PanelState.Contracted) + { + State = PanelState.Expanded; + return true; + } + + PostExpandAction?.Invoke(); + + return true; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + => base.ReceivePositionalInputAt(screenSpacePos) + || topLayerContainer.ReceivePositionalInputAt(screenSpacePos) + || middleLayerContainer.ReceivePositionalInputAt(screenSpacePos); + + private ScorePanelTrackingContainer trackingContainer; + + /// + /// Creates a which this can reside inside. + /// The will track the size of this . + /// + /// + /// This is immediately added as a child of the . + /// + /// The . + /// If a already exists. + public ScorePanelTrackingContainer CreateTrackingContainer() + { + if (trackingContainer != null) + throw new InvalidOperationException("A score panel container has already been created."); + + return trackingContainer = new ScorePanelTrackingContainer(this); + } + } +} diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs new file mode 100644 index 0000000000..0f8bc82ac0 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -0,0 +1,288 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public class ScorePanelList : CompositeDrawable + { + /// + /// Normal spacing between all panels. + /// + private const float panel_spacing = 5; + + /// + /// Spacing around both sides of the expanded panel. This is added on top of . + /// + private const float expanded_panel_spacing = 15; + + /// + /// An action to be invoked if a is clicked while in an expanded state. + /// + public Action PostExpandAction; + + public readonly Bindable SelectedScore = new Bindable(); + + private readonly Flow flow; + private readonly Scroll scroll; + private ScorePanel expandedPanel; + + /// + /// Creates a new . + /// + public ScorePanelList() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = scroll = new Scroll + { + RelativeSizeAxes = Axes.Both, + HandleScroll = () => expandedPanel?.IsHovered != true, // handle horizontal scroll only when not hovering the expanded panel. + Child = flow = new Flow + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(panel_spacing, 0), + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedScore.BindValueChanged(selectedScoreChanged, true); + } + + /// + /// Adds a to this list. + /// + /// The to add. + public ScorePanel AddScore(ScoreInfo score) + { + var panel = new ScorePanel(score) + { + PostExpandAction = () => PostExpandAction?.Invoke() + }.With(p => + { + p.StateChanged += s => + { + if (s == PanelState.Expanded) + SelectedScore.Value = p.Score; + }; + }); + + flow.Add(panel.CreateTrackingContainer().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + })); + + if (SelectedScore.Value == score) + selectedScoreChanged(new ValueChangedEvent(SelectedScore.Value, SelectedScore.Value)); + 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; + } + } + + return panel; + } + + /// + /// Brings a to the centre of the screen and expands it. + /// + /// The to present. + private void selectedScoreChanged(ValueChangedEvent score) + { + // Contract the old panel. + foreach (var t in flow.Where(t => t.Panel.Score == score.OldValue)) + { + t.Panel.State = PanelState.Contracted; + t.Margin = new MarginPadding(); + } + + // Find the panel corresponding to the new score. + var expandedTrackingComponent = flow.SingleOrDefault(t => t.Panel.Score == score.NewValue); + expandedPanel = expandedTrackingComponent?.Panel; + + if (expandedPanel == null) + return; + + Debug.Assert(expandedTrackingComponent != null); + + // Expand the new panel. + expandedTrackingComponent.Margin = new MarginPadding { Horizontal = expanded_panel_spacing }; + expandedPanel.State = PanelState.Expanded; + + // Scroll to the new panel. This is done manually since we need: + // 1) To scroll after the scroll container's visible range is updated. + // 2) To account for the centre anchor/origins of panels. + // In the end, it's easier to compute the scroll position manually. + float scrollOffset = flow.GetPanelIndex(expandedPanel.Score) * (ScorePanel.CONTRACTED_WIDTH + panel_spacing); + scroll.ScrollTo(scrollOffset); + } + + protected override void Update() + { + base.Update(); + + float offset = DrawWidth / 2f; + + // Add padding to both sides such that the centre of an expanded panel on either side is in the middle of the screen. + + if (SelectedScore.Value != null) + { + // The expanded panel has extra padding applied to it, so it needs to be included into the offset. + offset -= ScorePanel.EXPANDED_WIDTH / 2f + expanded_panel_spacing; + } + else + offset -= ScorePanel.CONTRACTED_WIDTH / 2f; + + flow.Padding = new MarginPadding { Horizontal = offset }; + } + + private bool handleInput = true; + + /// + /// Whether this or any of the s contained should handle scroll or click input. + /// Setting to false will also hide the scrollbar. + /// + public bool HandleInput + { + get => handleInput; + set + { + handleInput = value; + scroll.ScrollbarVisible = value; + } + } + + public override bool PropagatePositionalInputSubTree => HandleInput && base.PropagatePositionalInputSubTree; + + public override bool PropagateNonPositionalInputSubTree => HandleInput && base.PropagateNonPositionalInputSubTree; + + /// + /// Enumerates all s contained in this . + /// + /// + public IEnumerable GetScorePanels() => flow.Select(t => t.Panel); + + /// + /// Finds the corresponding to a . + /// + /// The to find the corresponding for. + /// The . + public ScorePanel GetPanelForScore(ScoreInfo score) => flow.Single(t => t.Panel.Score == score).Panel; + + /// + /// Detaches a from its , allowing the panel to be moved elsewhere in the hierarchy. + /// + /// The to detach. + /// If is not a part of this . + public void Detach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Detach(); + } + + /// + /// Attaches a to its in this . + /// + /// The to attach. + /// If is not a part of this . + public void Attach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Attach(); + } + + private class Flow : FillFlowContainer + { + public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); + + public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count(); + + private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() + .OrderByDescending(s => s.Panel.Score.TotalScore) + .ThenBy(s => s.Panel.Score.OnlineScoreID); + + protected override int Compare(Drawable x, Drawable y) + { + var tX = (ScorePanelTrackingContainer)x; + var tY = (ScorePanelTrackingContainer)y; + + int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore); + + if (result != 0) + return result; + + if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null) + return base.Compare(x, y); + + return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value); + } + } + + private class Scroll : OsuScrollContainer + { + public new float Target => base.Target; + + public Scroll() + : base(Direction.Horizontal) + { + } + + /// + /// The target that will be scrolled to instantaneously next frame. + /// + public float? InstantScrollTarget; + + /// + /// Whether this container should handle scroll trigger events. + /// + public Func HandleScroll; + + protected override void UpdateAfterChildren() + { + if (InstantScrollTarget != null) + { + ScrollTo(InstantScrollTarget.Value, false); + InstantScrollTarget = null; + } + + base.UpdateAfterChildren(); + } + + public override bool HandlePositionalInput => HandleScroll(); + + public override bool HandleNonPositionalInput => HandleScroll(); + } + } +} diff --git a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs new file mode 100644 index 0000000000..c8010d1c32 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking +{ + /// + /// A which tracks the size of a , to which the can be added or removed. + /// + public class ScorePanelTrackingContainer : CompositeDrawable + { + /// + /// The that created this . + /// + public readonly ScorePanel Panel; + + internal ScorePanelTrackingContainer(ScorePanel panel) + { + Panel = panel; + Attach(); + } + + /// + /// Detaches the from this , removing it as a child. + /// This will continue tracking any size changes. + /// + /// If the is already detached. + public void Detach() + { + if (InternalChildren.Count == 0) + throw new InvalidOperationException("Score panel container is not attached."); + + RemoveInternal(Panel); + } + + /// + /// Attaches the to this , adding it as a child. + /// + /// If the is already attached. + public void Attach() + { + if (InternalChildren.Count > 0) + throw new InvalidOperationException("Score panel container is already attached."); + + AddInternal(Panel); + } + } +} diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs new file mode 100644 index 0000000000..9cf2e6757a --- /dev/null +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking +{ + public class SoloResultsScreen : ResultsScreen + { + [Resolved] + private RulesetStore rulesets { get; set; } + + public SoloResultsScreen(ScoreInfo score, bool allowRetry = true) + : base(score, allowRetry) + { + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + if (Score.Beatmap.OnlineBeatmapID == null || Score.Beatmap.Status <= BeatmapSetOnlineStatus.Pending) + return null; + + var req = new GetScoresRequest(Score.Beatmap, Score.Ruleset); + req.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); + return req; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..527da429ed --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.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; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// A graph which displays the distribution of hit timing in a series of s. + /// + public class HitEventTimingDistributionGraph : CompositeDrawable + { + /// + /// The number of bins on each side of the timing distribution. + /// + private const int timing_distribution_bins = 50; + + /// + /// The total number of bins in the timing distribution, including bins on both sides and the centre bin at 0. + /// + private const int total_timing_distribution_bins = timing_distribution_bins * 2 + 1; + + /// + /// The centre bin, with a timing distribution very close to/at 0. + /// + private const int timing_distribution_centre_bin_index = timing_distribution_bins; + + /// + /// The number of data points shown on each side of the axis below the graph. + /// + private const float axis_points = 5; + + private readonly IReadOnlyList hitEvents; + + /// + /// Creates a new . + /// + /// The s to display the timing distribution of. + public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) + { + this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows)).ToList(); + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents == null || hitEvents.Count == 0) + return; + + int[] bins = new int[total_timing_distribution_bins]; + + double binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); + + // Prevent div-by-0 by enforcing a minimum bin size + binSize = Math.Max(1, binSize); + + foreach (var e in hitEvents) + { + int binOffset = (int)(e.TimeOffset / binSize); + bins[timing_distribution_centre_bin_index + binOffset]++; + } + + int maxCount = bins.Max(); + var bars = new Drawable[total_timing_distribution_bins]; + for (int i = 0; i < bars.Length; i++) + bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) }; + + Container axisFlow; + + InternalChild = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { bars } + } + }, + new Drawable[] + { + axisFlow = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + }; + + // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size. + double maxValue = timing_distribution_bins * binSize; + double axisValueStep = maxValue / axis_points; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "0", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + for (int i = 1; i <= axis_points; i++) + { + double axisValue = i * axisValueStep; + float position = (float)(axisValue / maxValue); + float alpha = 1f - position * 0.8f; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = -position / 2, + Alpha = alpha, + Text = axisValue.ToString("-0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = position / 2, + Alpha = alpha, + Text = axisValue.ToString("+0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + } + } + + private class Bar : CompositeDrawable + { + public Bar() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + RelativeSizeAxes = Axes.Both; + + Padding = new MarginPadding { Horizontal = 1 }; + + InternalChild = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#66FFCC") + }; + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs new file mode 100644 index 0000000000..ed98698411 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics.CodeAnalysis; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// Wraps a to add a header and suitable layout for use in . + /// + internal class StatisticContainer : CompositeDrawable + { + /// + /// Creates a new . + /// + /// The to display. + public StatisticContainer([NotNull] StatisticItem item) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 9, + Width = 4, + Colour = Color4Extensions.FromHex("#00FFAA") + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = item.Name, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + } + } + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 15 }, + Child = item.Content + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs new file mode 100644 index 0000000000..e959ed24fc --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// An item to be displayed in a row of statistics inside the results screen. + /// + public class StatisticItem + { + /// + /// The name of this item. + /// + public readonly string Name; + + /// + /// The content to be displayed. + /// + public readonly Drawable Content; + + /// + /// The of this row. This can be thought of as the column dimension of an encompassing . + /// + public readonly Dimension Dimension; + + /// + /// Creates a new , to be displayed inside a in the results screen. + /// + /// The name of the item. + /// The content to be displayed. + /// The of this item. This can be thought of as the column dimension of an encompassing . + public StatisticItem([NotNull] string name, [NotNull] Drawable content, [CanBeNull] Dimension dimension = null) + { + Name = name; + Content = content; + Dimension = dimension; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs new file mode 100644 index 0000000000..e1ca9799a3 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticRow.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 JetBrains.Annotations; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// A row of statistics to be displayed in the results screen. + /// + public class StatisticRow + { + /// + /// The columns of this . + /// + [ItemNotNull] + public StatisticItem[] Columns; + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs new file mode 100644 index 0000000000..7f406331cd --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -0,0 +1,150 @@ +// Copyright (c) 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.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + public class StatisticsPanel : VisibilityContainer + { + public const float SIDE_PADDING = 30; + + public readonly Bindable Score = new Bindable(); + + protected override bool StartHidden => true; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private readonly Container content; + private readonly LoadingSpinner spinner; + + public StatisticsPanel() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = ScorePanel.EXPANDED_WIDTH + SIDE_PADDING * 3, + Right = SIDE_PADDING, + Top = SIDE_PADDING, + Bottom = 50 // Approximate padding to the bottom of the score panel. + }, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.Both }, + spinner = new LoadingSpinner() + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + Score.BindValueChanged(populateStatistics, true); + } + + private CancellationTokenSource loadCancellation; + + private void populateStatistics(ValueChangedEvent score) + { + loadCancellation?.Cancel(); + loadCancellation = null; + + foreach (var child in content) + child.FadeOut(150).Expire(); + + var newScore = score.NewValue; + + if (newScore == null) + return; + + if (newScore.HitEvents == null || newScore.HitEvents.Count == 0) + content.Add(new MessagePlaceholder("Score has no statistics :(")); + else + { + spinner.Show(); + + var localCancellationSource = loadCancellation = new CancellationTokenSource(); + IBeatmap playableBeatmap = null; + + // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. + Task.Run(() => + { + playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.Beatmap).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty()); + }, loadCancellation.Token).ContinueWith(t => Schedule(() => + { + var rows = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30, 15), + }; + + foreach (var row in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap)) + { + rows.Add(new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + row.Columns?.Select(c => new StatisticContainer(c) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }).Cast().ToArray() + }, + ColumnDimensions = Enumerable.Range(0, row.Columns?.Length ?? 0) + .Select(i => row.Columns[i].Dimension ?? new Dimension()).ToArray(), + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }); + } + + LoadComponentAsync(rows, d => + { + if (Score.Value != newScore) + return; + + spinner.Hide(); + content.Add(d); + }, localCancellationSource.Token); + }), localCancellationSource.Token); + } + } + + protected override bool OnClick(ClickEvent e) + { + ToggleVisibility(); + return true; + } + + protected override void PopIn() => this.FadeIn(150, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(150, Easing.OutQuint); + + protected override void Dispose(bool isDisposing) + { + loadCancellation?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs b/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs deleted file mode 100644 index fe183c5f89..0000000000 --- a/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Pages; - -namespace osu.Game.Screens.Ranking.Types -{ - public class LocalLeaderboardPageInfo : IResultPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public LocalLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public IconUsage Icon => FontAwesome.Solid.User; - - public string Name => @"Local Leaderboard"; - - public ResultsPage CreatePage() => new LocalLeaderboardPage(score, beatmap); - } -} diff --git a/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.cs b/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.cs deleted file mode 100644 index 424dbff6f6..0000000000 --- a/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.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 osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Pages; - -namespace osu.Game.Screens.Ranking.Types -{ - public class ScoreOverviewPageInfo : IResultPageInfo - { - public IconUsage Icon => FontAwesome.Solid.Asterisk; - - public string Name => "Overview"; - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public ScoreOverviewPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public ResultsPage CreatePage() - { - return new ScoreResultsPage(score, beatmap); - } - } -} diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 4acc619753..5f6f859d66 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -9,25 +9,35 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Configuration; using osuTK.Input; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Threading; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; +using osu.Game.Input.Bindings; using osu.Game.Screens.Select.Carousel; namespace osu.Game.Screens.Select { - public class BeatmapCarousel : CompositeDrawable + public class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler { - private const float bleed_top = FilterControl.HEIGHT; - private const float bleed_bottom = Footer.HEIGHT; + /// + /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedTop { get; set; } + + /// + /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedBottom { get; set; } /// /// Triggered when the loaded change and are completely loaded. @@ -46,6 +56,11 @@ namespace osu.Game.Screens.Select /// public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; + /// + /// A function to optionally decide on a recommended difficulty from a beatmap set. + /// + public Func, BeatmapInfo> GetRecommendedBeatmap; + private CarouselBeatmapSet selectedBeatmapSet; /// @@ -56,15 +71,19 @@ namespace osu.Game.Screens.Select public override bool HandleNonPositionalInput => AllowSelection; public override bool HandlePositionalInput => AllowSelection; + public override bool PropagatePositionalInputSubTree => AllowSelection; + public override bool PropagateNonPositionalInputSubTree => AllowSelection; + /// /// Whether carousel items have completed asynchronously loaded. /// public bool BeatmapSetsLoaded { get; private set; } - private readonly OsuScrollContainer scroll; + private readonly CarouselScrollContainer scroll; private IEnumerable beatmapSets => root.Children.OfType(); + // todo: only used for testing, maybe remove. public IEnumerable BeatmapSets { get => beatmapSets.Select(g => g.BeatmapSet); @@ -76,7 +95,6 @@ namespace osu.Game.Screens.Select CarouselRoot newRoot = new CarouselRoot(this); beatmapSets.Select(createCarouselSet).Where(g => g != null).ForEach(newRoot.AddChild); - newRoot.Filter(activeCriteria); // preload drawables as the ctor overhead is quite high currently. _ = newRoot.Drawables; @@ -89,6 +107,9 @@ namespace osu.Game.Screens.Select itemsCache.Invalidate(); scrollPositionCache.Invalidate(); + // apply any pending filter operation that may have been delayed (see applyActiveCriteria's scheduling behaviour when BeatmapSetsLoaded is false). + FlushPendingFilterOperations(); + // Run on late scheduler want to ensure this runs after all pending UpdateBeatmapSet / RemoveBeatmapSet operations are run. SchedulerAfterChildren.Add(() => { @@ -110,8 +131,14 @@ namespace osu.Game.Screens.Select private readonly Stack randomSelectedBeatmaps = new Stack(); protected List Items = new List(); + private CarouselRoot root; + private IBindable> itemUpdated; + private IBindable> itemRemoved; + private IBindable> itemHidden; + private IBindable> itemRestored; + public BeatmapCarousel() { root = new CarouselRoot(this); @@ -130,8 +157,11 @@ namespace osu.Game.Screens.Select }; } + [Resolved] + private BeatmapManager beatmaps { get; set; } + [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuConfigManager config, BeatmapManager beatmaps) + private void load(OsuConfigManager config) { config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); @@ -139,9 +169,20 @@ namespace osu.Game.Screens.Select RightClickScrollingEnabled.ValueChanged += enabled => scroll.RightMouseScrollbar = enabled.NewValue; RightClickScrollingEnabled.TriggerChange(); - loadBeatmapSets(beatmaps.GetAllUsableBeatmapSetsEnumerable()); + itemUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + itemUpdated.BindValueChanged(beatmapUpdated); + itemRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(beatmapRemoved); + itemHidden = beatmaps.BeatmapHidden.GetBoundCopy(); + itemHidden.BindValueChanged(beatmapHidden); + itemRestored = beatmaps.BeatmapRestored.GetBoundCopy(); + itemRestored.BindValueChanged(beatmapRestored); + + loadBeatmapSets(GetLoadableBeatmaps()); } + protected virtual IEnumerable GetLoadableBeatmaps() => beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.AllButFiles); + public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => { var existingSet = beatmapSets.FirstOrDefault(b => b.BeatmapSet.ID == beatmapSet.ID); @@ -175,9 +216,11 @@ namespace osu.Game.Screens.Select root.AddChild(newSet); - applyActiveCriteria(false, false); + // only reset scroll position if already near the scroll target. + // without this, during a large beatmap import it is impossible to navigate the carousel. + applyActiveCriteria(false, alwaysResetScrollPosition: false); - //check if we can/need to maintain our current selection. + // check if we can/need to maintain our current selection. if (previouslySelectedID != null) select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.Beatmap.ID == previouslySelectedID) ?? newSet); @@ -187,15 +230,15 @@ namespace osu.Game.Screens.Select /// /// Selects a given beatmap on the carousel. - /// - /// If bypassFilters is false, we will try to select another unfiltered beatmap in the same set. If the - /// entire set is filtered, no selection is made. /// /// The beatmap to select. /// Whether to select the beatmap even if it is filtered (i.e., not visible on carousel). /// True if a selection was made, False if it wasn't. public bool SelectBeatmap(BeatmapInfo beatmap, bool bypassFilters = true) { + // ensure that any pending events from BeatmapManager have been run before attempting a selection. + Scheduler.Update(); + if (beatmap?.Hidden != false) return false; @@ -211,14 +254,21 @@ namespace osu.Game.Screens.Select continue; if (!bypassFilters && item.Filtered.Value) - // The beatmap exists in this set but is filtered, so look for the first unfiltered map in the set - item = set.Beatmaps.FirstOrDefault(b => !b.Filtered.Value); + return false; - if (item != null) + select(item); + + // if we got here and the set is filtered, it means we were bypassing filters. + // in this case, reapplying the filter is necessary to ensure the panel is in the correct place + // (since it is forcefully being included in the carousel). + if (set.Filtered.Value) { - select(item); - return true; + Debug.Assert(bypassFilters); + + applyActiveCriteria(false); } + + return true; } return false; @@ -231,46 +281,40 @@ namespace osu.Game.Screens.Select /// Whether to skip individual difficulties and only increment over full groups. public void SelectNext(int direction = 1, bool skipDifficulties = true) { - var visibleItems = Items.Where(s => !s.Item.Filtered.Value).ToList(); - - if (!visibleItems.Any()) + if (beatmapSets.All(s => s.Filtered.Value)) return; - DrawableCarouselItem drawable = null; + if (skipDifficulties) + selectNextSet(direction, true); + else + selectNextDifficulty(direction); + } - if (selectedBeatmap != null && (drawable = selectedBeatmap.Drawables.FirstOrDefault()) == null) - // if the selected beatmap isn't present yet, we can't correctly change selection. - // we can fix this by changing this method to not reference drawables / Items in the first place. + private void selectNextSet(int direction, bool skipDifficulties) + { + var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); + + var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count]; + + if (skipDifficulties) + select(nextSet); + else + select(direction > 0 ? nextSet.Beatmaps.First(b => !b.Filtered.Value) : nextSet.Beatmaps.Last(b => !b.Filtered.Value)); + } + + private void selectNextDifficulty(int direction) + { + if (selectedBeatmap == null) return; - int originalIndex = visibleItems.IndexOf(drawable); - int currentIndex = originalIndex; + var unfilteredDifficulties = selectedBeatmapSet.Children.Where(s => !s.Filtered.Value).ToList(); - // local function to increment the index in the required direction, wrapping over extremities. - int incrementIndex() => currentIndex = (currentIndex + direction + visibleItems.Count) % visibleItems.Count; + int index = unfilteredDifficulties.IndexOf(selectedBeatmap); - while (incrementIndex() != originalIndex) - { - var item = visibleItems[currentIndex].Item; - - if (item.Filtered.Value || item.State.Value == CarouselItemState.Selected) continue; - - switch (item) - { - case CarouselBeatmap beatmap: - if (skipDifficulties) continue; - - select(beatmap); - return; - - case CarouselBeatmapSet set: - if (skipDifficulties) - select(set); - else - select(direction > 0 ? set.Beatmaps.First(b => !b.Filtered.Value) : set.Beatmaps.Last(b => !b.Filtered.Value)); - return; - } - } + if (index + direction < 0 || index + direction >= unfilteredDifficulties.Count) + selectNextSet(direction, false); + else + select(unfilteredDifficulties[index + direction]); } /// @@ -279,6 +323,9 @@ namespace osu.Game.Screens.Select /// True if a selection could be made, else False. public bool SelectNextRandom() { + if (!AllowSelection) + return false; + var visibleSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); if (!visibleSets.Any()) return false; @@ -311,8 +358,7 @@ namespace osu.Game.Screens.Select else set = visibleSets.ElementAt(RNG.Next(visibleSets.Count)); - var visibleBeatmaps = set.Beatmaps.Where(s => !s.Filtered.Value).ToList(); - select(visibleBeatmaps[RNG.Next(visibleBeatmaps.Count)]); + select(set); return true; } @@ -355,23 +401,23 @@ namespace osu.Game.Screens.Select /// the beatmap carousel bleeds into the and the /// /// - private float visibleHalfHeight => (DrawHeight + bleed_bottom + bleed_top) / 2; + private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2; /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => scroll.Current + DrawHeight + bleed_bottom; + private float visibleBottomBound => scroll.Current + DrawHeight + BleedBottom; /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => scroll.Current - bleed_top; + private float visibleUpperBound => scroll.Current - BleedTop; public void FlushPendingFilterOperations() { if (PendingFilter?.Completed == false) { - applyActiveCriteria(false, false); + applyActiveCriteria(false); Update(); } } @@ -381,12 +427,24 @@ namespace osu.Game.Screens.Select if (newCriteria != null) activeCriteria = newCriteria; - applyActiveCriteria(debounce, true); + applyActiveCriteria(debounce); } - private void applyActiveCriteria(bool debounce, bool scroll) + private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true) { - if (root.Children.Any() != true) return; + PendingFilter?.Cancel(); + PendingFilter = null; + + if (debounce) + PendingFilter = Scheduler.AddDelayed(perform, 250); + else + { + // if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation. + if (!BeatmapSetsLoaded) + PendingFilter = Schedule(perform); + else + perform(); + } void perform() { @@ -394,56 +452,107 @@ namespace osu.Game.Screens.Select root.Filter(activeCriteria); itemsCache.Invalidate(); - if (scroll) scrollPositionCache.Invalidate(); + + if (alwaysResetScrollPosition || !scroll.UserScrolling) + ScrollToSelected(); } - - PendingFilter?.Cancel(); - PendingFilter = null; - - if (debounce) - PendingFilter = Scheduler.AddDelayed(perform, 250); - else - perform(); } private float? scrollTarget; + /// + /// Scroll to the current . + /// public void ScrollToSelected() => scrollPositionCache.Invalidate(); + #region Key / button selection logic + protected override bool OnKeyDown(KeyDownEvent e) { - int direction = 0; - bool skipDifficulties = false; - switch (e.Key) { - case Key.Up: - direction = -1; - break; - - case Key.Down: - direction = 1; - break; - case Key.Left: - direction = -1; - skipDifficulties = true; - break; + if (!e.Repeat) + beginRepeatSelection(() => SelectNext(-1, true), e.Key); + return true; case Key.Right: - direction = 1; - skipDifficulties = true; + if (!e.Repeat) + beginRepeatSelection(() => SelectNext(1, true), e.Key); + return true; + } + + return false; + } + + protected override void OnKeyUp(KeyUpEvent e) + { + switch (e.Key) + { + case Key.Left: + case Key.Right: + endRepeatSelection(e.Key); break; } - if (direction == 0) - return base.OnKeyDown(e); - - SelectNext(direction, skipDifficulties); - return true; + base.OnKeyUp(e); } - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => ReceivePositionalInputAt(screenSpacePos); + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + beginRepeatSelection(() => SelectNext(1, false), action); + return true; + + case GlobalAction.SelectPrevious: + beginRepeatSelection(() => SelectNext(-1, false), action); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + case GlobalAction.SelectPrevious: + endRepeatSelection(action); + break; + } + } + + private ScheduledDelegate repeatDelegate; + private object lastRepeatSource; + + /// + /// Begin repeating the specified selection action. + /// + /// The action to perform. + /// The source of the action. Used in conjunction with to only cancel the correct action (most recently pressed key). + private void beginRepeatSelection(Action action, object source) + { + endRepeatSelection(); + + lastRepeatSource = source; + repeatDelegate = this.BeginKeyRepeat(Scheduler, action); + } + + private void endRepeatSelection(object source = null) + { + // only the most recent source should be able to cancel the current action. + if (source != null && !EqualityComparer.Default.Equals(lastRepeatSource, source)) + return; + + repeatDelegate?.Cancel(); + repeatDelegate = null; + lastRepeatSource = null; + } + + #endregion protected override void Update() { @@ -533,6 +642,30 @@ namespace osu.Game.Screens.Select i.Dispose(); } + private void beatmapRemoved(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + RemoveBeatmapSet(item); + } + + private void beatmapUpdated(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + UpdateBeatmapSet(item); + } + + private void beatmapRestored(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var b)) + UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); + } + + private void beatmapHidden(ValueChangedEvent> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var b)) + UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); + } + private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet) { if (beatmapSet.Beatmaps.All(b => b.Hidden)) @@ -540,12 +673,12 @@ namespace osu.Game.Screens.Select // todo: remove the need for this. foreach (var b in beatmapSet.Beatmaps) - { - if (b.Metadata == null) - b.Metadata = beatmapSet.Metadata; - } + b.Metadata ??= beatmapSet.Metadata; - var set = new CarouselBeatmapSet(beatmapSet); + var set = new CarouselBeatmapSet(beatmapSet) + { + GetRecommendedBeatmap = beatmaps => GetRecommendedBeatmap?.Invoke(beatmaps) + }; foreach (var c in set.Beatmaps) { @@ -557,7 +690,7 @@ namespace osu.Game.Screens.Select SelectionChanged?.Invoke(c.Beatmap); itemsCache.Invalidate(); - scrollPositionCache.Invalidate(); + ScrollToSelected(); } }; } @@ -598,7 +731,11 @@ namespace osu.Game.Screens.Select case DrawableCarouselBeatmap beatmap: { if (beatmap.Item.State.Value == CarouselItemState.Selected) - scrollTarget = currentY + beatmap.DrawHeight / 2 - DrawHeight / 2; + // scroll position at currentY makes the set panel appear at the very top of the carousel's screen space + // move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas) + // then reapply the top semi-transparent area (because carousel's screen space starts below it) + // and finally add half of the panel's own height to achieve vertical centering of the panel itself + scrollTarget = currentY - visibleHalfHeight + BleedTop + beatmap.DrawHeight / 2; void performMove(float y, float? startY = null) { @@ -707,13 +844,17 @@ namespace osu.Game.Screens.Select public CarouselRoot(BeatmapCarousel carousel) { + // root should always remain selected. if not, PerformSelection will not be called. + State.Value = CarouselItemState.Selected; + State.ValueChanged += state => State.Value = CarouselItemState.Selected; + this.carousel = carousel; } protected override void PerformSelection() { - if (LastSelected == null) - carousel.SelectNextRandom(); + if (LastSelected == null || LastSelected.Filtered.Value) + carousel?.SelectNextRandom(); else base.PerformSelection(); } @@ -723,6 +864,23 @@ namespace osu.Game.Screens.Select { private bool rightMouseScrollBlocked; + /// + /// Whether the last scroll event was user triggered, directly on the scroll container. + /// + public bool UserScrolling { get; private set; } + + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + { + UserScrolling = true; + base.OnUserScroll(value, animated, distanceDecay); + } + + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + { + UserScrolling = false; + base.ScrollTo(value, animated, distanceDecay); + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button == MouseButton.Right) diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index c9b6ca7bb3..b32416b361 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -14,7 +14,8 @@ namespace osu.Game.Screens.Select { public class BeatmapClearScoresDialog : PopupDialog { - private ScoreManager scoreManager; + [Resolved] + private ScoreManager scoreManager { get; set; } public BeatmapClearScoresDialog(BeatmapInfo beatmap, Action onCompletion) { @@ -38,11 +39,5 @@ namespace osu.Game.Screens.Select }, }; } - - [BackgroundDependencyLoader] - private void load(ScoreManager scoreManager) - { - this.scoreManager = scoreManager; - } } } diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs index 71733c9f06..2e78b1aed2 100644 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailArea.cs @@ -2,37 +2,40 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; -using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Select { - public class BeatmapDetailArea : Container + public abstract class BeatmapDetailArea : Container { private const float details_padding = 10; - private readonly Container content; - protected override Container Content => content; - - public readonly BeatmapDetails Details; - public readonly BeatmapLeaderboard Leaderboard; - private WorkingBeatmap beatmap; - public WorkingBeatmap Beatmap + public virtual WorkingBeatmap Beatmap { get => beatmap; set { beatmap = value; - Details.Beatmap = beatmap?.BeatmapInfo; - Leaderboard.Beatmap = beatmap is DummyWorkingBeatmap ? null : beatmap?.BeatmapInfo; + + Details.Beatmap = value?.BeatmapInfo; } } - public BeatmapDetailArea() + public readonly BeatmapDetails Details; + + protected Bindable CurrentTab => tabControl.Current; + + private readonly Container content; + protected override Container Content => content; + + private readonly BeatmapDetailAreaTabControl tabControl; + + protected BeatmapDetailArea() { AddRangeInternal(new Drawable[] { @@ -40,51 +43,62 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = BeatmapDetailAreaTabControl.HEIGHT }, - }, - new BeatmapDetailAreaTabControl - { - RelativeSizeAxes = Axes.X, - OnFilter = (tab, mods) => + Child = Details = new BeatmapDetails { - Leaderboard.FilterMods = mods; - - switch (tab) - { - case BeatmapDetailTab.Details: - Details.Show(); - Leaderboard.Hide(); - break; - - default: - Details.Hide(); - Leaderboard.Scope = (BeatmapLeaderboardScope)tab - 1; - Leaderboard.Show(); - break; - } - }, + RelativeSizeAxes = Axes.X, + Alpha = 0, + Margin = new MarginPadding { Top = details_padding }, + } }, - }); - - AddRange(new Drawable[] - { - Details = new BeatmapDetails + tabControl = new BeatmapDetailAreaTabControl { RelativeSizeAxes = Axes.X, - Alpha = 0, - Margin = new MarginPadding { Top = details_padding }, + TabItems = CreateTabItems(), + OnFilter = OnTabChanged, }, - Leaderboard = new BeatmapLeaderboard - { - RelativeSizeAxes = Axes.Both, - } }); } + /// + /// Refreshes the currently-displayed details. + /// + public virtual void Refresh() + { + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); Details.Height = Math.Min(DrawHeight - details_padding * 3 - BeatmapDetailAreaTabControl.HEIGHT, 450); } + + /// + /// Invoked when a new tab is selected. + /// + /// The tab that was selected. + /// Whether the currently-selected mods should be considered. + protected virtual void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + switch (tab) + { + case BeatmapDetailAreaDetailTabItem _: + Details.Show(); + break; + + default: + Details.Hide(); + break; + } + } + + /// + /// Creates the tabs to be displayed. + /// + /// The tabs. + protected virtual BeatmapDetailAreaTabItem[] CreateTabItems() => new BeatmapDetailAreaTabItem[] + { + new BeatmapDetailAreaDetailTabItem(), + }; } } diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs new file mode 100644 index 0000000000..7376cb4708 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs @@ -0,0 +1,10 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.Select +{ + public class BeatmapDetailAreaDetailTabItem : BeatmapDetailAreaTabItem + { + public override string Name => "Details"; + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs new file mode 100644 index 0000000000..066944e9d2 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.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; + +namespace osu.Game.Screens.Select +{ + public class BeatmapDetailAreaLeaderboardTabItem : BeatmapDetailAreaTabItem + where TScope : Enum + { + public override string Name => Scope.ToString(); + + public override bool FilterableByMods => true; + + public readonly TScope Scope; + + public BeatmapDetailAreaLeaderboardTabItem(TScope scope) + { + Scope = scope; + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs index 19ecdb6dbf..63711e3e50 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,14 +19,25 @@ namespace osu.Game.Screens.Select public class BeatmapDetailAreaTabControl : Container { public const float HEIGHT = 24; + + public Bindable Current + { + get => tabs.Current; + set => tabs.Current = value; + } + + public Action OnFilter; // passed the selected tab and if mods is checked + + public IReadOnlyList TabItems + { + get => tabs.Items; + set => tabs.Items = value; + } + private readonly OsuTabControlCheckbox modsCheckbox; - private readonly OsuTabControl tabs; + private readonly OsuTabControl tabs; private readonly Container tabsContainer; - public Action OnFilter; //passed the selected tab and if mods is checked - - private Bindable selectedTab; - public BeatmapDetailAreaTabControl() { Height = HEIGHT; @@ -43,7 +55,7 @@ namespace osu.Game.Screens.Select tabsContainer = new Container { RelativeSizeAxes = Axes.Both, - Child = tabs = new OsuTabControl + Child = tabs = new OsuTabControl { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -68,29 +80,22 @@ namespace osu.Game.Screens.Select private void load(OsuColour colour, OsuConfigManager config) { modsCheckbox.AccentColour = tabs.AccentColour = colour.YellowLight; - - selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); - - tabs.Current.BindTo(selectedTab); - tabs.Current.TriggerChange(); } private void invokeOnFilter() { OnFilter?.Invoke(tabs.Current.Value, modsCheckbox.Current.Value); - modsCheckbox.FadeTo(tabs.Current.Value == BeatmapDetailTab.Details ? 0 : 1, 200, Easing.OutQuint); - - tabsContainer.Padding = new MarginPadding { Right = tabs.Current.Value == BeatmapDetailTab.Details ? 0 : 100 }; + if (tabs.Current.Value.FilterableByMods) + { + modsCheckbox.FadeTo(1, 200, Easing.OutQuint); + tabsContainer.Padding = new MarginPadding { Right = 100 }; + } + else + { + modsCheckbox.FadeTo(0, 200, Easing.OutQuint); + tabsContainer.Padding = new MarginPadding(); + } } } - - public enum BeatmapDetailTab - { - Details, - Local, - Country, - Global, - Friends - } } diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs new file mode 100644 index 0000000000..f28e5a7c22 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.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; + +namespace osu.Game.Screens.Select +{ + public abstract class BeatmapDetailAreaTabItem : IEquatable + { + /// + /// The name of this tab, to be displayed in the tab control. + /// + public abstract string Name { get; } + + /// + /// Whether the contents of this tab can be filtered by the user's currently-selected mods. + /// + public virtual bool FilterableByMods => false; + + public override string ToString() => Name; + + public bool Equals(BeatmapDetailAreaTabItem other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Name == other.Name; + } + + public override int GetHashCode() + { + return Name != null ? Name.GetHashCode() : 0; + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 577d999388..9669a1391c 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using System.Linq; using osu.Game.Online.API; using osu.Framework.Threading; @@ -17,6 +16,7 @@ using osu.Game.Screens.Select.Details; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; @@ -35,9 +35,10 @@ namespace osu.Game.Screens.Select private readonly MetadataSection description, source, tags; private readonly Container failRetryContainer; private readonly FailRetryGraph failRetryGraph; - private readonly DimmedLoadingLayer loading; + private readonly LoadingLayer loading; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } private ScheduledDelegate pendingBeatmapSwitch; @@ -62,6 +63,8 @@ namespace osu.Game.Screens.Select public BeatmapDetails() { + Container content; + Children = new Drawable[] { new Box @@ -69,7 +72,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.Both, Colour = Color4.Black.Opacity(0.5f), }, - new Container + content = new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = spacing }, @@ -156,16 +159,10 @@ namespace osu.Game.Screens.Select }, }, }, - loading = new DimmedLoadingLayer(), + loading = new LoadingLayer(content), }; } - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - this.api = api; - } - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -204,7 +201,7 @@ namespace osu.Game.Screens.Select Schedule(() => { if (beatmap != requestedBeatmap) - //the beatmap has been changed since we started the lookup. + // the beatmap has been changed since we started the lookup. return; var b = res.ToBeatmap(rulesets); @@ -225,7 +222,7 @@ namespace osu.Game.Screens.Select Schedule(() => { if (beatmap != requestedBeatmap) - //the beatmap has been changed since we started the lookup. + // the beatmap has been changed since we started the lookup. return; updateMetrics(); diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index d54c13c7db..27ce9e82dd 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -13,7 +13,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -23,6 +23,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Select { private const float shear_width = 36.75f; - private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGED_CONTAINER_SIZE.Y, 0); + private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); private readonly IBindable ruleset = new Bindable(); @@ -154,7 +155,6 @@ namespace osu.Game.Screens.Select var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); CacheDrawnFrameBuffer = true; - RedrawOnScale = false; RelativeSizeAxes = Axes.Both; @@ -311,20 +311,27 @@ namespace osu.Game.Screens.Select Content = getBPMRange(b), })); - IBeatmap playableBeatmap; - try { - // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); - } - catch (BeatmapInvalidForRulesetException) - { - // Can't be converted to the user's ruleset, so use the beatmap's own ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); - } + IBeatmap playableBeatmap; - labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + try + { + // Try to get the beatmap with the user's ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); + } + catch (BeatmapInvalidForRulesetException) + { + // Can't be converted to the user's ruleset, so use the beatmap's own ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); + } + + labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); + } } return labels.ToArray(); @@ -363,7 +370,7 @@ namespace osu.Game.Screens.Select public class InfoLabel : Container, IHasTooltip { - public string TooltipText { get; private set; } + public string TooltipText { get; } public InfoLabel(BeatmapStatistic statistic) { @@ -384,7 +391,7 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"441288"), + Colour = Color4Extensions.FromHex(@"441288"), Icon = FontAwesome.Solid.Square, Rotation = 45, }, @@ -394,7 +401,7 @@ namespace osu.Game.Screens.Select Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Scale = new Vector2(0.8f), - Colour = OsuColour.FromHex(@"f7dd55"), + Colour = Color4Extensions.FromHex(@"f7dd55"), Icon = statistic.Icon, }, } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 68a6ad8845..ed54c158db 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; @@ -30,6 +29,13 @@ namespace osu.Game.Screens.Select.Carousel Beatmap.RulesetID == criteria.Ruleset.ID || (Beatmap.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); + if (Beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + { + // only check ruleset equality or convertability for selected beatmap + Filtered.Value = !match; + return; + } + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(Beatmap.StarDifficulty); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate); @@ -44,12 +50,11 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(Beatmap.Metadata.Artist) || criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode); + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(Beatmap.StarDifficulty); + if (match) { - var terms = new List(); - - terms.AddRange(Beatmap.Metadata.SearchableTerms); - terms.Add(Beatmap.Version); + var terms = Beatmap.SearchableTerms; foreach (var criteriaTerm in criteria.SearchTerms) match &= terms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0); diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 301d0d4dae..92ccfde14b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -16,6 +16,8 @@ namespace osu.Game.Screens.Select.Carousel public BeatmapSetInfo BeatmapSet; + public Func, BeatmapInfo> GetRecommendedBeatmap; + public CarouselBeatmapSet(BeatmapSetInfo beatmapSet) { BeatmapSet = beatmapSet ?? throw new ArgumentNullException(nameof(beatmapSet)); @@ -28,6 +30,17 @@ namespace osu.Game.Screens.Select.Carousel protected override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmapSet(this); + protected override CarouselItem GetNextToSelect() + { + if (LastSelected == null) + { + if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.Beatmap)) is BeatmapInfo recommended) + return Children.OfType().First(b => b.Beatmap == recommended); + } + + return base.GetNextToSelect(); + } + public override int CompareTo(FilterCriteria criteria, CarouselItem other) { if (!(other is CarouselBeatmapSet otherSet)) @@ -62,7 +75,7 @@ namespace osu.Game.Screens.Select.Carousel /// /// All beatmaps which are not filtered and valid for display. /// - protected IEnumerable ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value).Select(b => b.Beatmap); + protected IEnumerable ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.Beatmap); private int compareUsingAggregateMax(CarouselBeatmapSet other, Func func) { diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index 045c682dc3..262bea9c71 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -90,11 +90,15 @@ namespace osu.Game.Screens.Select.Carousel PerformSelection(); } + protected virtual CarouselItem GetNextToSelect() + { + return Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? + Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + } + protected virtual void PerformSelection() { - CarouselItem nextToSelect = - Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? - Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + CarouselItem nextToSelect = GetNextToSelect(); if (nextToSelect != null) nextToSelect.State.Value = CarouselItemState.Selected; @@ -104,7 +108,8 @@ namespace osu.Game.Screens.Select.Carousel private void updateSelected(CarouselItem newSelection) { - LastSelected = newSelection; + if (newSelection != null) + LastSelected = newSelection; updateSelectedIndex(); } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index fba7a328c1..3e4798a812 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -37,7 +38,8 @@ namespace osu.Game.Screens.Select.Carousel private Triangles triangles; private StarCounter starCounter; - private BeatmapSetOverlay beatmapOverlay; + [Resolved(CanBeNull = true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } public DrawableCarouselBeatmap(CarouselBeatmap panel) : base(panel) @@ -47,14 +49,13 @@ namespace osu.Game.Screens.Select.Carousel } [BackgroundDependencyLoader(true)] - private void load(SongSelect songSelect, BeatmapManager manager, BeatmapSetOverlay beatmapOverlay) + private void load(BeatmapManager manager, SongSelect songSelect) { - this.beatmapOverlay = beatmapOverlay; - if (songSelect != null) { startRequested = b => songSelect.FinaliseSelection(b); - editRequested = songSelect.Edit; + if (songSelect.AllowEditing) + editRequested = songSelect.Edit; } if (manager != null) @@ -70,8 +71,8 @@ namespace osu.Game.Screens.Select.Carousel { TriangleScale = 2, RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"3a7285"), - ColourDark = OsuColour.FromHex(@"123744") + ColourLight = Color4Extensions.FromHex(@"3a7285"), + ColourDark = Color4Extensions.FromHex(@"123744") }, new FillFlowContainer { @@ -122,10 +123,24 @@ namespace osu.Game.Screens.Select.Carousel }, } }, - starCounter = new StarCounter + new FillFlowContainer { - CountStars = (float)beatmap.StarDifficulty, - Scale = new Vector2(0.8f), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new TopLocalRank(beatmap) + { + Scale = new Vector2(0.8f), + Size = new Vector2(40, 20) + }, + starCounter = new StarCounter + { + Current = (float)beatmap.StarDifficulty, + Scale = new Vector2(0.8f), + } + } } } } @@ -173,15 +188,19 @@ namespace osu.Game.Screens.Select.Carousel { get { - List items = new List - { - new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested?.Invoke(beatmap)), - new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested?.Invoke(beatmap)), - new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested?.Invoke(beatmap)), - }; + List items = new List(); - if (beatmap.OnlineBeatmapID.HasValue) - items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); + if (startRequested != null) + items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmap))); + + if (editRequested != null) + items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap))); + + if (hideRequested != null) + items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap))); + + if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null) + items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); return items.ToArray(); } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 699e01bca7..5acb6d1946 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; @@ -30,7 +31,9 @@ namespace osu.Game.Screens.Select.Carousel private Action restoreHiddenRequested; private Action viewDetails; - private DialogOverlay dialogOverlay; + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + private readonly BeatmapSetInfo beatmapSet; public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) @@ -40,10 +43,10 @@ namespace osu.Game.Screens.Select.Carousel } [BackgroundDependencyLoader(true)] - private void load(BeatmapManager manager, BeatmapSetOverlay beatmapOverlay, DialogOverlay overlay) + private void load(BeatmapManager manager, BeatmapSetOverlay beatmapOverlay) { restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore); - dialogOverlay = overlay; + if (beatmapOverlay != null) viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; @@ -129,13 +132,14 @@ namespace osu.Game.Screens.Select.Carousel if (Item.State.Value == CarouselItemState.NotSelected) items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected)); - if (beatmapSet.OnlineBeatmapSetID != null) - items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails?.Invoke(beatmapSet.OnlineBeatmapSetID.Value))); + if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineBeatmapSetID.Value))); if (beatmapSet.Beatmaps.Any(b => b.Hidden)) - items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested?.Invoke(beatmapSet))); + items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); - items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet)))); + if (dialogOverlay != null) + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); return items.ToArray(); } @@ -157,11 +161,11 @@ namespace osu.Game.Screens.Select.Carousel Origin = Anchor.Centre, FillMode = FillMode.Fill, }, - // Todo: This should be a fill flow, but has invalidation issues (see https://github.com/ppy/osu-framework/issues/223) - new Container + new FillFlowContainer { Depth = -1, RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle Shear = new Vector2(0.8f, 0), Alpha = 0.5f, @@ -171,7 +175,6 @@ namespace osu.Game.Screens.Select.Carousel new Box { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, Colour = Color4.Black, Width = 0.4f, }, @@ -179,26 +182,20 @@ namespace osu.Game.Screens.Select.Carousel new Box { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), Width = 0.05f, - X = 0.4f, }, new Box { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), Width = 0.2f, - X = 0.45f, }, new Box { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), Width = 0.05f, - X = 0.65f, }, } }, @@ -210,23 +207,35 @@ namespace osu.Game.Screens.Select.Carousel { private readonly BindableBool filtered = new BindableBool(); + public bool IsFiltered => filtered.Value; + + public readonly CarouselBeatmap Item; + public FilterableDifficultyIcon(CarouselBeatmap item) : base(item.Beatmap) { filtered.BindTo(item.Filtered); filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); filtered.TriggerChange(); + + Item = item; + } + + protected override bool OnClick(ClickEvent e) + { + Item.State.Value = CarouselItemState.Selected; + return true; } } public class FilterableGroupedDifficultyIcon : GroupedDifficultyIcon { - private readonly List items; + public readonly List Items; public FilterableGroupedDifficultyIcon(List items, RulesetInfo ruleset) : base(items.Select(i => i.Beatmap).ToList(), ruleset, Color4.White) { - this.items = items; + Items = items; foreach (var item in items) item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay)); @@ -234,10 +243,16 @@ namespace osu.Game.Screens.Select.Carousel updateFilteredDisplay(); } + protected override bool OnClick(ClickEvent e) + { + Items.First().State.Value = CarouselItemState.Selected; + return true; + } + private void updateFilteredDisplay() { // for now, fade the whole group based on the ratio of hidden items. - this.FadeTo(1 - 0.9f * ((float)items.Count(i => i.Filtered.Value) / items.Count), 100); + this.FadeTo(1 - 0.9f * ((float)Items.Count(i => i.Filtered.Value) / Items.Count), 100); } } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index 6118191302..121491d6ca 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs new file mode 100644 index 0000000000..3ad57c1cb0 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Select.Carousel +{ + public class TopLocalRank : UpdateableRank + { + private readonly BeatmapInfo beatmap; + + [Resolved] + private ScoreManager scores { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + private IBindable> itemUpdated; + private IBindable> itemRemoved; + + public TopLocalRank(BeatmapInfo beatmap) + : base(null) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + itemUpdated = scores.ItemUpdated.GetBoundCopy(); + itemUpdated.BindValueChanged(scoreChanged); + + itemRemoved = scores.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(scoreChanged); + + ruleset.ValueChanged += _ => fetchAndLoadTopScore(); + + fetchAndLoadTopScore(); + } + + private void scoreChanged(ValueChangedEvent> weakScore) + { + if (weakScore.NewValue.TryGetTarget(out var score)) + { + if (score.BeatmapInfoID == beatmap.ID) + fetchAndLoadTopScore(); + } + } + + private ScheduledDelegate scheduledRankUpdate; + + private void fetchAndLoadTopScore() + { + var rank = fetchTopScore()?.Rank; + scheduledRankUpdate = Schedule(() => + { + Rank = rank; + + // Required since presence is changed via IsPresent override + Invalidate(Invalidation.Presence); + }); + } + + // We're present if a rank is set, or if there is a pending rank update (IsPresent = true is required for the scheduler to run). + public override bool IsPresent => base.IsPresent && (Rank != null || scheduledRankUpdate?.Completed == false); + + private ScoreInfo fetchTopScore() + { + if (scores == null || beatmap == null || ruleset?.Value == null || api?.LocalUser.Value == null) + return null; + + return scores.QueryScores(s => s.UserID == api.LocalUser.Value.Id && s.BeatmapInfoID == beatmap.ID && s.RulesetID == ruleset.Value.ID && !s.DeletePending) + .OrderByDescending(s => s.TotalScore) + .FirstOrDefault(); + } + } +} diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index a147527f6c..02822ea608 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.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 osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -10,13 +9,13 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using System; using osu.Game.Beatmaps; using osu.Framework.Bindables; using System.Collections.Generic; using osu.Game.Rulesets.Mods; using System.Linq; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Overlays.Settings; @@ -27,7 +26,8 @@ namespace osu.Game.Screens.Select.Details [Resolved] private IBindable> mods { get; set; } - private readonly StatisticRow firstValue, hpDrain, accuracy, approachRate, starDifficulty; + protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; + private readonly StatisticRow starDifficulty; private BeatmapInfo beatmap; @@ -50,13 +50,12 @@ namespace osu.Game.Screens.Select.Details { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(4f), Children = new[] { - firstValue = new StatisticRow(), //circle size/key amount - hpDrain = new StatisticRow { Title = "HP Drain" }, - accuracy = new StatisticRow { Title = "Accuracy" }, - approachRate = new StatisticRow { Title = "Approach Rate" }, + FirstValue = new StatisticRow(), // circle size/key amount + HpDrain = new StatisticRow { Title = "HP Drain" }, + Accuracy = new StatisticRow { Title = "Accuracy" }, + ApproachRate = new StatisticRow { Title = "Approach Rate" }, starDifficulty = new StatisticRow(10, true) { Title = "Star Difficulty" }, }, }; @@ -118,26 +117,29 @@ namespace osu.Game.Screens.Select.Details mod.ApplyToDifficulty(adjustedDifficulty); } - //mania specific - if ((Beatmap?.Ruleset?.ID ?? 0) == 3) + switch (Beatmap?.Ruleset?.ID ?? 0) { - firstValue.Title = "Key Amount"; - firstValue.Value = ((int)MathF.Round(baseDifficulty?.CircleSize ?? 0), (int)MathF.Round(adjustedDifficulty?.CircleSize ?? 0)); - } - else - { - firstValue.Title = "Circle Size"; - firstValue.Value = (baseDifficulty?.CircleSize ?? 0, adjustedDifficulty?.CircleSize); + case 3: + // Account for mania differences locally for now + // Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes + FirstValue.Title = "Key Count"; + FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, null); + break; + + default: + FirstValue.Title = "Circle Size"; + FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, adjustedDifficulty?.CircleSize); + break; } starDifficulty.Value = ((float)(Beatmap?.StarDifficulty ?? 0), null); - hpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); - accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); - approachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); + HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); + Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); + ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); } - private class StatisticRow : Container, IHasAccentColour + public class StatisticRow : Container, IHasAccentColour { private const float value_width = 25; private const float name_width = 70; @@ -145,7 +147,8 @@ namespace osu.Game.Screens.Select.Details private readonly float maxValue; private readonly bool forceDecimalPlaces; private readonly OsuSpriteText name, valueText; - private readonly Bar bar, modBar; + private readonly Bar bar; + public readonly Bar ModBar; [Resolved] private OsuColour colours { get; set; } @@ -171,14 +174,14 @@ namespace osu.Game.Screens.Select.Details bar.Length = value.baseValue / maxValue; valueText.Text = (value.adjustedValue ?? value.baseValue).ToString(forceDecimalPlaces ? "0.00" : "0.##"); - modBar.Length = (value.adjustedValue ?? 0) / maxValue; + ModBar.Length = (value.adjustedValue ?? 0) / maxValue; - if (value.adjustedValue > value.baseValue) - modBar.AccentColour = valueText.Colour = colours.Red; + if (Precision.AlmostEquals(value.baseValue, value.adjustedValue ?? value.baseValue, 0.05f)) + ModBar.AccentColour = valueText.Colour = Color4.White; + else if (value.adjustedValue > value.baseValue) + ModBar.AccentColour = valueText.Colour = colours.Red; else if (value.adjustedValue < value.baseValue) - modBar.AccentColour = valueText.Colour = colours.BlueDark; - else - modBar.AccentColour = valueText.Colour = Color4.White; + ModBar.AccentColour = valueText.Colour = colours.BlueDark; } } @@ -194,6 +197,7 @@ namespace osu.Game.Screens.Select.Details this.forceDecimalPlaces = forceDecimalPlaces; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Vertical = 2.5f }; Children = new Drawable[] { @@ -201,9 +205,11 @@ namespace osu.Game.Screens.Select.Details { Width = name_width, AutoSizeAxes = Axes.Y, + // osu-web uses 1.25 line-height, which at 12px font size makes the element 14px tall - this compentates that difference + Padding = new MarginPadding { Vertical = 1 }, Child = name = new OsuSpriteText { - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, bar = new Bar @@ -215,7 +221,7 @@ namespace osu.Game.Screens.Select.Details BackgroundColour = Color4.White.Opacity(0.5f), Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 }, }, - modBar = new Bar + ModBar = new Bar { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, @@ -234,7 +240,7 @@ namespace osu.Game.Screens.Select.Details { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, }; diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs index c1e01e3572..cf5e3ba1b3 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Screens/Select/Details/UserRatings.cs @@ -71,31 +71,32 @@ namespace osu.Game.Screens.Select.Details Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "User Rating", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12), + Margin = new MarginPadding { Bottom = 5 }, }, ratingsBar = new Bar { RelativeSizeAxes = Axes.X, Height = 5, - Margin = new MarginPadding { Top = 5 }, }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 10 }, Children = new[] { negativeRatings = new OsuSpriteText { Text = "0", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, positiveRatings = new OsuSpriteText { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Text = @"0", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, }, @@ -104,8 +105,8 @@ namespace osu.Game.Screens.Select.Details Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Rating Spread", - Font = OsuFont.GetFont(size: 13), - Margin = new MarginPadding { Top = 10, Bottom = 5 }, + Font = OsuFont.GetFont(size: 12), + Margin = new MarginPadding { Bottom = 5 }, }, }, }, diff --git a/osu.Game/Screens/Select/DifficultyRecommender.cs b/osu.Game/Screens/Select/DifficultyRecommender.cs new file mode 100644 index 0000000000..0dd3341a93 --- /dev/null +++ b/osu.Game/Screens/Select/DifficultyRecommender.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.Select +{ + public class DifficultyRecommender : Component, IOnlineComponent + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private Bindable ruleset { get; set; } + + private readonly Dictionary recommendedStarDifficulty = new Dictionary(); + + [BackgroundDependencyLoader] + private void load() + { + api.Register(this); + } + + /// + /// Find the recommended difficulty from a selection of available difficulties for the current local user. + /// + /// + /// This requires the user to be online for now. + /// + /// A collection of beatmaps to select a difficulty from. + /// The recommended difficulty, or null if a recommendation could not be provided. + public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) + { + if (recommendedStarDifficulty.TryGetValue(ruleset.Value, out var stars)) + { + return beatmaps.OrderBy(b => + { + var difference = b.StarDifficulty - stars; + return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder + }).FirstOrDefault(); + } + + return null; + } + + private void calculateRecommendedDifficulties() + { + rulesets.AvailableRulesets.ForEach(rulesetInfo => + { + 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 + recommendedStarDifficulty[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; + }; + + api.Queue(req); + }); + } + + public void APIStateChanged(IAPIProvider api, APIState state) + { + switch (state) + { + case APIState.Online: + calculateRecommendedDifficulties(); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + api?.Unregister(this); + } + } +} diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 5b81303788..e111ec4b15 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -2,20 +2,20 @@ // 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.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Select.Filter; -using Container = osu.Framework.Graphics.Containers.Container; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Screens.Select.Filter; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Select { @@ -25,9 +25,7 @@ namespace osu.Game.Screens.Select public Action FilterChanged; - private readonly OsuTabControl sortTabs; - - private readonly TabControl groupTabs; + private OsuTabControl sortTabs; private Bindable sortMode; @@ -42,26 +40,52 @@ namespace osu.Game.Screens.Select Group = groupMode.Value, Sort = sortMode.Value, AllowConvertedBeatmaps = showConverted.Value, - Ruleset = ruleset.Value + Ruleset = ruleset.Value, }; + if (!minimumStars.IsDefault) + criteria.UserStarDifficulty.Min = minimumStars.Value; + + if (!maximumStars.IsDefault) + criteria.UserStarDifficulty.Max = maximumStars.Value; + FilterQueryParser.ApplyQueries(criteria, query); return criteria; } - private readonly SeekLimitedSearchTextBox searchTextBox; + private SeekLimitedSearchTextBox searchTextBox; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - base.ReceivePositionalInputAt(screenSpacePos) || groupTabs.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); + base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); - public FilterControl() + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuColour colours, IBindable parentRuleset, OsuConfigManager config) { + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted); + showConverted.ValueChanged += _ => updateCriteria(); + + config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars); + minimumStars.ValueChanged += _ => updateCriteria(); + + config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars); + maximumStars.ValueChanged += _ => updateCriteria(); + + ruleset.BindTo(parentRuleset); + ruleset.BindValueChanged(_ => updateCriteria()); + + sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); + groupMode = config.GetBindable(OsuSetting.SongSelectGroupingMode); + + groupMode.BindValueChanged(_ => updateCriteria()); + sortMode.BindValueChanged(_ => updateCriteria()); + Children = new Drawable[] { - Background = new Box + new Box { Colour = Color4.Black, Alpha = 0.8f, + Width = 2, RelativeSizeAxes = Axes.Both, }, new Container @@ -89,33 +113,35 @@ namespace osu.Game.Screens.Select Direction = FillDirection.Horizontal, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), Children = new Drawable[] { - groupTabs = new OsuTabControl + new OsuTabControlCheckbox { - RelativeSizeAxes = Axes.X, - Height = 24, - Width = 0.5f, - AutoSort = true, + Text = "Show converted", + Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, }, - //spriteText = new OsuSpriteText - //{ - // Font = @"Exo2.0-Bold", - // Text = "Sort results by", - // Size = 14, - // Margin = new MarginPadding - // { - // Top = 5, - // Bottom = 5 - // }, - //}, 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 = "Sort by", + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, } }, } @@ -124,12 +150,13 @@ namespace osu.Game.Screens.Select searchTextBox.Current.ValueChanged += _ => FilterChanged?.Invoke(CreateCriteria()); - groupTabs.PinItem(GroupMode.All); - groupTabs.PinItem(GroupMode.RecentlyPlayed); + updateCriteria(); } public void Deactivate() { + searchTextBox.ReadOnly = true; + searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) GetContainingInputManager().ChangeFocus(searchTextBox); @@ -137,38 +164,20 @@ namespace osu.Game.Screens.Select public void Activate() { + searchTextBox.ReadOnly = false; searchTextBox.HoldFocus = true; } private readonly IBindable ruleset = new Bindable(); - private Bindable showConverted; - - public readonly Box Background; - - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, IBindable parentRuleset, OsuConfigManager config) - { - sortTabs.AccentColour = colours.GreenLight; - - showConverted = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); - showConverted.ValueChanged += _ => updateCriteria(); - - ruleset.BindTo(parentRuleset); - ruleset.BindValueChanged(_ => updateCriteria()); - - sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); - groupMode = config.GetBindable(OsuSetting.SongSelectGroupingMode); - - sortTabs.Current.BindTo(sortMode); - groupTabs.Current.BindTo(groupMode); - - groupMode.BindValueChanged(_ => updateCriteria()); - sortMode.BindValueChanged(_ => updateCriteria()); - - updateCriteria(); - } + private readonly Bindable showConverted = new Bindable(); + private readonly Bindable minimumStars = new BindableDouble(); + private readonly Bindable maximumStars = new BindableDouble(); private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria()); + + protected override bool OnClick(ClickEvent e) => true; + + protected override bool OnHover(HoverEvent e) => true; } } diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index c4d9996377..18be4fcac8 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -15,6 +15,8 @@ namespace osu.Game.Screens.Select public GroupMode Group; public SortMode Sort; + public BeatmapSetInfo SelectedBeatmapSet; + public OptionalRange StarDifficulty; public OptionalRange ApproachRate; public OptionalRange DrainRate; @@ -26,6 +28,12 @@ namespace osu.Game.Screens.Select public OptionalTextFilter Creator; public OptionalTextFilter Artist; + public OptionalRange UserStarDifficulty = new OptionalRange + { + IsLowerInclusive = true, + IsUpperInclusive = true + }; + public string[] SearchTerms = Array.Empty(); public RulesetInfo Ruleset; diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 1dc7081c1c..689a11166a 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -107,5 +107,7 @@ namespace osu.Game.Screens.Select protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) => true; + + protected override bool OnHover(HoverEvent e) => true; } } diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index b77da36748..35970cd960 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -56,6 +56,7 @@ namespace osu.Game.Screens.Select } } + protected FillFlowContainer ButtonContentContainer; protected readonly Container TextContainer; protected readonly SpriteText SpriteText; private readonly Box box; @@ -80,15 +81,36 @@ namespace osu.Game.Screens.Select EdgeSmoothness = new Vector2(2, 0), RelativeSizeAxes = Axes.X, }, - TextContainer = new Container + new Container { - Size = new Vector2(100 - SHEAR_WIDTH, 50), - Shear = -SHEAR, - Child = SpriteText = new OsuSpriteText + AutoSizeAxes = Axes.Both, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } + ButtonContentContainer = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Shear = -SHEAR, + AutoSizeAxes = Axes.X, + Height = 50, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + TextContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Child = SpriteText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + }, + }, + }, }, }; } @@ -97,6 +119,19 @@ namespace osu.Game.Screens.Select public Action HoverLost; public Key? Hotkey; + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + float horizontalMargin = (100 - TextContainer.Width) / 2; + ButtonContentContainer.Padding = new MarginPadding + { + Left = horizontalMargin, + // right side margin offset to compensate for shear + Right = horizontalMargin - SHEAR_WIDTH / 2 + }; + } + protected override bool OnHover(HoverEvent e) { Hovered?.Invoke(); @@ -118,10 +153,10 @@ namespace osu.Game.Screens.Select return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { box.FadeOut(Footer.TRANSITION_LENGTH, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 8419ee0c2a..02333da0dc 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -3,7 +3,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Screens.Play.HUD; using osu.Game.Rulesets.Mods; using System.Collections.Generic; @@ -28,37 +27,25 @@ namespace osu.Game.Screens.Select } protected readonly OsuSpriteText MultiplierText; - private readonly FooterModDisplay modDisplay; + private readonly ModDisplay modDisplay; private Color4 lowMultiplierColour; private Color4 highMultiplierColour; public FooterButtonMods() { - Add(new FillFlowContainer + ButtonContentContainer.Add(modDisplay = new ModDisplay { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Direction = FillDirection.Horizontal, - Shear = -SHEAR, - Children = new Drawable[] - { - modDisplay = new FooterModDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - DisplayUnrankedText = false, - Scale = new Vector2(0.8f) - }, - MultiplierText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Margin = new MarginPadding { Right = 10 } - } - }, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Left = 70 } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + DisplayUnrankedText = false, + Scale = new Vector2(0.8f), + ExpansionMode = ExpansionMode.AlwaysContracted, + }); + ButtonContentContainer.Add(MultiplierText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.Bold), }); } @@ -92,16 +79,11 @@ namespace osu.Game.Screens.Select MultiplierText.FadeColour(lowMultiplierColour, 200); else MultiplierText.FadeColour(Color4.White, 200); - } - private class FooterModDisplay : ModDisplay - { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false; - - public FooterModDisplay() - { - AllowExpand = false; - } + if (Current.Value?.Count > 0) + modDisplay.FadeIn(); + else + modDisplay.FadeOut(); } } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 14c9eb2035..a42e6721db 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -24,8 +24,11 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = @"rewind", - Alpha = 0 + Alpha = 0, }); + + // force both text sprites to always be present to avoid width flickering while they're being swapped out + SpriteText.AlwaysPresent = secondaryText.AlwaysPresent = true; } [BackgroundDependencyLoader] @@ -44,11 +47,11 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { secondaryActive = e.ShiftPressed; updateText(); - return base.OnKeyUp(e); + base.OnKeyUp(e); } private void updateText() diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs index 20494829ae..272f9566d5 100644 --- a/osu.Game/Screens/Select/ImportFromStablePopup.cs +++ b/osu.Game/Screens/Select/ImportFromStablePopup.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Select public ImportFromStablePopup(Action importFromStable) { HeaderText = @"You have no beatmaps!"; - BodyText = "An existing copy of osu! was found, though.\nWould you like to import your beatmaps, skins and scores?"; + BodyText = "An existing copy of osu! was found, though.\nWould you like to import your beatmaps, skins and scores?\nThis will create a second copy of all files on disk."; Icon = FontAwesome.Solid.Plane; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 1b45a9d270..8e85eb4eb2 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -60,6 +60,8 @@ namespace osu.Game.Screens.Select.Leaderboards private UserTopScoreContainer topScoreContainer; + private IBindable> itemRemoved; + /// /// Whether to apply the game's currently selected mods as a filter when retrieving scores. /// @@ -103,6 +105,9 @@ namespace osu.Game.Screens.Select.Leaderboards { ScoreSelected = s => ScoreSelected?.Invoke(s) }); + + itemRemoved = scoreManager.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(onScoreRemoved); } protected override void Reset() @@ -111,6 +116,8 @@ namespace osu.Game.Screens.Select.Leaderboards TopScore = null; } + private void onScoreRemoved(ValueChangedEvent> score) => Schedule(RefreshScores); + protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest FetchScores(Action> scoresCallback) diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs new file mode 100644 index 0000000000..085ea372c0 --- /dev/null +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.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.Game.Overlays.Dialog; +using osu.Game.Scoring; +using System.Diagnostics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; + +namespace osu.Game.Screens.Select +{ + public class LocalScoreDeleteDialog : PopupDialog + { + 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() + { + BeatmapInfo beatmap = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); + Debug.Assert(beatmap != null); + + BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; + + Icon = FontAwesome.Regular.TrashAlt; + HeaderText = "Confirm deletion of local score"; + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Yes. Please.", + Action = () => scoreManager?.Delete(score) + }, + new PopupDialogCancelButton + { + Text = "No, I'm still attached.", + }, + }; + } + } +} diff --git a/osu.Game/Screens/Select/MatchSongSelect.cs b/osu.Game/Screens/Select/MatchSongSelect.cs index c5fa9e2396..2f3674642e 100644 --- a/osu.Game/Screens/Select/MatchSongSelect.cs +++ b/osu.Game/Screens/Select/MatchSongSelect.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,8 +10,8 @@ using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Multi; +using osu.Game.Screens.Multi.Components; namespace osu.Game.Screens.Select { @@ -21,8 +22,10 @@ namespace osu.Game.Screens.Select public string ShortTitle => "song selection"; public override string Title => ShortTitle.Humanize(); - [Resolved(typeof(Room))] - protected Bindable CurrentItem { get; private set; } + public override bool AllowEditing => false; + + [Resolved(typeof(Room), nameof(Room.Playlist))] + protected BindableList Playlist { get; private set; } [Resolved] private BeatmapManager beatmaps { get; set; } @@ -32,51 +35,48 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; } + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea + { + CreateNewItem = createNewItem + }; + protected override bool OnStart() { - var item = new PlaylistItem + switch (Playlist.Count) { - Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = Ruleset.Value, - RulesetID = Ruleset.Value.ID ?? 0 - }; + case 0: + createNewItem(); + break; - item.RequiredMods.AddRange(Mods.Value); + case 1: + populateItemFromCurrent(Playlist.Single()); + break; + } - Selected?.Invoke(item); - - if (this.IsCurrentScreen()) - this.Exit(); + this.Exit(); return true; } - public override bool OnExiting(IScreen next) + private void createNewItem() { - if (base.OnExiting(next)) - return true; - - if (CurrentItem.Value != null) + PlaylistItem item = new PlaylistItem { - Ruleset.Value = CurrentItem.Value.Ruleset; - Beatmap.Value = beatmaps.GetWorkingBeatmap(CurrentItem.Value.Beatmap); - Mods.Value = CurrentItem.Value.RequiredMods?.ToArray() ?? Array.Empty(); - } + ID = Playlist.Count == 0 ? 0 : Playlist.Max(p => p.ID) + 1 + }; - Beatmap.Disabled = true; - Ruleset.Disabled = true; - Mods.Disabled = true; + populateItemFromCurrent(item); - return false; + Playlist.Add(item); } - public override void OnEntering(IScreen last) + private void populateItemFromCurrent(PlaylistItem item) { - base.OnEntering(last); + item.Beatmap.Value = Beatmap.Value.BeatmapInfo; + item.Ruleset.Value = Ruleset.Value; - Beatmap.Disabled = false; - Ruleset.Disabled = false; - Mods.Disabled = false; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(Mods.Value); } } } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index ff9beafb23..4e4653cb57 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -60,10 +60,10 @@ namespace osu.Game.Screens.Select.Options return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { flash.FadeTo(0, 1000, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs new file mode 100644 index 0000000000..d719502a4f --- /dev/null +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -0,0 +1,143 @@ +// Copyright (c) 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.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Screens.Select.Leaderboards; + +namespace osu.Game.Screens.Select +{ + public class PlayBeatmapDetailArea : BeatmapDetailArea + { + public readonly BeatmapLeaderboard Leaderboard; + + public override WorkingBeatmap Beatmap + { + get => base.Beatmap; + set + { + base.Beatmap = value; + + Leaderboard.Beatmap = value is DummyWorkingBeatmap ? null : value?.BeatmapInfo; + } + } + + private Bindable selectedTab; + + public PlayBeatmapDetailArea() + { + Add(Leaderboard = new BeatmapLeaderboard { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); + selectedTab.BindValueChanged(tab => CurrentTab.Value = getTabItemFromTabType(tab.NewValue), true); + CurrentTab.BindValueChanged(tab => selectedTab.Value = getTabTypeFromTabItem(tab.NewValue)); + } + + public override void Refresh() + { + base.Refresh(); + + Leaderboard.RefreshScores(); + } + + protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + base.OnTabChanged(tab, selectedMods); + + Leaderboard.FilterMods = selectedMods; + + switch (tab) + { + case BeatmapDetailAreaLeaderboardTabItem leaderboard: + Leaderboard.Scope = leaderboard.Scope; + Leaderboard.Show(); + break; + + default: + Leaderboard.Hide(); + break; + } + } + + protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Concat(new BeatmapDetailAreaTabItem[] + { + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend), + }).ToArray(); + + private BeatmapDetailAreaTabItem getTabItemFromTabType(TabType type) + { + switch (type) + { + case TabType.Details: + return new BeatmapDetailAreaDetailTabItem(); + + case TabType.Local: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local); + + case TabType.Country: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); + + case TabType.Global: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global); + + case TabType.Friends: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); + + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private TabType getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) + { + switch (item) + { + case BeatmapDetailAreaDetailTabItem _: + return TabType.Details; + + case BeatmapDetailAreaLeaderboardTabItem leaderboardTab: + switch (leaderboardTab.Scope) + { + case BeatmapLeaderboardScope.Local: + return TabType.Local; + + case BeatmapLeaderboardScope.Country: + return TabType.Country; + + case BeatmapLeaderboardScope.Global: + return TabType.Global; + + case BeatmapLeaderboardScope.Friend: + return TabType.Friends; + + default: + throw new ArgumentOutOfRangeException(nameof(item)); + } + + default: + throw new ArgumentOutOfRangeException(nameof(item)); + } + } + + public enum TabType + { + Details, + Local, + Country, + Global, + Friends + } + } +} diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 9368bac69f..2236aa4d72 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -4,9 +4,14 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Users; using osuTK.Input; @@ -17,6 +22,9 @@ namespace osu.Game.Screens.Select private bool removeAutoModOnResume; private OsuScreen player; + [Resolved(CanBeNull = true)] + private NotificationOverlay notifications { get; set; } + public override bool AllowExternalScreenChange => true; protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); @@ -29,20 +37,45 @@ namespace osu.Game.Screens.Select ValidForResume = false; Edit(); }, Key.Number4); + + ((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore; } + protected void PresentScore(ScoreInfo score) => + FinaliseSelection(score.Beatmap, score.Ruleset, () => this.Push(new SoloResultsScreen(score))); + + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + public override void OnResuming(IScreen last) { + base.OnResuming(last); + player = null; if (removeAutoModOnResume) { - var autoType = Ruleset.Value.CreateInstance().GetAutoplayMod().GetType(); - ModSelect.DeselectTypes(new[] { autoType }, true); + var autoType = Ruleset.Value.CreateInstance().GetAutoplayMod()?.GetType(); + + if (autoType != null) + ModSelect.DeselectTypes(new[] { autoType }, true); + removeAutoModOnResume = false; } + } - base.OnResuming(last); + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Enter: + case Key.KeypadEnter: + // this is a special hard-coded case; we can't rely on OnPressed (of SongSelect) as GlobalActionContainer is + // matching with exact modifier consideration (so Ctrl+Enter would be ignored). + FinaliseSelection(); + return true; + } + + return base.OnKeyDown(e); } protected override bool OnStart() @@ -53,10 +86,19 @@ namespace osu.Game.Screens.Select if (GetContainingInputManager().CurrentState?.Keyboard.ControlPressed == true) { var auto = Ruleset.Value.CreateInstance().GetAutoplayMod(); - var autoType = auto.GetType(); + var autoType = auto?.GetType(); var mods = Mods.Value; + if (autoType == null) + { + notifications?.Post(new SimpleNotification + { + Text = "The current ruleset doesn't have an autoplay mod avalaible!" + }); + return false; + } + if (mods.All(m => m.GetType() != autoType)) { Mods.Value = mods.Append(auto).ToArray(); @@ -64,14 +106,9 @@ namespace osu.Game.Screens.Select } } - Beatmap.Value.Track.Looping = false; - SampleConfirm?.Play(); - LoadComponentAsync(player = new PlayerLoader(() => new Player()), l => - { - if (this.IsCurrentScreen()) this.Push(player); - }); + this.Push(player = new PlayerLoader(() => new Player())); return true; } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 8f7ad2022d..74a5ee8309 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -23,7 +23,6 @@ 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; @@ -35,13 +34,14 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; namespace osu.Game.Screens.Select { public abstract class SongSelect : OsuScreen, IKeyBindingHandler { - public static readonly Vector2 WEDGED_CONTAINER_SIZE = new Vector2(0.5f, 245); + public static readonly float WEDGE_HEIGHT = 245; protected const float BACKGROUND_BLUR = 20; private const float left_area_padding = 20; @@ -66,13 +66,25 @@ namespace osu.Game.Screens.Select /// protected Container FooterPanels { get; private set; } + /// + /// Whether entering editor mode should be allowed. + /// + public virtual bool AllowEditing => true; + + [Resolved] + private Bindable> selectedMods { get; set; } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); protected BeatmapCarousel Carousel { get; private set; } + private readonly DifficultyRecommender recommender = new DifficultyRecommender(); + private BeatmapInfoWedge beatmapInfoWedge; private DialogOverlay dialogOverlay; - private BeatmapManager beatmaps; + + [Resolved] + private BeatmapManager beatmaps { get; set; } protected ModSelectOverlay ModSelect { get; private set; } @@ -81,6 +93,8 @@ namespace osu.Game.Screens.Select private SampleChannel sampleChangeDifficulty; private SampleChannel sampleChangeBeatmap; + private Container carouselContainer; + protected BeatmapDetailArea BeatmapDetails { get; private set; } private readonly Bindable decoupledRuleset = new Bindable(); @@ -89,126 +103,164 @@ namespace osu.Game.Screens.Select private MusicController music { get; set; } [BackgroundDependencyLoader(true)] - private void load(BeatmapManager beatmaps, AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores) + private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores) { // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); + LoadComponentAsync(Carousel = new BeatmapCarousel + { + AllowSelection = false, // delay any selection until our bindables are ready to make a good choice. + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + BleedTop = FilterControl.HEIGHT, + BleedBottom = Footer.HEIGHT, + SelectionChanged = updateSelectedBeatmap, + BeatmapSetsChanged = carouselBeatmapsLoaded, + GetRecommendedBeatmap = recommender.GetRecommendedBeatmap, + }, c => carouselContainer.Child = c); + AddRangeInternal(new Drawable[] { - new ParallaxContainer - { - Masking = true, - ParallaxAmount = 0.005f, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new WedgeBackground - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = -150 }, - Size = new Vector2(WEDGED_CONTAINER_SIZE.X, 1), - } - } - }, - new Container - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(WEDGED_CONTAINER_SIZE.X, 1), - Padding = new MarginPadding - { - Bottom = Footer.HEIGHT, - Top = WEDGED_CONTAINER_SIZE.Y + left_area_padding, - Left = left_area_padding, - Right = left_area_padding * 2, - }, - Child = BeatmapDetails = new BeatmapDetailArea - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 10, Right = 5 }, - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 2, //avoid horizontal masking so the panels don't clip when screen stack is pushed. - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Top = FilterControl.HEIGHT, - Bottom = Footer.HEIGHT - }, - Child = Carousel = new BeatmapCarousel - { - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1 - WEDGED_CONTAINER_SIZE.X, 1), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - SelectionChanged = updateSelectedBeatmap, - BeatmapSetsChanged = carouselBeatmapsLoaded, - }, - }, - FilterControl = new FilterControl - { - RelativeSizeAxes = Axes.X, - Height = FilterControl.HEIGHT, - FilterChanged = ApplyFilterToCarousel, - Background = { Width = 2 }, - }, - } - }, - }, - beatmapInfoWedge = new BeatmapInfoWedge - { - Size = WEDGED_CONTAINER_SIZE, - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding - { - Top = left_area_padding, - Right = left_area_padding, - }, - }, + recommender, new ResetScrollContainer(() => Carousel.ScrollToSelected()) { RelativeSizeAxes = Axes.Y, Width = 250, - } + }, + new VerticalMaskingContainer + { + Children = new Drawable[] + { + new GridContainer // used for max width implementation + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 850), + }, + Content = new[] + { + new Drawable[] + { + new ParallaxContainer + { + ParallaxAmount = 0.005f, + RelativeSizeAxes = Axes.Both, + Child = new WedgeBackground + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -150 }, + }, + }, + carouselContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Top = FilterControl.HEIGHT, + Bottom = Footer.HEIGHT + }, + Child = new LoadingSpinner(true) { State = { Value = Visibility.Visible } } + } + }, + } + }, + FilterControl = new FilterControl + { + RelativeSizeAxes = Axes.X, + Height = FilterControl.HEIGHT, + FilterChanged = ApplyFilterToCarousel, + }, + new GridContainer // used for max width implementation + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 650), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + + Children = new Drawable[] + { + beatmapInfoWedge = new BeatmapInfoWedge + { + Height = WEDGE_HEIGHT, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding + { + Top = left_area_padding, + Right = left_area_padding, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Bottom = Footer.HEIGHT, + Top = WEDGE_HEIGHT + left_area_padding, + Left = left_area_padding, + Right = left_area_padding * 2, + }, + Child = BeatmapDetails = CreateBeatmapDetailArea().With(d => + { + d.RelativeSizeAxes = Axes.Both; + d.Padding = new MarginPadding { Top = 10, Right = 5 }; + }) + }, + } + }, + }, + } + } + } + }, }); if (ShowFooter) { - AddRangeInternal(new[] + AddRangeInternal(new Drawable[] { - FooterPanels = new Container + new GridContainer // used for max height implementation { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Bottom = Footer.HEIGHT }, - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - BeatmapOptions = new BeatmapOptionsOverlay(), - ModSelect = new ModSelectOverlay + new Dimension(), + new Dimension(GridSizeMode.Relative, 1f, maxSize: ModSelectOverlay.HEIGHT + Footer.HEIGHT), + }, + Content = new[] + { + null, + new Drawable[] { - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, + FooterPanels = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = Footer.HEIGHT }, + Children = new Drawable[] + { + BeatmapOptions = new BeatmapOptionsOverlay(), + ModSelect = new ModSelectOverlay + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + } + } + } } } }, @@ -216,8 +268,6 @@ namespace osu.Game.Screens.Select }); } - BeatmapDetails.Leaderboard.ScoreSelected += score => this.Push(new SoloResults(score)); - if (Footer != null) { Footer.AddButton(new FooterButtonMods { Current = Mods }, ModSelect); @@ -229,14 +279,6 @@ namespace osu.Game.Screens.Select BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo), Key.Number3); } - if (this.beatmaps == null) - this.beatmaps = beatmaps; - - this.beatmaps.ItemAdded += onBeatmapSetAdded; - this.beatmaps.ItemRemoved += onBeatmapSetRemoved; - this.beatmaps.BeatmapHidden += onBeatmapHidden; - this.beatmaps.BeatmapRestored += onBeatmapRestored; - dialogOverlay = dialog; sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); @@ -248,7 +290,7 @@ namespace osu.Game.Screens.Select Schedule(() => { // if we have no beatmaps but osu-stable is found, let's prompt the user to import. - if (!beatmaps.GetAllUsableBeatmapSetsEnumerable().Any() && beatmaps.StableInstallationAvailable) + if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && beatmaps.StableInstallationAvailable) { dialogOverlay.Push(new ImportFromStablePopup(() => { @@ -265,7 +307,7 @@ namespace osu.Game.Screens.Select // if not the current screen, we want to get carousel in a good presentation state before displaying (resume or enter). bool shouldDebounce = this.IsCurrentScreen(); - Schedule(() => Carousel.Filter(criteria, shouldDebounce)); + Carousel.Filter(criteria, shouldDebounce); } private DependencyContainer dependencies; @@ -281,8 +323,16 @@ namespace osu.Game.Screens.Select return dependencies; } + /// + /// Creates the beatmap details to be displayed underneath the wedge. + /// + protected abstract BeatmapDetailArea CreateBeatmapDetailArea(); + public void Edit(BeatmapInfo beatmap = null) { + if (!AllowEditing) + throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled"); + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap ?? beatmapNoDebounce); this.Push(new Editor()); } @@ -291,15 +341,21 @@ namespace osu.Game.Screens.Select /// Call to make a selection and perform the default action for this SongSelect. /// /// An optional beatmap to override the current carousel selection. - /// Whether to trigger . - public void FinaliseSelection(BeatmapInfo beatmap = null, bool performStartAction = true) + /// An optional ruleset to override the current carousel selection. + /// An optional custom action to perform instead of . + public void FinaliseSelection(BeatmapInfo beatmap = null, RulesetInfo ruleset = null, Action customStartAction = null) { // This is very important as we have not yet bound to screen-level bindables before the carousel load is completed. if (!Carousel.BeatmapSetsLoaded) return; - // if we have a pending filter operation, we want to run it now. - // it could change selection (ie. if the ruleset has been changed). + if (ruleset != null) + Ruleset.Value = ruleset; + + transferRulesetValue(); + + // while transferRulesetValue will flush, it only does so if the ruleset changes. + // the user could have changed a filter, and we want to ensure we are 100% up-to-date and consistent here. Carousel.FlushPendingFilterOperations(); // avoid attempting to continue before a selection has been obtained. @@ -316,8 +372,13 @@ namespace osu.Game.Screens.Select selectionChangedDebounce = null; } - if (performStartAction) - OnStart(); + if (customStartAction != null) + { + customStartAction(); + Carousel.AllowSelection = false; + } + else if (OnStart()) + Carousel.AllowSelection = false; } /// @@ -330,16 +391,29 @@ namespace osu.Game.Screens.Select private void workingBeatmapChanged(ValueChangedEvent e) { - if (e.NewValue is DummyWorkingBeatmap) return; + if (e.NewValue is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - if (this.IsCurrentScreen() && !Carousel.SelectBeatmap(e.NewValue?.BeatmapInfo, false)) + Logger.Log($"working beatmap updated to {e.NewValue}"); + + if (!Carousel.SelectBeatmap(e.NewValue.BeatmapInfo, false)) { - // If selecting new beatmap without bypassing filters failed, there's possibly a ruleset mismatch - if (e.NewValue?.BeatmapInfo?.Ruleset != null && !e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) + // 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 != null && !e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) { Ruleset.Value = e.NewValue.BeatmapInfo.Ruleset; - Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); + transferRulesetValue(); } + + // Even if a ruleset mismatch was not the cause (ie. a text filter is applied), + // 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; + Carousel.Filter(criteria); + + Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); } } @@ -367,7 +441,7 @@ namespace osu.Game.Screens.Select } /// - /// selection has been changed as the result of a user interaction. + /// Selection has been changed as the result of a user interaction. /// private void performUpdateSelected() { @@ -376,7 +450,7 @@ namespace osu.Game.Screens.Select selectionChangedDebounce?.Cancel(); - if (beatmap == null) + if (beatmapNoDebounce == null) run(); else selectionChangedDebounce = Scheduler.AddDelayed(run, 200); @@ -385,21 +459,15 @@ namespace osu.Game.Screens.Select { Logger.Log($"updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ID.ToString() ?? "null"}"); - if (ruleset?.Equals(decoupledRuleset.Value) == false) + if (transferRulesetValue()) { - Logger.Log($"ruleset changed from \"{decoupledRuleset.Value}\" to \"{ruleset}\""); - Mods.Value = Array.Empty(); - decoupledRuleset.Value = ruleset; - // force a filter before attempting to change the beatmap. - // we may still be in the wrong ruleset as there is a debounce delay on ruleset changes. - Carousel.Filter(null, false); - - // Filtering only completes after the carousel runs Update. - // If we also have a pending beatmap change we should delay it one frame. - selectionChangedDebounce = Schedule(run); - return; + // transferRulesetValue() may trigger a refilter. 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). + if (beatmap != null && !Carousel.SelectBeatmap(beatmap, false)) + beatmap = null; } // We may be arriving here due to another component changing the bindable Beatmap. @@ -423,7 +491,7 @@ namespace osu.Game.Screens.Select if (this.IsCurrentScreen()) ensurePlayingSelected(); - UpdateBeatmap(Beatmap.Value); + updateComponentFromBeatmap(Beatmap.Value); } } @@ -441,6 +509,8 @@ namespace osu.Game.Screens.Select this.FadeInFromZero(250); FilterControl.Activate(); + + ModSelect.SelectedMods.BindTo(selectedMods); } private const double logo_transition = 250; @@ -481,21 +551,27 @@ namespace osu.Game.Screens.Select public override void OnResuming(IScreen last) { - BeatmapDetails.Leaderboard.RefreshScores(); + base.OnResuming(last); + + // required due to https://github.com/ppy/osu-framework/issues/3218 + ModSelect.SelectedMods.Disabled = false; + ModSelect.SelectedMods.BindTo(selectedMods); + + Carousel.AllowSelection = true; + + BeatmapDetails.Refresh(); Beatmap.Value.Track.Looping = true; music?.ResetTrackAdjustments(); if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending) { - UpdateBeatmap(Beatmap.Value); + updateComponentFromBeatmap(Beatmap.Value); // restart playback on returning to song select, regardless. music?.Play(); } - base.OnResuming(last); - this.FadeIn(250); this.ScaleTo(1, 250, Easing.OutSine); @@ -505,10 +581,14 @@ namespace osu.Game.Screens.Select public override void OnSuspending(IScreen next) { + ModSelect.SelectedMods.UnbindFrom(selectedMods); ModSelect.Hide(); BeatmapOptions.Hide(); + if (Beatmap.Value.Track != null) + Beatmap.Value.Track.Looping = false; + this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); @@ -519,12 +599,6 @@ namespace osu.Game.Screens.Select public override bool OnExiting(IScreen next) { - if (ModSelect.State.Value == Visibility.Visible) - { - ModSelect.Hide(); - return true; - } - if (base.OnExiting(next)) return true; @@ -540,19 +614,22 @@ namespace osu.Game.Screens.Select return false; } + public override bool OnBackButton() + { + if (ModSelect.State.Value == Visibility.Visible) + { + ModSelect.Hide(); + return true; + } + + return false; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); decoupledRuleset.UnbindAll(); - - if (beatmaps != null) - { - beatmaps.ItemAdded -= onBeatmapSetAdded; - beatmaps.ItemRemoved -= onBeatmapSetRemoved; - beatmaps.BeatmapHidden -= onBeatmapHidden; - beatmaps.BeatmapRestored -= onBeatmapRestored; - } } /// @@ -560,10 +637,8 @@ namespace osu.Game.Screens.Select /// This is a debounced call (unlike directly binding to WorkingBeatmap.ValueChanged). /// /// The working beatmap. - protected virtual void UpdateBeatmap(WorkingBeatmap beatmap) + private void updateComponentFromBeatmap(WorkingBeatmap beatmap) { - Logger.Log($"working beatmap updated to {beatmap}"); - if (Background is BackgroundScreenBeatmap backgroundModeBeatmap) { backgroundModeBeatmap.Beatmap = beatmap; @@ -599,23 +674,28 @@ namespace osu.Game.Screens.Select lastTrack.SetTarget(track); } - private void onBeatmapSetAdded(BeatmapSetInfo s) => Carousel.UpdateBeatmapSet(s); - private void onBeatmapSetRemoved(BeatmapSetInfo s) => Carousel.RemoveBeatmapSet(s); - private void onBeatmapRestored(BeatmapInfo b) => Carousel.UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); - private void onBeatmapHidden(BeatmapInfo b) => Carousel.UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); - private void carouselBeatmapsLoaded() { bindBindables(); + Carousel.AllowSelection = true; + // If a selection was already obtained, do not attempt to update the selected beatmap. if (Carousel.SelectedBeatmapSet != null) return; // Attempt to select the current beatmap on the carousel, if it is valid to be selected. - if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false - && Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo, false)) - return; + if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false) + { + if (Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo, false)) + return; + + // prefer not changing ruleset at this point, so look for another difficulty in the currently playing beatmap + var found = Beatmap.Value.BeatmapSetInfo.Beatmaps.FirstOrDefault(b => b.Ruleset.Equals(decoupledRuleset.Value)); + + if (found != null && Carousel.SelectBeatmap(found, false)) + return; + } // If the current active beatmap could not be selected, select a new random beatmap. if (!Carousel.SelectNextRandom()) @@ -635,20 +715,34 @@ namespace osu.Game.Screens.Select // manual binding to parent ruleset to allow for delayed load in the incoming direction. transferRulesetValue(); + Ruleset.ValueChanged += r => updateSelectedRuleset(r.NewValue); decoupledRuleset.ValueChanged += r => Ruleset.Value = r.NewValue; decoupledRuleset.DisabledChanged += r => Ruleset.Disabled = r; - Beatmap.BindDisabledChanged(disabled => Carousel.AllowSelection = !disabled, true); Beatmap.BindValueChanged(workingBeatmapChanged); boundLocalBindables = true; } - private void transferRulesetValue() + /// + /// Transfer the game-wide ruleset to the local decoupled ruleset. + /// Will immediately run filter operations if required. + /// + /// Whether a transfer occurred. + private bool transferRulesetValue() { + if (decoupledRuleset.Value?.Equals(Ruleset.Value) == true) + return false; + + Logger.Log($"decoupled ruleset transferred (\"{decoupledRuleset.Value}\" -> \"{Ruleset.Value}\")"); rulesetNoDebounce = decoupledRuleset.Value = Ruleset.Value; + + // 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(); + return true; } private void delete(BeatmapSetInfo beatmap) @@ -664,7 +758,7 @@ namespace osu.Game.Screens.Select dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmap, () => // schedule done here rather than inside the dialog as the dialog may fade out and never callback. - Schedule(() => BeatmapDetails.Leaderboard.RefreshScores()))); + Schedule(() => BeatmapDetails.Refresh()))); } public virtual bool OnPressed(GlobalAction action) @@ -681,7 +775,9 @@ namespace osu.Game.Screens.Select return false; } - public bool OnReleased(GlobalAction action) => action == GlobalAction.Select; + public void OnReleased(GlobalAction action) + { + } protected override bool OnKeyDown(KeyDownEvent e) { @@ -703,6 +799,29 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } + private class VerticalMaskingContainer : Container + { + private const float panel_overflow = 1.2f; + + protected override Container Content { get; } + + public VerticalMaskingContainer() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Width = panel_overflow; // avoid horizontal masking so the panels don't clip when screen stack is pushed. + InternalChild = Content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 1 / panel_overflow, + }; + } + } + private class ResetScrollContainer : Container { private readonly Action onHoverAction; diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 1929a7e5d2..78d3a37f7c 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -20,7 +20,7 @@ namespace osu.Game.Skinning new Color4(242, 24, 57, 255) ); - Configuration.LegacyVersion = 2.0m; + Configuration.LegacyVersion = 2.7m; } public static SkinInfo Info { get; } = new SkinInfo diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 2a065ea3d7..61d0112c89 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; 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 osuTK.Graphics; @@ -21,7 +22,7 @@ namespace osu.Game.Skinning public override Drawable GetDrawableComponent(ISkinComponent component) => null; - public override Texture GetTexture(string componentName) => null; + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public override SampleChannel GetSample(ISampleInfo sampleInfo) => null; @@ -31,10 +32,10 @@ namespace osu.Game.Skinning { // todo: this code is pulled from LegacySkin and should not exist. // will likely change based on how databased storage of skin configuration goes. - case GlobalSkinConfiguration global: + case GlobalSkinColours global: switch (global) { - case GlobalSkinConfiguration.ComboColours: + case GlobalSkinColours.ComboColours: return SkinUtils.As(new Bindable>(Configuration.ComboColours)); } diff --git a/osu.Game/Skinning/GlobalSkinColour.cs b/osu.Game/Skinning/GlobalSkinColours.cs similarity index 79% rename from osu.Game/Skinning/GlobalSkinColour.cs rename to osu.Game/Skinning/GlobalSkinColours.cs index d039be98ce..f889371b98 100644 --- a/osu.Game/Skinning/GlobalSkinColour.cs +++ b/osu.Game/Skinning/GlobalSkinColours.cs @@ -3,8 +3,9 @@ namespace osu.Game.Skinning { - public enum GlobalSkinColour + public enum GlobalSkinColours { + ComboColours, MenuGlow } } diff --git a/osu.Game/Skinning/GlobalSkinConfiguration.cs b/osu.Game/Skinning/GlobalSkinConfiguration.cs index 66dc9a9395..d405702ea5 100644 --- a/osu.Game/Skinning/GlobalSkinConfiguration.cs +++ b/osu.Game/Skinning/GlobalSkinConfiguration.cs @@ -5,6 +5,7 @@ namespace osu.Game.Skinning { public enum GlobalSkinConfiguration { - ComboColours + AnimationFramerate, + LayeredHitSounds, } } diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs new file mode 100644 index 0000000000..7e52bb8176 --- /dev/null +++ b/osu.Game/Skinning/IAnimationTimeReference.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.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Timing; + +namespace osu.Game.Skinning +{ + /// + /// Denotes an object which provides a reference time to start animations from. + /// + /// + /// This should not be used to start an animation immediately at the current time. + /// To do so, use with startAtCurrentTime = true instead. + /// + [Cached] + public interface IAnimationTimeReference + { + /// + /// The reference clock. + /// + IFrameBasedClock Clock { get; } + + /// + /// The time which animations should be started from, relative to . + /// + double AnimationStartTime { get; } + } +} diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index cb2a379b8e..5abd963773 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -5,6 +5,7 @@ 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; @@ -29,7 +30,17 @@ namespace osu.Game.Skinning /// The requested texture. /// A matching texture, or null if unavailable. [CanBeNull] - Texture GetTexture(string componentName); + Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + /// + /// Retrieve a . + /// + /// The requested texture. + /// The texture wrap mode in horizontal direction. + /// The texture wrap mode in vertical direction. + /// A matching texture, or null if unavailable. + [CanBeNull] + Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); /// /// Retrieve a . diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index fa7e895a28..87bca856a3 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -2,13 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.IO.Stores; +using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects.Legacy; namespace osu.Game.Skinning { public class LegacyBeatmapSkin : LegacySkin { + protected override bool AllowManiaSkin => false; + public LegacyBeatmapSkin(BeatmapInfo beatmap, IResourceStore storage, AudioManager audioManager) : base(createSkinInfo(beatmap), new LegacySkinResourceStore(beatmap.BeatmapSet, storage), audioManager, beatmap.Path) { @@ -16,6 +22,33 @@ namespace osu.Game.Skinning Configuration.AllowDefaultComboColoursFallback = false; } + public override IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case LegacySkinConfiguration.LegacySetting s when s == LegacySkinConfiguration.LegacySetting.Version: + // For lookup simplicity, ignore beatmap-level versioning completely. + + // If it is decided that we need this due to beatmaps somehow using it, the default (1.0 specified in LegacySkinDecoder.CreateTemplateObject) + // needs to be removed else it will cause incorrect skin behaviours. This is due to the config lookup having no context of which skin + // it should be returning the version for. + return null; + } + + return base.GetConfig(lookup); + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) + { + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) + { + // When no custom sample bank is provided, always fall-back to the default samples. + return null; + } + + return base.GetSample(sampleInfo); + } + private static SkinInfo createSkinInfo(BeatmapInfo beatmap) => new SkinInfo { Name = beatmap.ToString(), Creator = beatmap.Metadata.Author.ToString() }; } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs new file mode 100644 index 0000000000..af7d6007f3 --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.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; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps.Formats; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinConfiguration : IHasCustomColours + { + /// + /// Conversion factor from converting legacy positioning values (based in x480 dimensions) to x768. + /// + public const float POSITION_SCALE_FACTOR = 1.6f; + + /// + /// Size of a legacy column in the default skin, used for determining relative scale factors. + /// + public const float DEFAULT_COLUMN_SIZE = 30 * POSITION_SCALE_FACTOR; + + public readonly int Keys; + + public Dictionary CustomColours { get; set; } = new Dictionary(); + + public Dictionary ImageLookups = new Dictionary(); + + public readonly float[] ColumnLineWidth; + public readonly float[] ColumnSpacing; + public readonly float[] ColumnWidth; + public readonly float[] ExplosionWidth; + + public float HitPosition = (480 - 402) * POSITION_SCALE_FACTOR; + public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; + public bool ShowJudgementLine = true; + + public LegacyManiaSkinConfiguration(int keys) + { + Keys = keys; + + ColumnLineWidth = new float[keys + 1]; + ColumnSpacing = new float[keys - 1]; + ColumnWidth = new float[keys]; + ExplosionWidth = new float[keys]; + + ColumnLineWidth.AsSpan().Fill(2); + ColumnWidth.AsSpan().Fill(DEFAULT_COLUMN_SIZE); + } + + private float? minimumColumnWidth; + + public float MinimumColumnWidth + { + get => minimumColumnWidth ?? ColumnWidth.Min(); + set => minimumColumnWidth = value; + } + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs new file mode 100644 index 0000000000..4990ca8e60 --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.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. + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinConfigurationLookup + { + public readonly int Keys; + public readonly LegacyManiaSkinConfigurationLookups Lookup; + public readonly int? TargetColumn; + + public LegacyManiaSkinConfigurationLookup(int keys, LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + { + Keys = keys; + Lookup = lookup; + TargetColumn = targetColumn; + } + } + + public enum LegacyManiaSkinConfigurationLookups + { + ColumnWidth, + ColumnSpacing, + LightImage, + LeftLineWidth, + RightLineWidth, + HitPosition, + LightPosition, + HitTargetImage, + ShowJudgementLine, + KeyImage, + KeyImageDown, + NoteImage, + HoldNoteHeadImage, + HoldNoteTailImage, + HoldNoteBodyImage, + ExplosionImage, + ExplosionScale, + ColumnLineColour, + JudgementLineColour, + ColumnBackgroundColour, + ColumnLightColour, + MinimumColumnWidth, + LeftStageImage, + RightStageImage, + BottomStageImage, + Hit300g, + Hit300, + Hit200, + Hit100, + Hit50, + Hit0, + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs new file mode 100644 index 0000000000..aebc229f7c --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using osu.Game.Beatmaps.Formats; + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinDecoder : LegacyDecoder> + { + public LegacyManiaSkinDecoder() + : base(1) + { + } + + private readonly List pendingLines = new List(); + private LegacyManiaSkinConfiguration currentConfig; + + protected override void OnBeginNewSection(Section section) + { + base.OnBeginNewSection(section); + + // If a new section is reached with pending lines remaining, they can all be discarded as there isn't a valid configuration to parse them into. + pendingLines.Clear(); + currentConfig = null; + } + + protected override void ParseLine(List output, Section section, string line) + { + line = StripComments(line); + + switch (section) + { + case Section.Mania: + var pair = SplitKeyVal(line); + + switch (pair.Key) + { + case "Keys": + currentConfig = new LegacyManiaSkinConfiguration(int.Parse(pair.Value, CultureInfo.InvariantCulture)); + + // Silently ignore duplicate configurations. + if (output.All(c => c.Keys != currentConfig.Keys)) + output.Add(currentConfig); + + // All existing lines can be flushed now that we have a valid configuration. + flushPendingLines(); + break; + + default: + pendingLines.Add(line); + + // Hold all lines until a "Keys" item is found. + if (currentConfig != null) + flushPendingLines(); + break; + } + + break; + } + } + + private void flushPendingLines() + { + Debug.Assert(currentConfig != null); + + foreach (var line in pendingLines) + { + var pair = SplitKeyVal(line); + + switch (pair.Key) + { + case "ColumnLineWidth": + parseArrayValue(pair.Value, currentConfig.ColumnLineWidth, false); + break; + + case "ColumnSpacing": + parseArrayValue(pair.Value, currentConfig.ColumnSpacing); + break; + + case "ColumnWidth": + parseArrayValue(pair.Value, currentConfig.ColumnWidth); + break; + + case "HitPosition": + currentConfig.HitPosition = (480 - float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + + case "LightPosition": + currentConfig.LightPosition = (480 - float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + + case "JudgementLine": + currentConfig.ShowJudgementLine = pair.Value == "1"; + break; + + case "LightingNWidth": + parseArrayValue(pair.Value, currentConfig.ExplosionWidth); + break; + + case "WidthForNoteHeightScale": + float minWidth = float.Parse(pair.Value, CultureInfo.InvariantCulture) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + if (minWidth > 0) + currentConfig.MinimumColumnWidth = minWidth; + break; + + case string _ when pair.Key.StartsWith("Colour"): + HandleColours(currentConfig, line); + break; + + // Custom sprite paths + case string _ when pair.Key.StartsWith("NoteImage"): + case string _ when pair.Key.StartsWith("KeyImage"): + case string _ when pair.Key.StartsWith("Hit"): + case string _ when pair.Key.StartsWith("Stage"): + currentConfig.ImageLookups[pair.Key] = pair.Value; + break; + } + } + + pendingLines.Clear(); + } + + private void parseArrayValue(string value, float[] output, bool applyScaleFactor = true) + { + string[] values = value.Split(','); + + for (int i = 0; i < values.Length; i++) + { + if (i >= output.Length) + break; + + output[i] = float.Parse(values[i], CultureInfo.InvariantCulture) * (applyScaleFactor ? LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR : 1); + } + } + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 671d37fda4..3bbeff9918 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.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.Diagnostics; using System.IO; using System.Linq; using JetBrains.Annotations; @@ -9,9 +11,11 @@ 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.Game.Audio; +using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Scoring; using osuTK.Graphics; @@ -26,12 +30,22 @@ namespace osu.Game.Skinning [CanBeNull] protected IResourceStore Samples; + /// + /// Whether texture for the keys exists. + /// Used to determine if the mania ruleset is skinned. + /// + private readonly Lazy hasKeyTexture; + + protected virtual bool AllowManiaSkin => hasKeyTexture.Value; + public new LegacySkinConfiguration Configuration { get => base.Configuration as LegacySkinConfiguration; set => base.Configuration = value; } + private readonly Dictionary maniaConfigurations = new Dictionary(); + public LegacySkin(SkinInfo skin, IResourceStore storage, AudioManager audioManager) : this(skin, new LegacySkinResourceStore(skin, storage), audioManager, "skin.ini") { @@ -40,21 +54,43 @@ namespace osu.Game.Skinning protected LegacySkin(SkinInfo skin, IResourceStore storage, AudioManager audioManager, string filename) : base(skin) { - Stream stream = storage?.GetStream(filename); - - if (stream != null) + using (var stream = storage?.GetStream(filename)) { - using (LineBufferedReader reader = new LineBufferedReader(stream)) - Configuration = new LegacySkinDecoder().Decode(reader); + if (stream != null) + { + using (LineBufferedReader reader = new LineBufferedReader(stream, true)) + Configuration = new LegacySkinDecoder().Decode(reader); + + stream.Seek(0, SeekOrigin.Begin); + + using (LineBufferedReader reader = new LineBufferedReader(stream)) + { + var maniaList = new LegacyManiaSkinDecoder().Decode(reader); + + foreach (var config in maniaList) + maniaConfigurations[config.Keys] = config; + } + } + else + Configuration = new LegacySkinConfiguration(); } - else - Configuration = new LegacySkinConfiguration { LegacyVersion = LegacySkinConfiguration.LATEST_VERSION }; if (storage != null) { - Samples = audioManager?.GetSampleStore(storage); + var samples = audioManager?.GetSampleStore(storage); + if (samples != null) + samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + + Samples = samples; Textures = new TextureStore(new TextureLoaderStore(storage)); + + (storage as ResourceStore)?.AddExtension("ogg"); } + + // todo: this shouldn't really be duplicated here (from ManiaLegacySkinTransformer). we need to come up with a better solution. + hasKeyTexture = new Lazy(() => this.GetAnimation( + lookupForMania(new LegacyManiaSkinConfigurationLookup(4, LegacyManiaSkinConfigurationLookups.KeyImage, 0))?.Value ?? "mania-key1", true, + true) != null); } protected override void Dispose(bool isDisposing) @@ -68,38 +104,47 @@ namespace osu.Game.Skinning { switch (lookup) { - case GlobalSkinConfiguration global: - switch (global) + case GlobalSkinColours colour: + switch (colour) { - case GlobalSkinConfiguration.ComboColours: + case GlobalSkinColours.ComboColours: var comboColours = Configuration.ComboColours; if (comboColours != null) return SkinUtils.As(new Bindable>(comboColours)); break; + + default: + return SkinUtils.As(getCustomColour(Configuration, colour.ToString())); } break; - case GlobalSkinColour colour: - return SkinUtils.As(getCustomColour(colour.ToString())); - case LegacySkinConfiguration.LegacySetting legacy: switch (legacy) { case LegacySkinConfiguration.LegacySetting.Version: - if (Configuration.LegacyVersion is decimal version) - return SkinUtils.As(new Bindable(version)); - - break; + return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); } break; case SkinCustomColourLookup customColour: - return SkinUtils.As(getCustomColour(customColour.Lookup.ToString())); + return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); + + case LegacyManiaSkinConfigurationLookup maniaLookup: + if (!AllowManiaSkin) + return null; + + var result = lookupForMania(maniaLookup); + if (result != null) + return result; + + break; default: + // handles lookups like GlobalSkinConfiguration + try { if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out var val)) @@ -124,7 +169,122 @@ namespace osu.Game.Skinning return null; } - private IBindable getCustomColour(string lookup) => Configuration.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; + private IBindable lookupForMania(LegacyManiaSkinConfigurationLookup maniaLookup) + { + if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) + maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.ColumnSpacing: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.HitPosition: + return SkinUtils.As(new Bindable(existing.HitPosition)); + + case LegacyManiaSkinConfigurationLookups.LightPosition: + return SkinUtils.As(new Bindable(existing.LightPosition)); + + case LegacyManiaSkinConfigurationLookups.ShowJudgementLine: + return SkinUtils.As(new Bindable(existing.ShowJudgementLine)); + + case LegacyManiaSkinConfigurationLookups.ExplosionScale: + Debug.Assert(maniaLookup.TargetColumn != null); + + if (GetConfig(LegacySkinConfiguration.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)); + + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + case LegacyManiaSkinConfigurationLookups.ColumnLineColour: + return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); + + case LegacyManiaSkinConfigurationLookups.JudgementLineColour: + return SkinUtils.As(getCustomColour(existing, "ColourJudgementLine")); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); + + case LegacyManiaSkinConfigurationLookups.ColumnLightColour: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); + + case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: + return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); + + case LegacyManiaSkinConfigurationLookups.NoteImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); + + case LegacyManiaSkinConfigurationLookups.KeyImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); + + case LegacyManiaSkinConfigurationLookups.KeyImageDown: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); + + case LegacyManiaSkinConfigurationLookups.LeftStageImage: + return SkinUtils.As(getManiaImage(existing, "StageLeft")); + + case LegacyManiaSkinConfigurationLookups.RightStageImage: + return SkinUtils.As(getManiaImage(existing, "StageRight")); + + case LegacyManiaSkinConfigurationLookups.BottomStageImage: + return SkinUtils.As(getManiaImage(existing, "StageBottom")); + + case LegacyManiaSkinConfigurationLookups.LightImage: + return SkinUtils.As(getManiaImage(existing, "StageLight")); + + case LegacyManiaSkinConfigurationLookups.HitTargetImage: + return SkinUtils.As(getManiaImage(existing, "StageHint")); + + case LegacyManiaSkinConfigurationLookups.LeftLineWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.RightLineWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); + + case LegacyManiaSkinConfigurationLookups.Hit0: + case LegacyManiaSkinConfigurationLookups.Hit50: + case LegacyManiaSkinConfigurationLookups.Hit100: + case LegacyManiaSkinConfigurationLookups.Hit200: + case LegacyManiaSkinConfigurationLookups.Hit300: + case LegacyManiaSkinConfigurationLookups.Hit300g: + return SkinUtils.As(getManiaImage(existing, maniaLookup.Lookup.ToString())); + } + + return null; + } + + private IBindable getCustomColour(IHasCustomColours source, string lookup) + => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; + + private IBindable getManiaImage(LegacyManiaSkinConfiguration source, string lookup) + => source.ImageLookups.TryGetValue(lookup, out var image) ? new Bindable(image) : null; public override Drawable GetDrawableComponent(ISkinComponent component) { @@ -152,23 +312,27 @@ namespace osu.Game.Skinning return this.GetAnimation(component.LookupName, false, false); } - public override Texture GetTexture(string componentName) + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { - componentName = getFallbackName(componentName); - - float ratio = 2; - var texture = Textures?.Get($"{componentName}@2x"); - - if (texture == null) + foreach (var name in getFallbackNames(componentName)) { - ratio = 1; - texture = Textures?.Get(componentName); + float ratio = 2; + var texture = Textures?.Get($"{name}@2x", wrapModeS, wrapModeT); + + if (texture == null) + { + ratio = 1; + texture = Textures?.Get(name, wrapModeS, wrapModeT); + } + + if (texture == null) + continue; + + texture.ScaleAdjust = ratio; + return texture; } - if (texture != null) - texture.ScaleAdjust = ratio; - - return texture; + return null; } public override SampleChannel GetSample(ISampleInfo sampleInfo) @@ -188,10 +352,14 @@ namespace osu.Game.Skinning return null; } - private string getFallbackName(string componentName) + private IEnumerable getFallbackNames(string componentName) { + // May be something like "Gameplay/osu/approachcircle" from lazer, or "Arrows/note1" from a user skin. + yield return componentName; + + // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/osu/approachcircle" -> "approachcircle"). string lastPiece = componentName.Split('/').Last(); - return componentName.StartsWith("Gameplay/taiko/") ? "taiko-" + lastPiece : lastPiece; + yield return componentName.StartsWith("Gameplay/taiko/") ? "taiko-" + lastPiece : lastPiece; } } } diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index 88ba7b23b7..75b7ba28b9 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -44,6 +44,12 @@ namespace osu.Game.Skinning } break; + + // osu!catch section only has colour settings + // so no harm in handling the entire section + case Section.CatchTheBeat: + HandleColours(skin, line); + return; } if (!string.IsNullOrEmpty(pair.Key)) @@ -52,5 +58,12 @@ namespace osu.Game.Skinning base.ParseLine(skin, section, line); } + + protected override LegacySkinConfiguration CreateTemplateObject() + { + var config = base.CreateTemplateObject(); + config.LegacyVersion = 1.0m; + return config; + } } } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index c758b699ed..7cf41ef3c1 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -1,8 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; 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; @@ -10,48 +14,90 @@ namespace osu.Game.Skinning { public static class LegacySkinExtensions { - public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, string animationSeparator = "-") + public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) + => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); + + public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, + string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) { - const double default_frame_time = 1000 / 60d; - Texture texture; - Texture getFrameTexture(int frame) => source.GetTexture($"{componentName}{animationSeparator}{frame}"); - - TextureAnimation animation = null; - if (animatable) { - for (int i = 0; true; i++) + var textures = getTextures().ToArray(); + + if (textures.Length > 0) { - if ((texture = getFrameTexture(i)) == null) - break; - - if (animation == null) + var animation = new SkinnableTextureAnimation(startAtCurrentTime) { - animation = new TextureAnimation - { - DefaultFrameLength = default_frame_time, - Repeat = looping - }; - } + DefaultFrameLength = frameLength ?? getFrameLength(source, applyConfigFrameRate, textures), + Loop = looping, + }; - animation.AddFrame(texture); + foreach (var t in textures) + animation.AddFrame(t); + + return animation; } } - if (animation != null) - return animation; - - if ((texture = source.GetTexture(componentName)) != null) - { - return new Sprite - { - Texture = texture - }; - } + // if an animation was not allowed or not found, fall back to a sprite retrieval. + if ((texture = source.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + return new Sprite { Texture = texture }; return null; + + IEnumerable getTextures() + { + for (int i = 0; true; i++) + { + if ((texture = source.GetTexture($"{componentName}{animationSeparator}{i}", wrapModeS, wrapModeT)) == null) + break; + + yield return texture; + } + } + } + + public class SkinnableTextureAnimation : TextureAnimation + { + [Resolved(canBeNull: true)] + private IAnimationTimeReference timeReference { get; set; } + + public SkinnableTextureAnimation(bool startAtCurrentTime = true) + : base(startAtCurrentTime) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (timeReference != null) + { + Clock = timeReference.Clock; + PlaybackPosition = timeReference.Clock.CurrentTime - timeReference.AnimationStartTime; + } + } + } + + private const double default_frame_time = 1000 / 60d; + + private static double getFrameLength(ISkin source, bool applyConfigFrameRate, Texture[] textures) + { + if (applyConfigFrameRate) + { + var iniRate = source.GetConfig(GlobalSkinConfiguration.AnimationFramerate); + + if (iniRate?.Value > 0) + return 1000f / iniRate.Value; + + return 1000f / textures.Length; + } + + return default_frame_time; } } } diff --git a/osu.Game/Skinning/LegacySkinResourceStore.cs b/osu.Game/Skinning/LegacySkinResourceStore.cs index 249d48b34b..05d0dee05f 100644 --- a/osu.Game/Skinning/LegacySkinResourceStore.cs +++ b/osu.Game/Skinning/LegacySkinResourceStore.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Game.Database; @@ -27,7 +28,7 @@ namespace osu.Game.Skinning foreach (var filename in base.GetFilenames(name)) { - var path = getPathForFile(filename); + var path = getPathForFile(filename.ToStandardisedPath()); if (path != null) yield return path; } diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs new file mode 100644 index 0000000000..786056b932 --- /dev/null +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.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; + +namespace osu.Game.Skinning +{ + /// + /// Transformer used to handle support of legacy features for individual rulesets. + /// + public abstract class LegacySkinTransformer : ISkin + { + /// + /// Source of the which is being transformed. + /// + protected ISkinSource Source { get; } + + protected LegacySkinTransformer(ISkinSource source) + { + Source = source; + } + + public abstract Drawable GetDrawableComponent(ISkinComponent component); + + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + => Source.GetTexture(componentName, wrapModeS, wrapModeT); + + public virtual SampleChannel GetSample(ISampleInfo sampleInfo) + { + if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) + return Source.GetSample(sampleInfo); + + var playLayeredHitSounds = GetConfig(GlobalSkinConfiguration.LayeredHitSounds); + if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) + return new SampleChannelVirtual(); + + return Source.GetSample(sampleInfo); + } + + public abstract IBindable GetConfig(TLookup lookup); + } +} diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index fa4aebd8a5..4b0cf02c0a 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -5,6 +5,7 @@ using System; 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; @@ -20,7 +21,9 @@ namespace osu.Game.Skinning public abstract SampleChannel GetSample(ISampleInfo sampleInfo); - public abstract Texture GetTexture(string componentName); + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + public abstract Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); public abstract IBindable GetConfig(TLookup lookup); diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 6b9627188e..b9fe44ef3b 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -24,8 +24,6 @@ namespace osu.Game.Skinning public bool DeletePending { get; set; } - public string FullName => $"\"{Name}\" by {Creator}"; - public static SkinInfo Default { get; } = new SkinInfo { Name = "osu!lazer", @@ -34,6 +32,10 @@ namespace osu.Game.Skinning public bool Equals(SkinInfo other) => other != null && ID == other.ID; - public override string ToString() => FullName; + public override string ToString() + { + string author = Creator == null ? string.Empty : $"({Creator})"; + return $"{Name} {author}".Trim(); + } } } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 3d469ab6e1..e1f713882a 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -13,6 +13,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.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -43,12 +44,15 @@ namespace osu.Game.Skinning this.audio = audio; this.legacyDefaultResources = legacyDefaultResources; - ItemRemoved += removedInfo => + ItemRemoved.BindValueChanged(weakRemovedInfo => { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (removedInfo.ID == CurrentSkinInfo.Value.ID) - CurrentSkinInfo.Value = SkinInfo.Default; - }; + if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) + { + // check the removed skin is not the current user choice. if it is, switch back to default. + if (removedInfo.ID == CurrentSkinInfo.Value.ID) + CurrentSkinInfo.Value = SkinInfo.Default; + } + }); CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); CurrentSkin.ValueChanged += skin => @@ -127,7 +131,7 @@ namespace osu.Game.Skinning public Drawable GetDrawableComponent(ISkinComponent component) => CurrentSkin.Value.GetDrawableComponent(component); - public Texture GetTexture(string componentName) => CurrentSkin.Value.GetTexture(componentName); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); public SampleChannel GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index 1c01bbf1ab..adf62ed452 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -7,6 +7,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.Textures; using osu.Game.Audio; @@ -47,13 +48,13 @@ namespace osu.Game.Skinning return fallbackSource?.GetDrawableComponent(component); } - public Texture GetTexture(string componentName) + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { Texture sourceTexture; - if (AllowTextureLookup(componentName) && (sourceTexture = skin?.GetTexture(componentName)) != null) + if (AllowTextureLookup(componentName) && (sourceTexture = skin?.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return sourceTexture; - return fallbackSource?.GetTexture(componentName); + return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); } public SampleChannel GetSample(ISampleInfo sampleInfo) diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index fda031e6cb..d9a5036649 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osuTK; namespace osu.Game.Skinning @@ -18,6 +19,12 @@ namespace osu.Game.Skinning /// public Drawable Drawable { get; private set; } + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + private readonly ISkinComponent component; private readonly ConfineMode confineMode; @@ -44,6 +51,11 @@ namespace osu.Game.Skinning RelativeSizeAxes = Axes.Both; } + /// + /// Seeks to the 0-th frame if the content of this is an . + /// + public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0); + private readonly Func createDefault; private readonly Cached scaling = new Cached(); @@ -92,20 +104,13 @@ namespace osu.Game.Skinning switch (confineMode) { - case ConfineMode.NoScaling: - return; - - case ConfineMode.ScaleDownToFit: - if (Drawable.DrawSize.X <= DrawSize.X && Drawable.DrawSize.Y <= DrawSize.Y) - return; - + case ConfineMode.ScaleToFit: + Drawable.RelativeSizeAxes = Axes.Both; + Drawable.Size = Vector2.One; + Drawable.Scale = Vector2.One; + Drawable.FillMode = FillMode.Fit; break; } - - Drawable.RelativeSizeAxes = Axes.Both; - Drawable.Size = Vector2.One; - Drawable.Scale = Vector2.One; - Drawable.FillMode = FillMode.Fit; } finally { @@ -121,7 +126,6 @@ namespace osu.Game.Skinning /// Don't apply any scaling. This allows the user element to be of any size, exceeding specified bounds. /// NoScaling, - ScaleDownToFit, ScaleToFit, } } diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index fc6afd0b27..24d6648273 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -4,11 +4,13 @@ using System.Collections.Generic; using System.Linq; 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.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; using osu.Game.Audio; namespace osu.Game.Skinning @@ -17,30 +19,60 @@ namespace osu.Game.Skinning { private readonly ISampleInfo[] hitSamples; - private List<(AdjustableProperty property, BindableDouble bindable)> adjustments; + [Resolved] + private ISampleStore samples { get; set; } - private SampleChannel[] channels; - - private ISampleStore samples; + public SkinnableSound(ISampleInfo hitSamples) + : this(new[] { hitSamples }) + { + } public SkinnableSound(IEnumerable hitSamples) { this.hitSamples = hitSamples.ToArray(); - } - - public SkinnableSound(ISampleInfo hitSamples) - { - this.hitSamples = new[] { hitSamples }; - } - - [BackgroundDependencyLoader] - private void load(ISampleStore samples) - { - this.samples = samples; + InternalChild = samplesContainer = new AudioContainer(); } private bool looping; + private readonly AudioContainer samplesContainer; + + public BindableNumber Volume => samplesContainer.Volume; + + public BindableNumber Balance => samplesContainer.Balance; + + public BindableNumber Frequency => samplesContainer.Frequency; + + public BindableNumber Tempo => samplesContainer.Tempo; + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence VolumeTo(double newVolume, double duration = 0, Easing easing = Easing.None) => + samplesContainer.VolumeTo(newVolume, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence BalanceTo(double newBalance, double duration = 0, Easing easing = Easing.None) => + samplesContainer.BalanceTo(newBalance, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence FrequencyTo(double newFrequency, double duration = 0, Easing easing = Easing.None) => + samplesContainer.FrequencyTo(newFrequency, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence TempoTo(double newTempo, double duration = 0, Easing easing = Easing.None) => + samplesContainer.TempoTo(newTempo, duration, easing); + public bool Looping { get => looping; @@ -50,33 +82,23 @@ namespace osu.Game.Skinning looping = value; - channels?.ForEach(c => c.Looping = looping); + samplesContainer.ForEach(c => c.Looping = looping); } } - public void Play() => channels?.ForEach(c => c.Play()); - - public void Stop() => channels?.ForEach(c => c.Stop()); - - public void AddAdjustment(AdjustableProperty type, BindableDouble adjustBindable) + public void Play() => samplesContainer.ForEach(c => { - if (adjustments == null) adjustments = new List<(AdjustableProperty, BindableDouble)>(); + if (c.AggregateVolume.Value > 0) + c.Play(); + }); - adjustments.Add((type, adjustBindable)); - channels?.ForEach(c => c.AddAdjustment(type, adjustBindable)); - } - - public void RemoveAdjustment(AdjustableProperty type, BindableDouble adjustBindable) - { - adjustments?.Remove((type, adjustBindable)); - channels?.ForEach(c => c.RemoveAdjustment(type, adjustBindable)); - } + public void Stop() => samplesContainer.ForEach(c => c.Stop()); public override bool IsPresent => Scheduler.HasPendingTasks; protected override void SkinChanged(ISkinSource skin, bool allowFallback) { - channels = hitSamples.Select(s => + var channels = hitSamples.Select(s => { var ch = skin.GetSample(s); @@ -93,27 +115,12 @@ namespace osu.Game.Skinning { ch.Looping = looping; ch.Volume.Value = s.Volume / 100.0; - - if (adjustments != null) - { - foreach (var (property, bindable) in adjustments) - ch.AddAdjustment(property, bindable); - } } return ch; - }).Where(c => c != null).ToArray(); - } + }).Where(c => c != null); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (channels != null) - { - foreach (var c in channels) - c.Dispose(); - } + samplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 7a84ac009a..ec461fa095 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using System.Threading; using osuTK; using osu.Framework.Allocation; @@ -14,7 +15,7 @@ namespace osu.Game.Storyboards.Drawables { public class DrawableStoryboard : Container { - public Storyboard Storyboard { get; private set; } + public Storyboard Storyboard { get; } protected override Container Content { get; } @@ -50,7 +51,7 @@ namespace osu.Game.Storyboards.Drawables AddInternal(Content = new Container { - Size = new Vector2(640, 480), + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, }); @@ -72,10 +73,12 @@ namespace osu.Game.Storyboards.Drawables } } + public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay"); + private void updateLayerVisibility() { foreach (var layer in Children) - layer.Enabled = passing ? layer.Layer.EnabledWhenPassing : layer.Layer.EnabledWhenFailing; + layer.Enabled = passing ? layer.Layer.VisibleWhenPassing : layer.Layer.VisibleWhenFailing; } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index a076bb54df..72e52f6106 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -8,14 +8,14 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; namespace osu.Game.Storyboards.Drawables { public class DrawableStoryboardAnimation : TextureAnimation, IFlippable, IVectorScalable { - public StoryboardAnimation Animation { get; private set; } + public StoryboardAnimation Animation { get; } private bool flipH; @@ -108,7 +108,7 @@ namespace osu.Game.Storyboards.Drawables Animation = animation; Origin = animation.Origin; Position = animation.InitialPosition; - Repeat = animation.LoopType == AnimationLoopType.LoopForever; + Loop = animation.LoopType == AnimationLoopType.LoopForever; LifetimeStart = animation.StartTime; LifetimeEnd = animation.EndTime; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs index fd2d441f34..2ada83c3b4 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs @@ -8,9 +8,9 @@ using osu.Framework.Graphics.Containers; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardLayer : LifetimeManagementContainer + public class DrawableStoryboardLayer : CompositeDrawable { - public StoryboardLayer Layer { get; private set; } + public StoryboardLayer Layer { get; } public bool Enabled; public override bool IsPresent => Enabled && base.IsPresent; @@ -21,18 +21,36 @@ namespace osu.Game.Storyboards.Drawables RelativeSizeAxes = Axes.Both; Anchor = Anchor.Centre; Origin = Anchor.Centre; - Enabled = layer.EnabledWhenPassing; + Enabled = layer.VisibleWhenPassing; + Masking = layer.Masking; + + InternalChild = new LayerElementContainer(layer); } - [BackgroundDependencyLoader] - private void load(CancellationToken? cancellationToken) + private class LayerElementContainer : LifetimeManagementContainer { - foreach (var element in Layer.Elements) - { - cancellationToken?.ThrowIfCancellationRequested(); + private readonly StoryboardLayer storyboardLayer; - if (element.IsDrawable) - AddInternal(element.CreateDrawable()); + public LayerElementContainer(StoryboardLayer layer) + { + storyboardLayer = layer; + + Width = 640; + Height = 480; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(CancellationToken? cancellationToken) + { + foreach (var element in storyboardLayer.Elements) + { + cancellationToken?.ThrowIfCancellationRequested(); + + if (element.IsDrawable) + AddInternal(element.CreateDrawable()); + } } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index f3f8308964..8eaf9ac652 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.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.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; namespace osu.Game.Storyboards.Drawables { @@ -17,7 +20,8 @@ namespace osu.Game.Storyboards.Drawables private const double allowable_late_start = 100; private readonly StoryboardSampleInfo sampleInfo; - private SampleChannel channel; + + protected SampleChannel Channel { get; private set; } public override bool RemoveWhenNotAlive => false; @@ -28,12 +32,16 @@ namespace osu.Game.Storyboards.Drawables } [BackgroundDependencyLoader] - private void load(IBindable beatmap) + private void load(IBindable beatmap, IBindable> mods) { - channel = beatmap.Value.Skin.GetSample(sampleInfo); + Channel = beatmap.Value.Skin.GetSample(sampleInfo); + if (Channel == null) + return; - if (channel != null) - channel.Volume.Value = sampleInfo.Volume / 100.0; + Channel.Volume.Value = sampleInfo.Volume / 100.0; + + foreach (var mod in mods.Value.OfType()) + mod.ApplyToSample(Channel); } protected override void Update() @@ -44,19 +52,19 @@ namespace osu.Game.Storyboards.Drawables if (Time.Current < sampleInfo.StartTime) { // We've rewound before the start time of the sample - channel?.Stop(); + Channel?.Stop(); // In the case that the user fast-forwards to a point far beyond the start time of the sample, // we want to be able to fall into the if-conditional below (therefore we must not have a life time end) LifetimeStart = sampleInfo.StartTime; LifetimeEnd = double.MaxValue; } - else if (Time.Current - Time.Elapsed < sampleInfo.StartTime) + else if (Time.Current - Time.Elapsed <= sampleInfo.StartTime) { // We've passed the start time of the sample. We only play the sample if we're within an allowable range // from the sample's start, to reduce layering if we've been fast-forwarded far into the future if (Time.Current - sampleInfo.StartTime < allowable_late_start) - channel?.Play(); + Channel?.Play(); // In the case that the user rewinds to a point far behind the start time of the sample, // we want to be able to fall into the if-conditional above (therefore we must not have a life time start) @@ -67,7 +75,7 @@ namespace osu.Game.Storyboards.Drawables protected override void Dispose(bool isDisposing) { - channel?.Stop(); + Channel?.Stop(); base.Dispose(isDisposing); } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index ac795b3349..d8d3248659 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -8,14 +8,14 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; namespace osu.Game.Storyboards.Drawables { public class DrawableStoryboardSprite : Sprite, IFlippable, IVectorScalable { - public StoryboardSprite Sprite { get; private set; } + public StoryboardSprite Sprite { get; } private bool flipH; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs new file mode 100644 index 0000000000..4ea582ca4a --- /dev/null +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Video; +using osu.Game.Beatmaps; + +namespace osu.Game.Storyboards.Drawables +{ + public class DrawableStoryboardVideo : CompositeDrawable + { + public readonly StoryboardVideo Video; + private Video video; + + public override bool RemoveWhenNotAlive => false; + + public DrawableStoryboardVideo(StoryboardVideo video) + { + Video = video; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(IBindable beatmap, TextureStore textureStore) + { + var path = beatmap.Value.BeatmapSetInfo?.Files?.Find(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + + if (path == null) + return; + + var stream = textureStore.GetStream(path); + + if (stream == null) + return; + + InternalChild = video = new Video(stream, false) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (video == null) return; + + using (video.BeginAbsoluteSequence(Video.StartTime)) + { + Schedule(() => video.PlaybackPosition = Time.Current - Video.StartTime); + video.FadeIn(500); + } + } + } +} diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 35bfe8c229..b0fb583d62 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Beatmaps; -using osu.Game.Storyboards.Drawables; using System.Collections.Generic; using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Storyboards.Drawables; namespace osu.Game.Storyboards { @@ -19,18 +19,26 @@ namespace osu.Game.Storyboards public double FirstEventTime => Layers.Min(l => l.Elements.FirstOrDefault()?.StartTime ?? 0); + /// + /// Depth of the currently front-most storyboard layer, excluding the overlay layer. + /// + private int minimumLayerDepth; + public Storyboard() { + layers.Add("Video", new StoryboardLayer("Video", 4, false)); layers.Add("Background", new StoryboardLayer("Background", 3)); - layers.Add("Fail", new StoryboardLayer("Fail", 2) { EnabledWhenPassing = false, }); - layers.Add("Pass", new StoryboardLayer("Pass", 1) { EnabledWhenFailing = false, }); - layers.Add("Foreground", new StoryboardLayer("Foreground", 0)); + layers.Add("Fail", new StoryboardLayer("Fail", 2) { VisibleWhenPassing = false, }); + layers.Add("Pass", new StoryboardLayer("Pass", 1) { VisibleWhenFailing = false, }); + layers.Add("Foreground", new StoryboardLayer("Foreground", minimumLayerDepth = 0)); + + layers.Add("Overlay", new StoryboardLayer("Overlay", int.MinValue)); } public StoryboardLayer GetLayer(string name) { if (!layers.TryGetValue(name, out var layer)) - layers[name] = layer = new StoryboardLayer(name, layers.Values.Min(l => l.Depth) - 1); + layers[name] = layer = new StoryboardLayer(name, --minimumLayerDepth); return layer; } diff --git a/osu.Game/Storyboards/StoryboardLayer.cs b/osu.Game/Storyboards/StoryboardLayer.cs index d15f771534..1cde7cf67a 100644 --- a/osu.Game/Storyboards/StoryboardLayer.cs +++ b/osu.Game/Storyboards/StoryboardLayer.cs @@ -8,17 +8,23 @@ namespace osu.Game.Storyboards { public class StoryboardLayer { - public string Name; - public int Depth; - public bool EnabledWhenPassing = true; - public bool EnabledWhenFailing = true; + public readonly string Name; + + public readonly int Depth; + + public readonly bool Masking; + + public bool VisibleWhenPassing = true; + + public bool VisibleWhenFailing = true; public List Elements = new List(); - public StoryboardLayer(string name, int depth) + public StoryboardLayer(string name, int depth, bool masking = true) { Name = name; Depth = depth; + Masking = masking; } public void Add(IStoryboardElement element) @@ -27,6 +33,6 @@ namespace osu.Game.Storyboards } public DrawableStoryboardLayer CreateDrawable() - => new DrawableStoryboardLayer(this) { Depth = Depth, }; + => new DrawableStoryboardLayer(this) { Depth = Depth, Name = Name }; } } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 22e1929419..f411ad04f3 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -16,7 +16,7 @@ namespace osu.Game.Storyboards private readonly List loops = new List(); private readonly List triggers = new List(); - public string Path { get; set; } + public string Path { get; } public bool IsDrawable => HasCommands; public Anchor Origin; diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs new file mode 100644 index 0000000000..4652e45852 --- /dev/null +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards +{ + public class StoryboardVideo : IStoryboardElement + { + public string Path { get; } + + public bool IsDrawable => true; + + public double StartTime { get; } + + public StoryboardVideo(string path, int offset) + { + Path = path; + StartTime = offset; + } + + public Drawable CreateDrawable() => new DrawableStoryboardVideo(this); + } +} diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index ef86186e41..6ada632850 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -10,7 +10,6 @@ using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; @@ -100,10 +99,7 @@ namespace osu.Game.Tests.Beatmaps private ConvertResult convert(string name, Mod[] mods) { - var beatmap = getBeatmap(name); - - var rulesetInstance = CreateRuleset(); - beatmap.BeatmapInfo.Ruleset = beatmap.BeatmapInfo.RulesetID == rulesetInstance.RulesetInfo.ID ? rulesetInstance.RulesetInfo : new RulesetInfo(); + var beatmap = GetBeatmap(name); var converterResult = new Dictionary>(); @@ -116,7 +112,7 @@ namespace osu.Game.Tests.Beatmaps } }; - working.GetPlayableBeatmap(rulesetInstance.RulesetInfo, mods); + working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); return new ConvertResult { @@ -144,14 +140,19 @@ namespace osu.Game.Tests.Beatmaps } } - private IBeatmap getBeatmap(string name) + public IBeatmap GetBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) using (var stream = new LineBufferedReader(resStream)) { var decoder = Decoder.GetDecoder(stream); ((LegacyBeatmapDecoder)decoder).ApplyOffsets = false; - return decoder.Decode(stream); + var beatmap = decoder.Decode(stream); + + var rulesetInstance = CreateRuleset(); + beatmap.BeatmapInfo.Ruleset = beatmap.BeatmapInfo.RulesetID == rulesetInstance.RulesetInfo.ID ? rulesetInstance.RulesetInfo : new RulesetInfo(); + + return beatmap; } } @@ -207,8 +208,6 @@ namespace osu.Game.Tests.Beatmaps protected override Texture GetBackground() => throw new NotImplementedException(); - protected override VideoSprite GetVideo() => throw new NotImplementedException(); - protected override Track GetTrack() => throw new NotImplementedException(); protected override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs new file mode 100644 index 0000000000..ab4fb38657 --- /dev/null +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -0,0 +1,194 @@ +// Copyright (c) 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.Audio; +using osu.Framework.IO.Stores; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osu.Game.Users; + +namespace osu.Game.Tests.Beatmaps +{ + public abstract class HitObjectSampleTest : PlayerTestScene + { + protected abstract IResourceStore Resources { get; } + protected LegacySkin Skin { get; private set; } + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + private readonly SkinInfo userSkinInfo = new SkinInfo(); + + private readonly BeatmapInfo beatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo(), + Metadata = new BeatmapMetadata + { + Author = User.SYSTEM_USER + } + }; + + private readonly TestResourceStore userSkinResourceStore = new TestResourceStore(); + private readonly TestResourceStore beatmapSkinResourceStore = new TestResourceStore(); + private SkinSourceDependencyContainer dependencies; + private IBeatmap currentTestBeatmap; + protected sealed override bool HasCustomSteps => true; + + protected sealed override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + => new DependencyContainer(dependencies = new SkinSourceDependencyContainer(base.CreateChildDependencies(parent))); + + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestBeatmap; + + protected sealed override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new TestWorkingBeatmap(beatmapInfo, beatmapSkinResourceStore, beatmap, storyboard, Clock, Audio); + + protected void CreateTestWithBeatmap(string filename) + { + CreateTest(() => + { + AddStep("clear performed lookups", () => + { + userSkinResourceStore.PerformedLookups.Clear(); + beatmapSkinResourceStore.PerformedLookups.Clear(); + }); + + AddStep($"load {filename}", () => + { + using (var reader = new LineBufferedReader(Resources.GetStream($"Resources/SampleLookups/{filename}"))) + currentTestBeatmap = Decoder.GetDecoder(reader).Decode(reader); + + // populate ruleset for beatmap converters that require it to be present. + currentTestBeatmap.BeatmapInfo.Ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.RulesetID); + }); + }); + } + + protected void SetupSkins(string beatmapFile, string userFile) + { + AddStep("setup skins", () => + { + userSkinInfo.Files = new List + { + new SkinFileInfo + { + Filename = userFile, + FileInfo = new IO.FileInfo { Hash = userFile } + } + }; + + beatmapInfo.BeatmapSet.Files = new List + { + new BeatmapSetFileInfo + { + Filename = beatmapFile, + FileInfo = new IO.FileInfo { Hash = beatmapFile } + } + }; + + // Need to refresh the cached skin source to refresh the skin resource store. + dependencies.SkinSource = new SkinProvidingContainer(Skin = new LegacySkin(userSkinInfo, userSkinResourceStore, Audio)); + }); + } + + protected void AssertBeatmapLookup(string name) => AddAssert($"\"{name}\" looked up from beatmap skin", + () => !userSkinResourceStore.PerformedLookups.Contains(name) && beatmapSkinResourceStore.PerformedLookups.Contains(name)); + + protected void AssertUserLookup(string name) => AddAssert($"\"{name}\" looked up from user skin", + () => !beatmapSkinResourceStore.PerformedLookups.Contains(name) && userSkinResourceStore.PerformedLookups.Contains(name)); + + protected void AssertNoLookup(string name) => AddAssert($"\"{name}\" not looked up", + () => !beatmapSkinResourceStore.PerformedLookups.Contains(name) && !userSkinResourceStore.PerformedLookups.Contains(name)); + + private class SkinSourceDependencyContainer : IReadOnlyDependencyContainer + { + public ISkinSource SkinSource; + + private readonly IReadOnlyDependencyContainer fallback; + + public SkinSourceDependencyContainer(IReadOnlyDependencyContainer fallback) + { + this.fallback = fallback; + } + + public object Get(Type type) + { + if (type == typeof(ISkinSource)) + return SkinSource; + + return fallback.Get(type); + } + + public object Get(Type type, CacheInfo info) + { + if (type == typeof(ISkinSource)) + return SkinSource; + + return fallback.Get(type, info); + } + + public void Inject(T instance) where T : class + { + // Never used directly + } + } + + private class TestResourceStore : IResourceStore + { + public readonly List PerformedLookups = new List(); + + public byte[] Get(string name) + { + markLookup(name); + return Array.Empty(); + } + + public Task GetAsync(string name) + { + markLookup(name); + return Task.FromResult(Array.Empty()); + } + + public Stream GetStream(string name) + { + markLookup(name); + return new MemoryStream(); + } + + private void markLookup(string name) => PerformedLookups.Add(name.Substring(name.LastIndexOf(Path.DirectorySeparatorChar) + 1)); + + public IEnumerable GetAvailableResources() => Enumerable.Empty(); + + public void Dispose() + { + } + } + + private class TestWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly BeatmapInfo skinBeatmapInfo; + private readonly IResourceStore resourceStore; + + public TestWorkingBeatmap(BeatmapInfo skinBeatmapInfo, IResourceStore resourceStore, IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, + double length = 60000) + : base(beatmap, storyboard, referenceClock, audio, length) + { + this.skinBeatmapInfo = skinBeatmapInfo; + this.resourceStore = resourceStore; + } + + protected override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, AudioManager); + } + } +} diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index e9251f8011..e93bf916c7 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Beatmaps protected void Test(LegacyMods legacyMods, Type[] expectedMods) { var ruleset = CreateRuleset(); - var mods = ruleset.ConvertLegacyMods(legacyMods).ToList(); + var mods = ruleset.ConvertFromLegacyMods(legacyMods).ToList(); Assert.AreEqual(expectedMods.Length, mods.Count); foreach (var modType in expectedMods) diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index d6f92ba086..9fc20fd0f2 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.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.IO; using System.Text; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.IO; using osu.Game.Rulesets; @@ -15,7 +17,7 @@ namespace osu.Game.Tests.Beatmaps { public TestBeatmap(RulesetInfo ruleset) { - var baseBeatmap = createTestBeatmap(); + var baseBeatmap = CreateBeatmap(); BeatmapInfo = baseBeatmap.BeatmapInfo; ControlPointInfo = baseBeatmap.ControlPointInfo; @@ -23,6 +25,7 @@ namespace osu.Game.Tests.Beatmaps HitObjects = baseBeatmap.HitObjects; BeatmapInfo.Ruleset = ruleset; + BeatmapInfo.RulesetID = ruleset.ID ?? 0; BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo @@ -37,13 +40,30 @@ namespace osu.Game.Tests.Beatmaps }; } + protected virtual Beatmap CreateBeatmap() => createTestBeatmap(); + private static Beatmap createTestBeatmap() { using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(test_beatmap_data))) - using (var reader = new LineBufferedReader(stream)) - return Decoder.GetDecoder(reader).Decode(reader); + { + using (var reader = new LineBufferedReader(stream)) + { + var b = Decoder.GetDecoder(reader).Decode(reader); + + b.BeatmapInfo.MD5Hash = test_beatmap_hash.Value.md5; + b.BeatmapInfo.Hash = test_beatmap_hash.Value.sha2; + + return b; + } + } } + private static readonly Lazy<(string md5, string sha2)> test_beatmap_hash = new Lazy<(string md5, string sha2)>(() => + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(test_beatmap_data))) + return (stream.ComputeMD5Hash(), stream.ComputeSHA2Hash()); + }); + private const string test_beatmap_data = @"osu file format v14 [General] diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index 871d8ee3f1..cdf9170701 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Game.Beatmaps; using osu.Game.Storyboards; @@ -19,21 +19,24 @@ namespace osu.Game.Tests.Beatmaps /// /// The beatmap. /// An optional storyboard. - public TestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) - : base(beatmap.BeatmapInfo, null) + /// The . + 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 override Texture GetBackground() => null; - protected override VideoSprite GetVideo() => null; - protected override Track GetTrack() => null; } } diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs new file mode 100644 index 0000000000..1193a29d70 --- /dev/null +++ b/osu.Game/Tests/TestScoreInfo.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 osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests +{ + public class TestScoreInfo : ScoreInfo + { + public TestScoreInfo(RulesetInfo ruleset) + { + User = new User + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }; + + Beatmap = new TestBeatmap(ruleset).BeatmapInfo; + Ruleset = ruleset; + RulesetID = ruleset.ID ?? 0; + Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; + + TotalScore = 2845370; + Accuracy = 0.95; + MaxCombo = 999; + Rank = ScoreRank.S; + Date = DateTimeOffset.Now; + + Statistics[HitResult.Miss] = 1; + Statistics[HitResult.Meh] = 50; + Statistics[HitResult.Good] = 100; + Statistics[HitResult.Great] = 300; + } + + private class TestModHardRock : ModHardRock + { + public override double ScoreMultiplier => 1; + } + + private class TestModDoubleTime : ModDoubleTime + { + public override double ScoreMultiplier => 1; + } + } +} diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 58a443ed3d..f0ec638fc9 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -15,7 +15,7 @@ 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 : OsuTestScene + public abstract class EditorClockTestScene : OsuManualInputManagerTestScene { protected readonly BindableBeatDivisor BeatDivisor = new BindableBeatDivisor(); protected new readonly EditorClock Clock; @@ -30,8 +30,7 @@ namespace osu.Game.Tests.Visual var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.Cache(BeatDivisor); - dependencies.CacheAs(Clock); - dependencies.CacheAs(Clock); + dependencies.CacheAs(Clock); return dependencies; } diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 75bbb3e110..cd08f4712a 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -1,31 +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.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Testing; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Tests.Visual { public abstract class EditorTestScene : ScreenTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(Editor), typeof(EditorScreen) }; - - private readonly Ruleset ruleset; - - protected EditorTestScene(Ruleset ruleset) - { - this.ruleset = ruleset; - } + protected Editor Editor { get; private set; } [BackgroundDependencyLoader] private void load() { - Beatmap.Value = CreateWorkingBeatmap(ruleset.RulesetInfo); - - LoadScreen(new Editor()); + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load editor", () => LoadScreen(Editor = CreateEditor())); + AddUntilStep("wait for editor to load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true + && Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + } + + /// + /// Creates the ruleset for providing a corresponding beatmap to load the editor on. + /// + [NotNull] + protected abstract Ruleset CreateEditorRuleset(); + + protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset(); + + protected virtual Editor CreateEditor() => new Editor(); } } diff --git a/osu.Game/Tests/Visual/ModPerfectTestScene.cs b/osu.Game/Tests/Visual/ModPerfectTestScene.cs new file mode 100644 index 0000000000..93b38a149c --- /dev/null +++ b/osu.Game/Tests/Visual/ModPerfectTestScene.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.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.Visual +{ + public abstract class ModPerfectTestScene : ModTestScene + { + private readonly ModPerfect mod; + + protected ModPerfectTestScene(ModPerfect mod) + { + this.mod = mod; + } + + protected void CreateHitObjectTest(HitObjectTestData testData, bool shouldMiss) => CreateModTest(new ModTestData + { + Mod = mod, + Beatmap = new Beatmap + { + BeatmapInfo = { Ruleset = CreatePlayerRuleset().RulesetInfo }, + HitObjects = { testData.HitObject } + }, + Autoplay = !shouldMiss, + PassCondition = () => ((PerfectModTestPlayer)Player).CheckFailed(shouldMiss && testData.FailOnMiss) + }); + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new PerfectModTestPlayer(); + + private class PerfectModTestPlayer : TestPlayer + { + public PerfectModTestPlayer() + : base(showResults: false) + { + } + + protected override bool CheckModsAllowFailure() => true; + + public bool CheckFailed(bool failed) + { + if (!failed) + return ScoreProcessor.HasCompleted.Value && !HealthProcessor.HasFailed; + + return HealthProcessor.HasFailed; + } + } + + protected class HitObjectTestData + { + public readonly HitObject HitObject; + public readonly bool FailOnMiss; + + public HitObjectTestData(HitObject hitObject, bool failOnMiss = true) + { + HitObject = hitObject; + FailOnMiss = failOnMiss; + } + } + } +} diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs new file mode 100644 index 0000000000..23b5ad0bd8 --- /dev/null +++ b/osu.Game/Tests/Visual/ModTestScene.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 JetBrains.Annotations; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual +{ + public abstract class ModTestScene : PlayerTestScene + { + protected sealed override bool HasCustomSteps => true; + + private ModTestData currentTestData; + + protected void CreateModTest(ModTestData testData) => CreateTest(() => + { + AddStep("set test data", () => currentTestData = testData); + }); + + public override void TearDownSteps() + { + AddUntilStep("test passed", () => + { + if (currentTestData == null) + return true; + + return currentTestData.PassCondition?.Invoke() ?? false; + }); + + base.TearDownSteps(); + } + + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestData?.Beatmap ?? base.CreateBeatmap(ruleset); + + protected sealed override TestPlayer CreatePlayer(Ruleset ruleset) + { + var mods = new List(SelectedMods.Value); + + if (currentTestData.Mod != null) + mods.Add(currentTestData.Mod); + if (currentTestData.Autoplay) + mods.Add(ruleset.GetAutoplayMod()); + + SelectedMods.Value = mods; + + return CreateModPlayer(ruleset); + } + + protected virtual TestPlayer CreateModPlayer(Ruleset ruleset) => new ModTestPlayer(AllowFail); + + protected class ModTestPlayer : TestPlayer + { + private readonly bool allowFail; + + protected override bool CheckModsAllowFailure() => allowFail; + + public ModTestPlayer(bool allowFail) + : base(false, false) + { + this.allowFail = allowFail; + } + } + + protected class ModTestData + { + /// + /// Whether to use a replay to simulate an auto-play. True by default. + /// + public bool Autoplay = true; + + /// + /// The beatmap for this test case. + /// + [CanBeNull] + public IBeatmap Beatmap; + + /// + /// The conditions that cause this test case to pass. + /// + [CanBeNull] + public Func PassCondition; + + /// + /// The this test case tests. + /// + public Mod Mod; + } + } +} diff --git a/osu.Game/Tests/Visual/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/MultiplayerTestScene.cs index ffb431b4d3..4d073f16f4 100644 --- a/osu.Game/Tests/Visual/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/MultiplayerTestScene.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tests.Visual public abstract class MultiplayerTestScene : ScreenTestScene { [Cached] - private readonly Bindable currentRoom = new Bindable(new Room()); + private readonly Bindable currentRoom = new Bindable(); protected Room Room { diff --git a/osu.Game/Tests/Visual/ManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs similarity index 89% rename from osu.Game/Tests/Visual/ManualInputManagerTestScene.cs rename to osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 86191609a4..64f4d7b95b 100644 --- a/osu.Game/Tests/Visual/ManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -8,12 +8,13 @@ using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual { - public abstract class ManualInputManagerTestScene : OsuTestScene + public abstract class OsuManualInputManagerTestScene : OsuTestScene { protected override Container Content => content; private readonly Container content; @@ -23,14 +24,18 @@ namespace osu.Game.Tests.Visual private readonly TriangleButton buttonTest; private readonly TriangleButton buttonLocal; - protected ManualInputManagerTestScene() + protected OsuManualInputManagerTestScene() { + MenuCursorContainer cursorContainer; + base.Content.AddRange(new Drawable[] { InputManager = new ManualInputManager { UseParentInput = true, - Child = content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }, + Child = new GlobalActionContainer(null) + .WithChild((cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) + .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both })) }, new Container { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 8926c76018..cb9ed40b00 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -20,6 +22,7 @@ using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; using osu.Game.Screens; using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps; @@ -36,7 +39,9 @@ namespace osu.Game.Tests.Visual protected new OsuScreenDependencies Dependencies { get; private set; } - private readonly Lazy localStorage; + private DrawableRulesetDependencies rulesetDependencies; + + private Lazy localStorage; protected Storage LocalStorage => localStorage.Value; private readonly Lazy contextFactory; @@ -64,7 +69,13 @@ namespace osu.Game.Tests.Visual protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - Dependencies = new OsuScreenDependencies(false, base.CreateChildDependencies(parent)); + var baseDependencies = base.CreateChildDependencies(parent); + + var providedRuleset = CreateRuleset(); + if (providedRuleset != null) + baseDependencies = rulesetDependencies = new DrawableRulesetDependencies(providedRuleset, baseDependencies); + + Dependencies = new OsuScreenDependencies(false, baseDependencies); Beatmap = Dependencies.Beatmap; Beatmap.SetDefault(); @@ -91,7 +102,7 @@ namespace osu.Game.Tests.Visual protected OsuTestScene() { - localStorage = new Lazy(() => new NativeStorage($"{GetType().Name}-{Guid.NewGuid()}")); + RecycleLocalStorage(); contextFactory = new Lazy(() => { var factory = new DatabaseContextFactory(LocalStorage); @@ -104,9 +115,35 @@ namespace osu.Game.Tests.Visual base.Content.Add(content = new DrawSizePreservingFillContainer()); } + public virtual void RecycleLocalStorage() + { + if (localStorage?.IsValueCreated == true) + { + try + { + localStorage.Value.DeleteDirectory("."); + } + catch + { + // we don't really care if this fails; it will just leave folders lying around from test runs. + } + } + + localStorage = new Lazy(() => new NativeStorage(Path.Combine(RuntimeInfo.StartupDirectory, $"{GetType().Name}-{Guid.NewGuid()}"))); + } + [Resolved] protected AudioManager Audio { get; private set; } + /// + /// Creates the ruleset to be used for this test scene. + /// + /// + /// When testing against ruleset-specific components, this method must be overriden to their corresponding ruleset. + /// + [CanBeNull] + protected virtual Ruleset CreateRuleset() => null; + protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => @@ -118,30 +155,22 @@ namespace osu.Game.Tests.Visual [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - Ruleset.Value = rulesets.AvailableRulesets.First(); + Ruleset.Value = CreateRuleset()?.RulesetInfo ?? rulesets.AvailableRulesets.First(); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + rulesetDependencies?.Dispose(); + if (Beatmap?.Value.TrackLoaded == true) Beatmap.Value.Track.Stop(); if (contextFactory.IsValueCreated) contextFactory.Value.ResetDatabase(); - if (localStorage.IsValueCreated) - { - try - { - localStorage.Value.DeleteDirectory("."); - } - catch - { - // we don't really care if this fails; it will just leave folders lying around from test runs. - } - } + RecycleLocalStorage(); } protected override ITestSceneTestRunner CreateRunner() => new OsuTestSceneTestRunner(); @@ -172,7 +201,7 @@ namespace osu.Game.Tests.Visual /// Audio manager. Required if a reference clock isn't provided. /// The length of the returned virtual track. public ClockBackedTestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, double length = 60000) - : base(beatmap, storyboard) + : base(beatmap, storyboard, audio) { if (referenceClock != null) { @@ -184,9 +213,9 @@ namespace osu.Game.Tests.Visual track = audio?.Tracks.GetVirtual(length); } - protected override void Dispose(bool isDisposing) + ~ClockBackedTestWorkingBeatmap() { - base.Dispose(isDisposing); + // Remove the track store from the audio manager store?.Dispose(); } @@ -224,15 +253,8 @@ namespace osu.Game.Tests.Visual { private readonly IFrameBasedClock referenceClock; - private readonly ManualClock clock = new ManualClock(); - private bool running; - /// - /// Local offset added to the reference clock to resolve correct time. - /// - private double offset; - public TrackVirtualManual(IFrameBasedClock referenceClock) { this.referenceClock = referenceClock; @@ -241,10 +263,10 @@ namespace osu.Game.Tests.Visual public override bool Seek(double seek) { - offset = Math.Clamp(seek, 0, Length); + accumulated = Math.Clamp(seek, 0, Length); lastReferenceTime = null; - return offset == seek; + return accumulated == seek; } public override void Start() @@ -263,9 +285,6 @@ namespace osu.Game.Tests.Visual if (running) { running = false; - // on stopping, the current value should be transferred out of the clock, as we can no longer rely on - // the referenceClock (which will still be counting time). - offset = clock.CurrentTime; lastReferenceTime = null; } } @@ -274,7 +293,9 @@ namespace osu.Game.Tests.Visual private double? lastReferenceTime; - public override double CurrentTime => clock.CurrentTime; + private double accumulated; + + public override double CurrentTime => Math.Min(accumulated, Length); protected override void UpdateState() { @@ -284,18 +305,12 @@ namespace osu.Game.Tests.Visual { double refTime = referenceClock.CurrentTime; - if (!lastReferenceTime.HasValue) - { - // if the clock just started running, the current value should be transferred to the offset - // (to zero the progression of time). - offset -= refTime; - } + if (lastReferenceTime.HasValue) + accumulated += (refTime - lastReferenceTime.Value) * Rate; lastReferenceTime = refTime; } - clock.CurrentTime = Math.Min((lastReferenceTime ?? 0) + offset, Length); - if (CurrentTime >= Length) { Stop(); diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 0688620b8e..c3d74f21aa 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -4,24 +4,21 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; namespace osu.Game.Tests.Visual { [Cached(Type = typeof(IPlacementHandler))] - public abstract class PlacementBlueprintTestScene : OsuTestScene, IPlacementHandler + public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler { - protected Container HitObjectContainer; + protected readonly Container HitObjectContainer; private PlacementBlueprint currentBlueprint; - private InputManager inputManager; - protected PlacementBlueprintTestScene() { Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); @@ -36,7 +33,7 @@ namespace osu.Game.Tests.Visual protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(new StopwatchClock()); + dependencies.CacheAs(new EditorClock()); return dependencies; } @@ -45,19 +42,25 @@ namespace osu.Game.Tests.Visual { base.LoadComplete(); - inputManager = GetContainingInputManager(); - Add(currentBlueprint = CreateBlueprint()); + ResetPlacement(); } public void BeginPlacement(HitObject hitObject) { } - public void EndPlacement(HitObject hitObject) + public void EndPlacement(HitObject hitObject, bool commit) { - AddHitObject(CreateHitObject(hitObject)); + if (commit) + AddHitObject(CreateHitObject(hitObject)); - Remove(currentBlueprint); + ResetPlacement(); + } + + protected void ResetPlacement() + { + if (currentBlueprint != null) + Remove(currentBlueprint); Add(currentBlueprint = CreateBlueprint()); } @@ -65,12 +68,16 @@ namespace osu.Game.Tests.Visual { } - protected override bool OnMouseMove(MouseMoveEvent e) + protected override void Update() { - currentBlueprint.UpdatePosition(e.ScreenSpaceMousePosition); - return true; + base.Update(); + + currentBlueprint.UpdatePosition(SnapForBlueprint(currentBlueprint)); } + protected virtual SnapResult SnapForBlueprint(PlacementBlueprint blueprint) => + new SnapResult(InputManager.CurrentState.Mouse.Position, null); + public override void Add(Drawable drawable) { base.Add(drawable); @@ -78,7 +85,7 @@ namespace osu.Game.Tests.Visual if (drawable is PlacementBlueprint blueprint) { blueprint.Show(); - blueprint.UpdatePosition(inputManager.CurrentState.Mouse.Position); + blueprint.UpdatePosition(SnapForBlueprint(blueprint)); } } diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index 3ed65bee61..2c46e7f6d3 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -1,27 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual { public abstract class PlayerTestScene : RateAdjustedBeatmapTestScene { - private readonly Ruleset ruleset; + /// + /// Whether custom test steps are provided. Custom tests should invoke to create the test steps. + /// + protected virtual bool HasCustomSteps { get; } = false; - protected Player Player; - - protected PlayerTestScene(Ruleset ruleset) - { - this.ruleset = ruleset; - } + protected TestPlayer Player; protected OsuConfigManager LocalConfig; @@ -33,9 +32,22 @@ namespace osu.Game.Tests.Visual } [SetUpSteps] - public virtual void SetUpSteps() + public override void SetUpSteps() { - AddStep(ruleset.RulesetInfo.Name, loadPlayer); + base.SetUpSteps(); + + if (!HasCustomSteps) + CreateTest(null); + } + + protected void CreateTest(Action action) + { + if (action != null && !HasCustomSteps) + throw new InvalidOperationException($"Cannot add custom test steps without {nameof(HasCustomSteps)} being set."); + + action?.Invoke(); + + AddStep(CreatePlayerRuleset().Description, LoadPlayer); AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); } @@ -43,11 +55,13 @@ namespace osu.Game.Tests.Visual protected virtual bool Autoplay => false; - private void loadPlayer() + protected void LoadPlayer() { + var ruleset = Ruleset.Value.CreateInstance(); var beatmap = CreateBeatmap(ruleset.RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(beatmap); + SelectedMods.Value = Array.Empty(); if (!AllowFail) { @@ -67,6 +81,14 @@ namespace osu.Game.Tests.Visual LoadScreen(Player); } - protected virtual Player CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false); + /// + /// Creates the ruleset for setting up the component. + /// + [NotNull] + protected abstract Ruleset CreatePlayerRuleset(); + + protected sealed override Ruleset CreateRuleset() => CreatePlayerRuleset(); + + protected virtual TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false); } } diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 707aa61283..33cc00e748 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Screens; namespace osu.Game.Tests.Visual @@ -10,7 +11,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 : ManualInputManagerTestScene + public abstract class ScreenTestScene : OsuManualInputManagerTestScene { protected readonly OsuScreenStack Stack; @@ -27,11 +28,23 @@ namespace osu.Game.Tests.Visual }); } - protected void LoadScreen(OsuScreen screen) + protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); + + [SetUpSteps] + public virtual void SetUpSteps() => addExitAllScreensStep(); + + [TearDownSteps] + public virtual void TearDownSteps() => addExitAllScreensStep(); + + private void addExitAllScreensStep() { - if (Stack.CurrentScreen != null) + AddUntilStep("exit all screens", () => + { + if (Stack.CurrentScreen == null) return true; + Stack.Exit(); - Stack.Push(screen); + return false; + }); } } } diff --git a/osu.Game/Tests/Visual/ScrollingTestContainer.cs b/osu.Game/Tests/Visual/ScrollingTestContainer.cs index bdad3d278c..994f23577d 100644 --- a/osu.Game/Tests/Visual/ScrollingTestContainer.cs +++ b/osu.Game/Tests/Visual/ScrollingTestContainer.cs @@ -30,6 +30,11 @@ namespace osu.Game.Tests.Visual set => scrollingInfo.TimeRange.Value = value; } + public ScrollingDirection Direction + { + set => scrollingInfo.Direction.Value = value; + } + public IScrollingInfo ScrollingInfo => scrollingInfo; [Cached(Type = typeof(IScrollingInfo))] @@ -42,19 +47,19 @@ namespace osu.Game.Tests.Visual public void Flip() => scrollingInfo.Direction.Value = scrollingInfo.Direction.Value == ScrollingDirection.Up ? ScrollingDirection.Down : ScrollingDirection.Up; - private class TestScrollingInfo : IScrollingInfo + public class TestScrollingInfo : IScrollingInfo { public readonly Bindable Direction = new Bindable(); IBindable IScrollingInfo.Direction => Direction; - public readonly Bindable TimeRange = new Bindable(1000) { Value = 1000 }; + public readonly Bindable TimeRange = new BindableDouble(1000) { Value = 1000 }; IBindable IScrollingInfo.TimeRange => TimeRange; public readonly TestScrollAlgorithm Algorithm = new TestScrollAlgorithm(); IScrollAlgorithm IScrollingInfo.Algorithm => Algorithm; } - private class TestScrollAlgorithm : IScrollAlgorithm + public class TestScrollAlgorithm : IScrollAlgorithm { public readonly SortedList ControlPoints = new SortedList(); @@ -86,8 +91,8 @@ namespace osu.Game.Tests.Visual } } - public double GetDisplayStartTime(double time, double timeRange) - => implementation.GetDisplayStartTime(time, timeRange); + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + => implementation.GetDisplayStartTime(originTime, offset, timeRange, scrollLength); public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) => implementation.GetLength(startTime, endTime, timeRange, scrollLength); diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index 3233ee160d..1176361679 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.Edit; namespace osu.Game.Tests.Visual { - public abstract class SelectionBlueprintTestScene : ManualInputManagerTestScene + public abstract class SelectionBlueprintTestScene : OsuManualInputManagerTestScene { protected override Container Content => content ?? base.Content; private readonly Container content; @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual }); } - protected void AddBlueprint(SelectionBlueprint blueprint) + protected void AddBlueprint(OverlaySelectionBlueprint blueprint) { Add(blueprint.With(d => { diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs new file mode 100644 index 0000000000..81c13112d0 --- /dev/null +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +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.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual +{ + public abstract class SkinnableTestScene : OsuGridTestScene + { + private Skin metricsSkin; + private Skin defaultSkin; + private Skin specialSkin; + private Skin oldSkin; + + protected SkinnableTestScene() + : base(2, 3) + { + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, SkinManager skinManager) + { + var dllStore = new DllResourceStore(DynamicCompilationOriginal.GetType().Assembly); + + metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), audio, true); + defaultSkin = skinManager.GetSkin(DefaultLegacySkin.Info); + specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), audio, true); + oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), audio, true); + } + + private readonly List createdDrawables = new List(); + + public void SetContents(Func creationFunction) + { + createdDrawables.Clear(); + + 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); + } + + protected IEnumerable CreatedDrawables => createdDrawables; + + private Drawable createProvider(Skin skin, Func creationFunction, IBeatmap beatmap) + { + var created = creationFunction(); + createdDrawables.Add(created); + + var autoSize = created.RelativeSizeAxes == Axes.None; + + var mainProvider = new SkinProvidingContainer(skin) + { + RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None, + AutoSizeAxes = autoSize ? Axes.Both : Axes.None, + }; + + return new Container + { + RelativeSizeAxes = Axes.Both, + BorderColour = Color4.White, + BorderThickness = 5, + Masking = true, + + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = skin?.SkinInfo?.Name ?? "none", + Scale = new Vector2(1.5f), + Padding = new MarginPadding(5), + }, + new Container + { + RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None, + AutoSizeAxes = autoSize ? Axes.Both : Axes.None, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new OutlineBox { Alpha = autoSize ? 1 : 0 }, + mainProvider.WithChild( + new SkinProvidingContainer(Ruleset.Value.CreateInstance().CreateLegacySkinProvider(mainProvider, beatmap)) + { + Child = created, + RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None, + AutoSizeAxes = autoSize ? Axes.Both : Axes.None, + } + ) + } + }, + } + }; + } + + /// + /// Creates the ruleset for adding the corresponding skin transforming component. + /// + [NotNull] + protected abstract Ruleset CreateRulesetForSkinProvider(); + + protected sealed override Ruleset CreateRuleset() => CreateRulesetForSkinProvider(); + + protected virtual IBeatmap CreateBeatmapForSkinProvider() => CreateWorkingBeatmap(Ruleset.Value).GetPlayableBeatmap(Ruleset.Value); + + private class OutlineBox : CompositeDrawable + { + public OutlineBox() + { + BorderColour = Color4.IndianRed; + BorderThickness = 5; + Masking = true; + RelativeSizeAxes = Axes.Both; + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.Brown, + AlwaysPresent = true + }; + } + } + + private class TestLegacySkin : LegacySkin + { + private readonly bool extrapolateAnimations; + + public TestLegacySkin(SkinInfo skin, IResourceStore storage, AudioManager audioManager, bool extrapolateAnimations) + : base(skin, storage, audioManager, "skin.ini") + { + this.extrapolateAnimations = extrapolateAnimations; + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + // extrapolate frames to test longer animations + if (extrapolateAnimations) + { + var match = Regex.Match(componentName, "-([0-9]*)"); + + if (match.Length > 0 && int.TryParse(match.Groups[1].Value, out var number) && number < 60) + return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}"), wrapModeS, wrapModeT); + } + + return base.GetTexture(componentName, wrapModeS, wrapModeT); + } + } + } +} diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 8e3821f1a0..f016d29f38 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -1,23 +1,51 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual { + /// + /// A player that exposes many components that would otherwise not be available, for testing purposes. + /// public class TestPlayer : Player { protected override bool PauseOnFocusLost { get; } public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; + /// + /// Mods from *player* (not OsuScreen). + /// + public new Bindable> Mods => base.Mods; + + public new HUDOverlay HUDOverlay => base.HUDOverlay; + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public new HealthProcessor HealthProcessor => base.HealthProcessor; + + public readonly List Results = new List(); + public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(allowPause, showResults) { PauseOnFocusLost = pauseOnFocusLost; } + + [BackgroundDependencyLoader] + private void load() + { + ScoreProcessor.NewJudgement += r => Results.Add(r); + } } } diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index f76cba7f41..ebb9995c66 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -7,8 +7,8 @@ using Newtonsoft.Json; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; -using osu.Framework.IO.Network; using osu.Framework.Platform; +using osu.Game.Online.API; using osu.Game.Overlays.Notifications; namespace osu.Game.Updater @@ -20,23 +20,21 @@ namespace osu.Game.Updater public class SimpleUpdateManager : UpdateManager { private string version; - private GameHost host; + + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(OsuGameBase game, GameHost host) + private void load(OsuGameBase game) { - this.host = host; version = game.Version; - - if (game.IsDeployedBuild) - Schedule(() => Task.Run(checkForUpdateAsync)); } - private async void checkForUpdateAsync() + protected override async Task PerformUpdateCheck() { try { - var releases = new JsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); + var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); await releases.PerformAsync(); @@ -77,6 +75,10 @@ namespace osu.Game.Updater bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip")); break; + case RuntimeInfo.Platform.Linux: + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".AppImage")); + break; + case RuntimeInfo.Platform.Android: // on our testing device this causes the download to magically disappear. //bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".apk")); diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 48505a9891..61775a26b7 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -16,6 +17,13 @@ namespace osu.Game.Updater /// public class UpdateManager : CompositeDrawable { + /// + /// Whether this UpdateManager should be or is capable of checking for updates. + /// + public bool CanCheckForUpdate => game.IsDeployedBuild && + // only implementations will actually check for updates. + GetType() != typeof(UpdateManager); + [Resolved] private OsuConfigManager config { get; set; } @@ -29,19 +37,46 @@ namespace osu.Game.Updater { base.LoadComplete(); + Schedule(() => Task.Run(CheckForUpdateAsync)); + var version = game.Version; + var lastVersion = config.Get(OsuSetting.Version); if (game.IsDeployedBuild && version != lastVersion) { - config.Set(OsuSetting.Version, version); - // only show a notification if we've previously saved a version to the config file (ie. not the first run). if (!string.IsNullOrEmpty(lastVersion)) Notifications.Post(new UpdateCompleteNotification(version)); } + + // debug / local compilations will reset to a non-release string. + // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). + config.Set(OsuSetting.Version, version); } + private readonly object updateTaskLock = new object(); + + private Task updateCheckTask; + + public async Task CheckForUpdateAsync() + { + if (!CanCheckForUpdate) + return; + + Task waitTask; + + lock (updateTaskLock) + waitTask = (updateCheckTask ??= PerformUpdateCheck()); + + await waitTask; + + lock (updateTaskLock) + updateCheckTask = null; + } + + protected virtual Task PerformUpdateCheck() => Task.CompletedTask; + private class UpdateCompleteNotification : SimpleNotification { private readonly string version; diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index ee9af15863..42d2dbb1c6 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -43,7 +43,7 @@ namespace osu.Game.Users.Drawables Texture texture = null; if (user != null && user.Id > 1) texture = textures.Get($@"https://a.ppy.sh/{user.Id}"); - if (texture == null) texture = textures.Get(@"Online/avatar-guest"); + texture ??= textures.Get(@"Online/avatar-guest"); ClickableArea clickableArea; Add(clickableArea = new ClickableArea @@ -68,13 +68,13 @@ namespace osu.Game.Users.Drawables if (!OpenOnClick.Value) return; - if (user != null) + if (user?.Id > 1) game?.ShowUser(user.Id); } private class ClickableArea : OsuClickableContainer { - public override string TooltipText => Enabled.Value ? @"View Profile" : null; + public override string TooltipText => Enabled.Value ? @"view profile" : null; protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index 59fbb5f910..171462f3fc 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.cs @@ -43,6 +43,8 @@ namespace osu.Game.Users.Drawables set => base.EdgeEffect = value; } + protected override double LoadDelay => 200; + /// /// Whether to show a default guest representation on null user (as opposed to nothing). /// diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index 5d0ffd5a67..f8bb8f4c6a 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -9,7 +9,7 @@ using osu.Framework.Bindables; namespace osu.Game.Users { - public class User + public class User : IEquatable { [JsonProperty(@"id")] public long Id = 1; @@ -69,6 +69,9 @@ namespace osu.Game.Users [JsonProperty(@"support_level")] public int SupportLevel; + [JsonProperty(@"current_mode_rank")] + public int? CurrentModeRank; + [JsonProperty(@"is_gmt")] public bool IsGMT; @@ -170,8 +173,27 @@ namespace osu.Game.Users public int Available; } + private UserStatistics statistics; + [JsonProperty(@"statistics")] - public UserStatistics Statistics; + public UserStatistics Statistics + { + get => statistics ??= new UserStatistics(); + set + { + if (statistics != null) + // we may already have rank history populated + value.RankHistory = statistics.RankHistory; + + statistics = value; + } + } + + [JsonProperty(@"rankHistory")] + private RankHistoryData rankHistory + { + set => statistics.RankHistory = value; + } public class RankHistoryData { @@ -182,12 +204,6 @@ namespace osu.Game.Users public int[] Data; } - [JsonProperty(@"rankHistory")] - private RankHistoryData rankHistory - { - set => Statistics.RankHistory = value; - } - [JsonProperty("badges")] public Badge[] Badges; @@ -203,6 +219,21 @@ namespace osu.Game.Users public int ID; } + [JsonProperty("monthly_playcounts")] + public UserHistoryCount[] MonthlyPlaycounts; + + [JsonProperty("replays_watched_counts")] + public UserHistoryCount[] ReplaysWatchedCounts; + + public class UserHistoryCount + { + [JsonProperty("start_date")] + public DateTime Date; + + [JsonProperty("count")] + public long Count; + } + public override string ToString() => Username; /// @@ -229,5 +260,13 @@ namespace osu.Game.Users [Description("Touch Screen")] Touch, } + + public bool Equals(User other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Id == other.Id; + } } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 8030fc55a2..3c9f201805 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -3,6 +3,7 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Rulesets; using osuTK.Graphics; namespace osu.Game.Users @@ -44,15 +45,15 @@ namespace osu.Game.Users { public BeatmapInfo Beatmap { get; } - public Rulesets.RulesetInfo Ruleset { get; } + public RulesetInfo Ruleset { get; } - public SoloGame(BeatmapInfo info, Rulesets.RulesetInfo ruleset) + public SoloGame(BeatmapInfo info, RulesetInfo ruleset) { Beatmap = info; Ruleset = ruleset; } - public override string Status => @"Playing alone"; + public override string Status => Ruleset.CreateInstance().PlayingVerb; } public class Spectating : UserActivity diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs new file mode 100644 index 0000000000..e62a834d6d --- /dev/null +++ b/osu.Game/Users/UserGridPanel.cs @@ -0,0 +1,139 @@ +// Copyright (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.Overlays.Profile.Header.Components; +using osuTK; + +namespace osu.Game.Users +{ + public class UserGridPanel : UserPanel + { + private const int margin = 10; + + public UserGridPanel(User user) + : base(user) + { + Height = 120; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.FadeTo(0.3f); + } + + protected override Drawable CreateLayout() + { + FillFlowContainer details; + + var layout = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(margin), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, margin), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + CreateAvatar().With(avatar => + { + avatar.Size = new Vector2(60); + avatar.Masking = true; + avatar.CornerRadius = 6; + }), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = margin }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + details = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6), + Children = new Drawable[] + { + CreateFlag(), + } + } + }, + new Drawable[] + { + CreateUsername().With(username => + { + username.Anchor = Anchor.CentreLeft; + username.Origin = Anchor.CentreLeft; + }) + } + } + } + } + }, + new[] + { + Empty(), + Empty() + }, + new Drawable[] + { + CreateStatusIcon().With(icon => + { + icon.Anchor = Anchor.Centre; + icon.Origin = Anchor.Centre; + }), + CreateStatusMessage(false).With(message => + { + message.Anchor = Anchor.CentreLeft; + message.Origin = Anchor.CentreLeft; + message.Margin = new MarginPadding { Left = margin }; + }) + } + } + } + }; + + if (User.IsSupporter) + { + details.Add(new SupporterIcon + { + Height = 26, + SupportLevel = User.SupportLevel + }); + } + + return layout; + } + } +} diff --git a/osu.Game/Users/UserListPanel.cs b/osu.Game/Users/UserListPanel.cs new file mode 100644 index 0000000000..1c3ae20577 --- /dev/null +++ b/osu.Game/Users/UserListPanel.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Colour; +using osu.Framework.Extensions.Color4Extensions; +using osuTK.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Game.Overlays.Profile.Header.Components; + +namespace osu.Game.Users +{ + public class UserListPanel : UserPanel + { + public UserListPanel(User user) + : base(user) + { + RelativeSizeAxes = Axes.X; + Height = 40; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.Width = 0.5f; + Background.Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(1), Color4.White.Opacity(0.3f)); + } + + protected override Drawable CreateLayout() + { + FillFlowContainer details; + + var layout = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + details = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + CreateAvatar().With(avatar => + { + avatar.Anchor = Anchor.CentreLeft; + avatar.Origin = Anchor.CentreLeft; + avatar.Size = new Vector2(40); + }), + CreateFlag().With(flag => + { + flag.Anchor = Anchor.CentreLeft; + flag.Origin = Anchor.CentreLeft; + }), + CreateUsername().With(username => + { + username.Anchor = Anchor.CentreLeft; + username.Origin = Anchor.CentreLeft; + }) + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Right = 10 }, + Children = new Drawable[] + { + CreateStatusIcon().With(icon => + { + icon.Anchor = Anchor.CentreRight; + icon.Origin = Anchor.CentreRight; + }), + CreateStatusMessage(true).With(message => + { + message.Anchor = Anchor.CentreRight; + message.Origin = Anchor.CentreRight; + }) + } + } + } + }; + + if (User.IsSupporter) + { + details.Add(new SupporterIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 20, + SupportLevel = User.SupportLevel + }); + } + + return layout; + } + } +} diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 6ddbc13a06..6f59f9e443 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -3,10 +3,8 @@ using System; using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -16,30 +14,17 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Containers; -using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users.Drawables; +using JetBrains.Annotations; +using osu.Framework.Input.Events; namespace osu.Game.Users { - public class UserPanel : OsuClickableContainer, IHasContextMenu + public abstract class UserPanel : OsuClickableContainer, IHasContextMenu { - private readonly User user; - private const float height = 100; - private const float content_padding = 10; - private const float status_height = 30; - - [Resolved(canBeNull: true)] - private OsuColour colours { get; set; } - - private Container statusBar; - private Box statusBg; - private OsuSpriteText statusMessage; - - private Container content; - protected override Container Content => content; + public readonly User User; public readonly Bindable Status = new Bindable(); @@ -47,166 +32,67 @@ namespace osu.Game.Users public new Action Action; - protected Action ViewProfile; + protected Action ViewProfile { get; private set; } - public UserPanel(User user) + protected DelayedLoadUnloadWrapper Background { get; private set; } + + protected TextFlowContainer LastVisitMessage { get; private set; } + + private SpriteIcon statusIcon; + private OsuSpriteText statusMessage; + + protected UserPanel(User user) { if (user == null) throw new ArgumentNullException(nameof(user)); - this.user = user; - - Height = height - status_height; + User = user; } - [BackgroundDependencyLoader(permitNulls: true)] - private void load(UserProfileOverlay profile) + [Resolved(canBeNull: true)] + private UserProfileOverlay profileOverlay { get; set; } + + [Resolved(canBeNull: true)] + private OverlayColourProvider colourProvider { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() { - if (colours == null) - throw new InvalidOperationException($"{nameof(colours)} not initialized!"); + Masking = true; + BorderColour = colourProvider?.Light1 ?? colours.GreyVioletLighter; - FillFlowContainer infoContainer; - - AddInternal(content = new Container + AddRange(new[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, - EdgeEffect = new EdgeEffectParameters + new Box { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background5 ?? colours.Gray1 }, - - Children = new Drawable[] + Background = new DelayedLoadUnloadWrapper(() => new UserCoverBackground { - new DelayedLoadUnloadWrapper(() => new UserCoverBackground - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - User = user, - }, 300, 5000) - { - RelativeSizeAxes = Axes.Both, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.7f), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Top = content_padding, Horizontal = content_padding }, - Children = new Drawable[] - { - new UpdateableAvatar - { - Size = new Vector2(height - status_height - content_padding * 2), - User = user, - Masking = true, - CornerRadius = 5, - OpenOnClick = { Value = false }, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, - }, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = height - status_height - content_padding }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = user.Username, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 18, italics: true), - }, - infoContainer = new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - AutoSizeAxes = Axes.X, - Height = 20f, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5f, 0f), - Children = new Drawable[] - { - new UpdateableFlag(user.Country) - { - Width = 30f, - RelativeSizeAxes = Axes.Y, - }, - }, - }, - }, - }, - }, - }, - statusBar = new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Alpha = 0f, - Children = new Drawable[] - { - statusBg = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f, - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f, 0f), - Children = new Drawable[] - { - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Regular.Circle, - Shadow = true, - Size = new Vector2(14), - }, - statusMessage = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold), - }, - }, - }, - }, - }, - } + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + User = User, + }, 300, 5000) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + }, + CreateLayout() }); - if (user.IsSupporter) - { - infoContainer.Add(new SupporterIcon - { - Height = 20f, - SupportLevel = user.SupportLevel - }); - } - Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); base.Action = ViewProfile = () => { Action?.Invoke(); - profile?.ShowUser(user); + profileOverlay?.ShowUser(User); }; } @@ -214,35 +100,116 @@ namespace osu.Game.Users { base.LoadComplete(); Status.TriggerChange(); + + // Colour should be applied immediately on first load. + statusIcon.FinishTransforms(); + } + + protected override bool OnHover(HoverEvent e) + { + BorderThickness = 2; + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + BorderThickness = 0; + base.OnHoverLost(e); + } + + [NotNull] + protected abstract Drawable CreateLayout(); + + protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar + { + User = User, + OpenOnClick = { Value = false } + }; + + protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) + { + Size = new Vector2(39, 26) + }; + + protected OsuSpriteText CreateUsername() => new OsuSpriteText + { + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + Shadow = false, + Text = User.Username, + }; + + protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon + { + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(25) + }; + + protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) + { + var statusContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical + }; + + var alignment = rightAlignedChildren ? Anchor.CentreRight : Anchor.CentreLeft; + + statusContainer.Add(LastVisitMessage = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)).With(text => + { + text.Anchor = alignment; + text.Origin = alignment; + text.AutoSizeAxes = Axes.Both; + text.Alpha = 0; + + if (User.LastVisit.HasValue) + { + text.AddText(@"Last seen "); + text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) + { + Shadow = false + }); + } + })); + + statusContainer.Add(statusMessage = new OsuSpriteText + { + Anchor = alignment, + Origin = alignment, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }); + + return statusContainer; } private void displayStatus(UserStatus status, UserActivity activity = null) { - const float transition_duration = 500; + if (status != null) + { + LastVisitMessage.FadeTo(status is UserStatusOffline && User.LastVisit.HasValue ? 1 : 0); - if (status == null) - { - statusBar.ResizeHeightTo(0f, transition_duration, Easing.OutQuint); - statusBar.FadeOut(transition_duration, Easing.OutQuint); - this.ResizeHeightTo(height - status_height, transition_duration, Easing.OutQuint); - } - else - { - statusBar.ResizeHeightTo(status_height, transition_duration, Easing.OutQuint); - statusBar.FadeIn(transition_duration, Easing.OutQuint); - this.ResizeHeightTo(height, transition_duration, Easing.OutQuint); + // 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; + statusIcon.FadeColour(activity.GetAppropriateColour(colours), 500, Easing.OutQuint); + return; + } + + // Otherwise use only status + statusMessage.Text = status.Message; + statusIcon.FadeColour(status.GetAppropriateColour(colours), 500, Easing.OutQuint); + + return; } - if (status is UserStatusOnline && activity != null) + // Fallback to web status if local one is null + if (User.IsOnline) { - statusMessage.Text = activity.Status; - statusBg.FadeColour(activity.GetAppropriateColour(colours), 500, Easing.OutQuint); - } - else - { - statusMessage.Text = status?.Message; - statusBg.FadeColour(status?.GetAppropriateColour(colours) ?? colours.Gray5, 500, Easing.OutQuint); + Status.Value = new UserStatusOnline(); + return; } + + Status.Value = new UserStatusOffline(); } public MenuItem[] ContextMenuItems => new MenuItem[] diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 24f1f0b30e..8b7699d0ad 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -4,6 +4,7 @@ using System; using Newtonsoft.Json; using osu.Game.Scoring; +using osu.Game.Utils; using static osu.Game.Users.User; namespace osu.Game.Users @@ -29,7 +30,7 @@ namespace osu.Game.Users public decimal? PP; [JsonProperty(@"pp_rank")] // the API sometimes only returns this value in condensed user responses - private int rank + private int? rank { set => Ranks.Global = value; } @@ -43,6 +44,9 @@ namespace osu.Game.Users [JsonProperty(@"hit_accuracy")] public decimal Accuracy; + [JsonIgnore] + public string DisplayAccuracy => Accuracy.FormatAccuracy(); + [JsonProperty(@"play_count")] public int PlayCount; @@ -67,13 +71,13 @@ namespace osu.Game.Users public struct Grades { [JsonProperty(@"ssh")] - public int SSPlus; + public int? SSPlus; [JsonProperty(@"ss")] public int SS; [JsonProperty(@"sh")] - public int SPlus; + public int? SPlus; [JsonProperty(@"s")] public int S; @@ -88,13 +92,13 @@ namespace osu.Game.Users switch (rank) { case ScoreRank.XH: - return SSPlus; + return SSPlus ?? 0; case ScoreRank.X: return SS; case ScoreRank.SH: - return SPlus; + return SPlus ?? 0; case ScoreRank.S: return S; diff --git a/osu.Game/Users/UserStatus.cs b/osu.Game/Users/UserStatus.cs index cf372560af..21c18413f4 100644 --- a/osu.Game/Users/UserStatus.cs +++ b/osu.Game/Users/UserStatus.cs @@ -15,7 +15,7 @@ namespace osu.Game.Users public class UserStatusOnline : UserStatus { public override string Message => @"Online"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.BlueDarker; + public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenLight; } public abstract class UserStatusBusy : UserStatusOnline @@ -26,7 +26,7 @@ namespace osu.Game.Users public class UserStatusOffline : UserStatus { public override string Message => @"Offline"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Gray7; + public override Color4 GetAppropriateColour(OsuColour colours) => Color4.Black; } public class UserStatusDoNotDisturb : UserStatus diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs new file mode 100644 index 0000000000..f2ab99f4b7 --- /dev/null +++ b/osu.Game/Utils/FormatUtils.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. + +namespace osu.Game.Utils +{ + public static class FormatUtils + { + /// + /// Turns the provided accuracy into a percentage with 2 decimal places. + /// + /// The accuracy to be formatted + /// formatted accuracy in percentage + public static string FormatAccuracy(this double accuracy) => $"{accuracy:0.00%}"; + + /// + /// Turns the provided accuracy into a percentage with 2 decimal places. + /// + /// The accuracy to be formatted + /// formatted accuracy in percentage + public static string FormatAccuracy(this decimal accuracy) => $"{accuracy:0.00}%"; + } +} diff --git a/osu.Game/Utils/OrderAttribute.cs b/osu.Game/Utils/OrderAttribute.cs new file mode 100644 index 0000000000..aded7f9814 --- /dev/null +++ b/osu.Game/Utils/OrderAttribute.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 System.Linq; + +namespace osu.Game.Utils +{ + public static class OrderAttributeUtils + { + /// + /// Get values of an enum in order. Supports custom ordering via . + /// + public static IEnumerable GetValuesInOrder() + { + var type = typeof(T); + + if (!type.IsEnum) + throw new InvalidOperationException("T must be an enum"); + + IEnumerable items = (T[])Enum.GetValues(type); + + if (Attribute.GetCustomAttribute(type, typeof(HasOrderedElementsAttribute)) == null) + return items; + + return items.OrderBy(i => + { + if (type.GetField(i.ToString()).GetCustomAttributes(typeof(OrderAttribute), false).FirstOrDefault() is OrderAttribute attr) + return attr.Order; + + throw new ArgumentException($"Not all values of {nameof(T)} have {nameof(OrderAttribute)} specified."); + }); + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class OrderAttribute : Attribute + { + public readonly int Order; + + public OrderAttribute(int order) + { + Order = order; + } + } + + [AttributeUsage(AttributeTargets.Enum)] + public class HasOrderedElementsAttribute : Attribute + { + } +} diff --git a/osu.Game/Utils/PeriodTracker.cs b/osu.Game/Utils/PeriodTracker.cs new file mode 100644 index 0000000000..ba77702247 --- /dev/null +++ b/osu.Game/Utils/PeriodTracker.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Utils +{ + /// + /// Represents a tracking component used for whether a specific time instant falls into any of the provided periods. + /// + public class PeriodTracker + { + private readonly List periods; + private int nearestIndex; + + public PeriodTracker(IEnumerable periods) + { + this.periods = periods.OrderBy(period => period.Start).ToList(); + } + + /// + /// Whether the provided time is in any of the added periods. + /// + /// The time value to check. + public bool IsInAny(double time) + { + if (periods.Count == 0) + return false; + + if (time > periods[nearestIndex].End) + { + while (time > periods[nearestIndex].End && nearestIndex < periods.Count - 1) + nearestIndex++; + } + else + { + while (time < periods[nearestIndex].Start && nearestIndex > 0) + nearestIndex--; + } + + var nearest = periods[nearestIndex]; + return time >= nearest.Start && time <= nearest.End; + } + } + + public readonly struct Period + { + /// + /// The start time of this period. + /// + public readonly double Start; + + /// + /// The end time of this period. + /// + public readonly double End; + + public Period(double start, double end) + { + if (start >= end) + throw new ArgumentException($"Invalid period provided, {nameof(start)} must be less than {nameof(end)}"); + + Start = start; + End = end; + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 565608b40f..2f3d08c528 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,14 +18,16 @@ - + + + - - - - + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 60355b8592..2bb3914c25 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -49,9 +49,6 @@ true 28126 - - true - Static @@ -73,20 +70,21 @@ - - + + - + + - - + + - + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index e5ff4aec95..3a16f81530 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -11,12 +11,5 @@ namespace osu.iOS public class OsuGameIOS : OsuGame { public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); - - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(new UpdateManager()); - } } } diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index d60a3475e7..1e9a21865d 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -7,6 +7,7 @@ {3F082D0B-A964-43D7-BDF7-C256D76A50D0} osu.iOS osu.iOS + false @@ -116,4 +117,4 @@ - \ No newline at end of file + diff --git a/osu.sln b/osu.sln index 79823848f0..1d64f6ff10 100644 --- a/osu.sln +++ b/osu.sln @@ -65,6 +65,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution osu.TestProject.props = osu.TestProject.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Benchmarks", "osu.Game.Benchmarks\osu.Game.Benchmarks.csproj", "{93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -399,6 +401,18 @@ Global {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.Build.0 = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.ActiveCfg = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.Build.0 = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 12571be31d..29ca385275 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -19,8 +19,8 @@ HINT DO_NOT_SHOW HINT - HINT - HINT + WARNING + WARNING WARNING WARNING WARNING @@ -60,6 +60,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -105,6 +106,8 @@ HINT WARNING WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -138,6 +141,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -196,6 +200,7 @@ WARNING WARNING HINT + WARNING DO_NOT_SHOW DO_NOT_SHOW DO_NOT_SHOW @@ -222,6 +227,7 @@ WARNING WARNING WARNING + WARNING True WARNING @@ -245,7 +251,7 @@ RequiredForMultiline Explicit ExpressionBody - ExpressionBody + BlockBody True NEXT_LINE True @@ -298,6 +304,7 @@ GL GLSL HID + HTML HUD ID IL @@ -765,6 +772,7 @@ See the LICENCE file in the repository root for full licence text. <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True True True @@ -775,6 +783,7 @@ See the LICENCE file in the repository root for full licence text. True True True + TestFolder True True o!f – Object Initializer: Anchor&Origin @@ -903,14 +912,17 @@ private void load() True True True + True True True True True True True + True True True True + True True True