diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index b51ecb4f7e..97fcb52ab1 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -2,12 +2,6 @@
"version": 1,
"isRoot": true,
"tools": {
- "cake.tool": {
- "version": "0.35.0",
- "commands": [
- "dotnet-cake"
- ]
- },
"dotnet-format": {
"version": "3.1.37601",
"commands": [
@@ -20,20 +14,20 @@
"jb"
]
},
- "nvika": {
- "version": "2.0.0",
+ "smoogipoo.nvika": {
+ "version": "1.0.1",
"commands": [
"nvika"
]
},
"codefilesanity": {
- "version": "15.0.0",
+ "version": "0.0.36",
"commands": [
"CodeFileSanity"
]
},
"ppy.localisationanalyser.tools": {
- "version": "2021.524.0",
+ "version": "2021.705.0",
"commands": [
"localisation"
]
diff --git a/.editorconfig b/.editorconfig
index f4d7e08d08..19bd89c52f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -157,7 +157,7 @@ csharp_style_unused_value_assignment_preference = discard_variable:warning
#Style - variable declaration
csharp_style_inlined_variable_declaration = true:warning
-csharp_style_deconstructed_variable_declaration = true:warning
+csharp_style_deconstructed_variable_declaration = false:silent
#Style - other C# 7.x features
dotnet_style_prefer_inferred_tuple_names = true:warning
@@ -168,8 +168,8 @@ dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
#Style - C# 8 features
csharp_prefer_static_local_function = true:warning
csharp_prefer_simple_using_statement = true:silent
-csharp_style_prefer_index_operator = true:warning
-csharp_style_prefer_range_operator = true:warning
+csharp_style_prefer_index_operator = false:silent
+csharp_style_prefer_range_operator = false:silent
csharp_style_prefer_switch_expression = false:none
#Supressing roslyn built-in analyzers
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000..29cbdd2d37
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,94 @@
+on: [push, pull_request]
+name: Continuous Integration
+
+jobs:
+ test:
+ name: Test
+ runs-on: ${{matrix.os.fullname}}
+ env:
+ OSU_EXECUTION_MODE: ${{matrix.threadingMode}}
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - { prettyname: Windows, fullname: windows-latest }
+ - { prettyname: macOS, fullname: macos-latest }
+ - { prettyname: Linux, fullname: ubuntu-latest }
+ threadingMode: ['SingleThread', 'MultiThreaded']
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Install .NET 5.0.x
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: "5.0.x"
+
+ # FIXME: libavformat is not included in Ubuntu. Let's fix that.
+ # https://github.com/ppy/osu-framework/issues/4349
+ # Remove this once https://github.com/actions/virtual-environments/issues/3306 has been resolved.
+ - name: Install libavformat-dev
+ if: ${{matrix.os.fullname == 'ubuntu-latest'}}
+ run: |
+ sudo apt-get update && \
+ sudo apt-get -y install libavformat-dev
+
+ - name: Compile
+ run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
+
+ - name: Test
+ run: dotnet test $pwd/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
+ shell: pwsh
+
+ # Attempt to upload results even if test fails.
+ # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
+ - name: Upload Test Results
+ uses: actions/upload-artifact@v2
+ if: ${{ always() }}
+ with:
+ name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
+ path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx
+
+ inspect-code:
+ name: Code Quality
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ # FIXME: Tools won't run in .NET 5.0 unless you install 3.1.x LTS side by side.
+ # https://itnext.io/how-to-support-multiple-net-sdks-in-github-actions-workflows-b988daa884e
+ - name: Install .NET 3.1.x LTS
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: "3.1.x"
+
+ - name: Install .NET 5.0.x
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: "5.0.x"
+
+ - name: Restore Tools
+ run: dotnet tool restore
+
+ - name: Restore Packages
+ run: dotnet restore
+
+ - name: CodeFileSanity
+ run: |
+ # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround.
+ # FIXME: Suppress warnings from templates project
+ dotnet codefilesanity | while read -r line; do
+ echo "::warning::$line"
+ done
+
+ # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded.
+ # - name: .NET Format (Dry Run)
+ # run: dotnet format --dry-run --check
+
+ - name: InspectCode
+ run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --output=$(pwd)/inspectcodereport.xml --cachesDir=$(pwd)/inspectcode --verbosity=WARN
+
+ - name: NVika
+ run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors
diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml
new file mode 100644
index 0000000000..e0ccd50989
--- /dev/null
+++ b/.github/workflows/report-nunit.yml
@@ -0,0 +1,32 @@
+# This is a workaround to allow PRs to report their coverage. This will run inside the base repository.
+# See:
+# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories
+# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token
+name: Annotate CI run with test results
+on:
+ workflow_run:
+ workflows: ["Continuous Integration"]
+ types:
+ - completed
+jobs:
+ annotate:
+ name: Annotate CI run with test results
+ runs-on: ubuntu-latest
+ if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - { prettyname: Windows }
+ - { prettyname: macOS }
+ - { prettyname: Linux }
+ threadingMode: ['SingleThread', 'MultiThreaded']
+ timeout-minutes: 5
+ steps:
+ - name: Annotate CI run with test results
+ uses: dorny/test-reporter@v1.4.2
+ with:
+ artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
+ name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}})
+ path: "*.trx"
+ reporter: dotnet-trx
diff --git a/.gitignore b/.gitignore
index d122d25054..de6a3ac848 100644
--- a/.gitignore
+++ b/.gitignore
@@ -336,3 +336,6 @@ inspectcode
/BenchmarkDotNet.Artifacts
*.GeneratedMSBuildEditorConfig.editorconfig
+
+# Fody (pulled in by Realm) - schema file
+FodyWeavers.xsd
diff --git a/.idea/.idea.osu.Desktop/.idea/dataSources.xml b/.idea/.idea.osu.Desktop/.idea/dataSources.xml
deleted file mode 100644
index 10f8c1c84d..0000000000
--- a/.idea/.idea.osu.Desktop/.idea/dataSources.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- sqlite.xerial
- true
- org.sqlite.JDBC
- jdbc:sqlite:$USER_HOME$/.local/share/osu/client.db
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index afd997f91d..1b590008cd 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -113,20 +113,6 @@
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build benchmarks",
"console": "internalConsole"
- },
- {
- "name": "Cake: Debug Script",
- "type": "coreclr",
- "request": "launch",
- "program": "${workspaceRoot}/build/tools/Cake.CoreCLR/0.30.0/Cake.dll",
- "args": [
- "${workspaceRoot}/build/build.cake",
- "--debug",
- "--verbosity=diagnostic"
- ],
- "cwd": "${workspaceRoot}/build",
- "stopAtEntry": true,
- "externalConsole": false
}
]
}
diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt
index 46c50dbfa2..ea3e25142c 100644
--- a/CodeAnalysis/BannedSymbols.txt
+++ b/CodeAnalysis/BannedSymbols.txt
@@ -3,6 +3,7 @@ M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Us
M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable or EqualityComparer.Default instead.
M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead.
T:System.IComparable;Don't use non-generic IComparable. Use generic version instead.
+T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable instead.
M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText.
M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900)
T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods.
diff --git a/FodyWeavers.xml b/FodyWeavers.xml
new file mode 100644
index 0000000000..ea490e3297
--- /dev/null
+++ b/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/InspectCode.ps1 b/InspectCode.ps1
index 6ed935fdbb..8316f48ff3 100644
--- a/InspectCode.ps1
+++ b/InspectCode.ps1
@@ -1,27 +1,11 @@
-[CmdletBinding()]
-Param(
- [string]$Target,
- [string]$Configuration,
- [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")]
- [string]$Verbosity,
- [switch]$ShowDescription,
- [Alias("WhatIf", "Noop")]
- [switch]$DryRun,
- [Parameter(Position = 0, Mandatory = $false, ValueFromRemainingArguments = $true)]
- [string[]]$ScriptArgs
-)
-
-# Build Cake arguments
-$cakeArguments = "";
-if ($Target) { $cakeArguments += "-target=$Target" }
-if ($Configuration) { $cakeArguments += "-configuration=$Configuration" }
-if ($Verbosity) { $cakeArguments += "-verbosity=$Verbosity" }
-if ($ShowDescription) { $cakeArguments += "-showdescription" }
-if ($DryRun) { $cakeArguments += "-dryrun" }
-if ($Experimental) { $cakeArguments += "-experimental" }
-$cakeArguments += $ScriptArgs
-
dotnet tool restore
-dotnet cake ./build/InspectCode.cake --bootstrap
-dotnet cake ./build/InspectCode.cake $cakeArguments
-exit $LASTEXITCODE
\ No newline at end of file
+
+# Temporarily disabled until the tool is upgraded to 5.0.
+ # The version specified in .config/dotnet-tools.json (3.1.37601) won't run on .NET hosts >=5.0.7.
+ # - cmd: dotnet format --dry-run --check
+
+dotnet CodeFileSanity
+dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
+dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors
+
+exit $LASTEXITCODE
diff --git a/InspectCode.sh b/InspectCode.sh
new file mode 100755
index 0000000000..cf2bc18175
--- /dev/null
+++ b/InspectCode.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+dotnet tool restore
+dotnet CodeFileSanity
+dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
+dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors
diff --git a/README.md b/README.md
index 3054f19e79..016bd7d922 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
A free-to-win rhythm game. Rhythm is just a *click* away!
-The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commonly known by the codename *osu!lazer*. Pew pew.
+The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the codename "*lazer*". As in sharper than cutting-edge.
## Status
@@ -23,7 +23,7 @@ We are accepting bug reports (please report with as much detail as possible and
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
-- Read peppy's [latest blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where lazer is currently and the roadmap going forward.
+- Read peppy's [blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward.
## Running osu!
@@ -43,7 +43,7 @@ If your platform is not listed above, there is still a chance you can manually b
osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
-You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852).
+You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu!
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
index 5eb5efa54c..3dd6be7307 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs
index 59a68245a6..a80f1178b6 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs
@@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyFreeform
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty();
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0];
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0];
}
}
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs
index d5c1e9bd15..f705009d18 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs
@@ -3,7 +3,6 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
-using osu.Game.Rulesets.EmptyFreeform.Objects;
using osu.Game.Rulesets.EmptyFreeform.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
@@ -11,7 +10,7 @@ using osu.Game.Users;
namespace osu.Game.Rulesets.EmptyFreeform.Mods
{
- public class EmptyFreeformModAutoplay : ModAutoplay
+ public class EmptyFreeformModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index d7c116411a..0c4bfe0ed7 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
index 8ea334c99c..4565c97d1a 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
@@ -4,14 +4,13 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Pippidon.Objects;
using osu.Game.Rulesets.Pippidon.Replays;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Rulesets.Pippidon.Mods
{
- public class PippidonModAutoplay : ModAutoplay
+ public class PippidonModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
index f6340f6c25..290148d14b 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
@@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty();
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0];
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0];
}
}
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
index 89b551286b..bb0a487274 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs
index 7f29c4e712..f557a4c754 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs
@@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyScrolling
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty();
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0];
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0];
}
}
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs
index 6dad1ff43b..431994e098 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs
@@ -3,7 +3,6 @@
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.EmptyScrolling.Objects;
using osu.Game.Rulesets.EmptyScrolling.Replays;
using osu.Game.Scoring;
using osu.Game.Users;
@@ -11,7 +10,7 @@ using System.Collections.Generic;
namespace osu.Game.Rulesets.EmptyScrolling.Mods
{
- public class EmptyScrollingModAutoplay : ModAutoplay
+ public class EmptyScrollingModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index d7c116411a..0c4bfe0ed7 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
index 8ea334c99c..4565c97d1a 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs
@@ -4,14 +4,13 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Pippidon.Objects;
using osu.Game.Rulesets.Pippidon.Replays;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Rulesets.Pippidon.Mods
{
- public class PippidonModAutoplay : ModAutoplay
+ public class PippidonModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
index f6340f6c25..290148d14b 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs
@@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty();
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0];
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0];
}
}
diff --git a/appveyor.yml b/appveyor.yml
index a4a0cedc66..5be73f9875 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,24 +1,27 @@
clone_depth: 1
version: '{branch}-{build}'
image: Visual Studio 2019
+cache:
+ - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
+
dotnet_csproj:
patch: true
file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects
version: '0.0.{build}'
-cache:
- - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
+
before_build:
- - ps: dotnet --info # Useful when version mismatch between CI and local
- - ps: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
+ - cmd: dotnet --info # Useful when version mismatch between CI and local
+ - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
+
build:
project: osu.sln
parallel: true
verbosity: minimal
publish_nuget: true
+
after_build:
- - ps: dotnet tool restore
- - ps: dotnet format --dry-run --check
- ps: .\InspectCode.ps1
+
test:
assemblies:
except:
diff --git a/build/Desktop.proj b/build/Desktop.proj
deleted file mode 100644
index b1c6b065e8..0000000000
--- a/build/Desktop.proj
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/build/InspectCode.cake b/build/InspectCode.cake
deleted file mode 100644
index 6836d9071b..0000000000
--- a/build/InspectCode.cake
+++ /dev/null
@@ -1,41 +0,0 @@
-#addin "nuget:?package=CodeFileSanity&version=0.0.36"
-
-///////////////////////////////////////////////////////////////////////////////
-// ARGUMENTS
-///////////////////////////////////////////////////////////////////////////////
-
-var target = Argument("target", "CodeAnalysis");
-var configuration = Argument("configuration", "Release");
-
-var rootDirectory = new DirectoryPath("..");
-var sln = rootDirectory.CombineWithFilePath("osu.sln");
-var desktopSlnf = rootDirectory.CombineWithFilePath("osu.Desktop.slnf");
-
-///////////////////////////////////////////////////////////////////////////////
-// TASKS
-///////////////////////////////////////////////////////////////////////////////
-
-Task("InspectCode")
- .Does(() => {
- var inspectcodereport = "inspectcodereport.xml";
- var cacheDir = "inspectcode";
- var verbosity = AppVeyor.IsRunningOnAppVeyor ? "WARN" : "INFO"; // Don't flood CI output
-
- DotNetCoreTool(rootDirectory.FullPath,
- "jb", $@"inspectcode ""{desktopSlnf}"" --output=""{inspectcodereport}"" --caches-home=""{cacheDir}"" --verbosity={verbosity}");
- DotNetCoreTool(rootDirectory.FullPath, "nvika", $@"parsereport ""{inspectcodereport}"" --treatwarningsaserrors");
- });
-
-Task("CodeFileSanity")
- .Does(() => {
- ValidateCodeSanity(new ValidateCodeSanitySettings {
- RootDirectory = rootDirectory.FullPath,
- IsAppveyorBuild = AppVeyor.IsRunningOnAppVeyor
- });
- });
-
-Task("CodeAnalysis")
- .IsDependentOn("CodeFileSanity")
- .IsDependentOn("InspectCode");
-
-RunTarget(target);
\ No newline at end of file
diff --git a/cake.config b/cake.config
deleted file mode 100644
index 187d825591..0000000000
--- a/cake.config
+++ /dev/null
@@ -1,5 +0,0 @@
-
-[Nuget]
-Source=https://api.nuget.org/v3/index.json
-UseInProcessClient=true
-LoadDependencies=true
diff --git a/osu.Android.props b/osu.Android.props
index e95c7e6619..171a0862a1 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,11 @@
-
-
+
+
+
+
+
+
diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs
index cffcea22c2..063e02d349 100644
--- a/osu.Android/OsuGameActivity.cs
+++ b/osu.Android/OsuGameActivity.cs
@@ -20,7 +20,8 @@ namespace osu.Android
[Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")]
- [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed" })]
+ [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-archive")]
+ [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed", "application/x-osu-archive" })]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })]
public class OsuGameActivity : AndroidGameActivity
{
diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs
index 47cd39dc5a..910751a723 100644
--- a/osu.Desktop/Updater/SquirrelUpdateManager.cs
+++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs
@@ -68,6 +68,8 @@ namespace osu.Desktop.Updater
return false;
}
+ scheduleRecheck = false;
+
if (notification == null)
{
notification = new UpdateProgressNotification(this) { State = ProgressNotificationState.Active };
@@ -98,7 +100,6 @@ namespace osu.Desktop.Updater
// could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959)
// try again without deltas.
await checkForUpdateAsync(false, notification).ConfigureAwait(false);
- scheduleRecheck = false;
}
else
{
@@ -110,13 +111,14 @@ namespace osu.Desktop.Updater
catch (Exception)
{
// we'll ignore this and retry later. can be triggered by no internet connection or thread abortion.
+ scheduleRecheck = true;
}
finally
{
if (scheduleRecheck)
{
// check again in 30 minutes.
- Scheduler.AddDelayed(async () => await checkForUpdateAsync().ConfigureAwait(false), 60000 * 30);
+ Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30);
}
}
@@ -141,7 +143,7 @@ namespace osu.Desktop.Updater
Activated = () =>
{
updateManager.PrepareUpdateAsync()
- .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit()));
+ .ContinueWith(_ => updateManager.Schedule(() => game?.GracefullyExit()));
return true;
};
}
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index ad5c323e9b..89b9ffb94b 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -5,8 +5,8 @@
true
A free-to-win rhythm game. Rhythm is just a *click* away!
osu!
- osu!lazer
- osu!lazer
+ osu!
+ osu!(lazer)
lazer.ico
app.manifest
0.0.0
diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec
index fa182f8e70..1757fd7c73 100644
--- a/osu.Desktop/osu.nuspec
+++ b/osu.Desktop/osu.nuspec
@@ -3,7 +3,7 @@
osulazer
0.0.0
- osu!lazer
+ osu!
ppy Pty Ltd
Dean Herbert
https://osu.ppy.sh/
@@ -20,4 +20,3 @@
-
diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
index 7a74563b2b..da8a0540f4 100644
--- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
+++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs
new file mode 100644
index 0000000000..158c8edba5
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs
@@ -0,0 +1,66 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Timing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Edit;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public class CatchEditorTestSceneContainer : Container
+ {
+ [Cached(typeof(Playfield))]
+ public readonly ScrollingPlayfield Playfield;
+
+ protected override Container Content { get; }
+
+ public CatchEditorTestSceneContainer()
+ {
+ Anchor = Anchor.Centre;
+ Origin = Anchor.Centre;
+ Width = CatchPlayfield.WIDTH;
+ Height = 1000;
+ Padding = new MarginPadding
+ {
+ Bottom = 100
+ };
+
+ InternalChildren = new Drawable[]
+ {
+ new ScrollingTestContainer(ScrollingDirection.Down)
+ {
+ TimeRange = 1000,
+ RelativeSizeAxes = Axes.Both,
+ Child = Playfield = new TestCatchPlayfield
+ {
+ RelativeSizeAxes = Axes.Both
+ }
+ },
+ new PlayfieldBorder
+ {
+ PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Full },
+ Clock = new FramedClock(new StopwatchClock(true))
+ },
+ Content = new Container
+ {
+ RelativeSizeAxes = Axes.Both
+ }
+ };
+ }
+
+ private class TestCatchPlayfield : CatchEditorPlayfield
+ {
+ public TestCatchPlayfield()
+ : base(new BeatmapDifficulty { CircleSize = 0 })
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs
new file mode 100644
index 0000000000..1d30ae34cd
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs
@@ -0,0 +1,79 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Testing;
+using osu.Framework.Timing;
+using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Tests.Visual;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public abstract class CatchPlacementBlueprintTestScene : PlacementBlueprintTestScene
+ {
+ protected const double TIME_SNAP = 100;
+
+ protected DrawableCatchHitObject LastObject;
+
+ protected new ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer;
+
+ protected override Container Content => contentContainer;
+
+ private readonly CatchEditorTestSceneContainer contentContainer;
+
+ protected CatchPlacementBlueprintTestScene()
+ {
+ base.Content.Add(contentContainer = new CatchEditorTestSceneContainer());
+
+ contentContainer.Playfield.Clock = new FramedClock(new ManualClock());
+ }
+
+ [SetUp]
+ public void Setup() => Schedule(() =>
+ {
+ HitObjectContainer.Clear();
+ ResetPlacement();
+ LastObject = null;
+ });
+
+ protected void AddMoveStep(double time, float x) => AddStep($"move to time={time}, x={x}", () =>
+ {
+ float y = HitObjectContainer.PositionAtTime(time);
+ Vector2 pos = HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight));
+ InputManager.MoveMouseTo(pos);
+ });
+
+ protected void AddClickStep(MouseButton button) => AddStep($"click {button}", () =>
+ {
+ InputManager.Click(button);
+ });
+
+ protected IEnumerable FruitOutlines => Content.ChildrenOfType();
+
+ // Unused because AddHitObject is overriden
+ protected override Container CreateHitObjectContainer() => new Container();
+
+ protected override void AddHitObject(DrawableHitObject hitObject)
+ {
+ LastObject = (DrawableCatchHitObject)hitObject;
+ contentContainer.Playfield.HitObjectContainer.Add(hitObject);
+ }
+
+ protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
+ {
+ var result = base.SnapForBlueprint(blueprint);
+ result.Time = Math.Round(HitObjectContainer.TimeAtScreenSpacePosition(result.ScreenSpacePosition) / TIME_SNAP) * TIME_SNAP;
+ return result;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs
new file mode 100644
index 0000000000..dcdc32145b
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public abstract class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene
+ {
+ protected ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer;
+
+ protected override Container Content => contentContainer;
+
+ private readonly CatchEditorTestSceneContainer contentContainer;
+
+ protected CatchSelectionBlueprintTestScene()
+ {
+ base.Content.Add(contentContainer = new CatchEditorTestSceneContainer());
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs
new file mode 100644
index 0000000000..e3811b7669
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs
@@ -0,0 +1,87 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public class TestSceneBananaShowerPlacementBlueprint : CatchPlacementBlueprintTestScene
+ {
+ protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject);
+
+ protected override PlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
+
+ protected override void AddHitObject(DrawableHitObject hitObject)
+ {
+ // Create nested bananas (but positions are not randomized because beatmap processing is not done).
+ hitObject.HitObject.ApplyDefaults(new ControlPointInfo(), Beatmap.Value.BeatmapInfo.BaseDifficulty);
+
+ base.AddHitObject(hitObject);
+ }
+
+ [Test]
+ public void TestBasicPlacement()
+ {
+ const double start_time = 100;
+ const double end_time = 500;
+
+ AddMoveStep(start_time, 0);
+ AddClickStep(MouseButton.Left);
+ AddMoveStep(end_time, 0);
+ AddClickStep(MouseButton.Right);
+ AddAssert("banana shower is placed", () => LastObject is DrawableBananaShower);
+ AddAssert("start time is correct", () => Precision.AlmostEquals(LastObject.HitObject.StartTime, start_time));
+ AddAssert("end time is correct", () => Precision.AlmostEquals(LastObject.HitObject.GetEndTime(), end_time));
+ }
+
+ [Test]
+ public void TestReversePlacement()
+ {
+ const double start_time = 100;
+ const double end_time = 500;
+
+ AddMoveStep(end_time, 0);
+ AddClickStep(MouseButton.Left);
+ AddMoveStep(start_time, 0);
+ AddClickStep(MouseButton.Right);
+ AddAssert("start time is correct", () => Precision.AlmostEquals(LastObject.HitObject.StartTime, start_time));
+ AddAssert("end time is correct", () => Precision.AlmostEquals(LastObject.HitObject.GetEndTime(), end_time));
+ }
+
+ [Test]
+ public void TestFinishWithZeroDuration()
+ {
+ AddMoveStep(100, 0);
+ AddClickStep(MouseButton.Left);
+ AddClickStep(MouseButton.Right);
+ AddAssert("banana shower is not placed", () => LastObject == null);
+ AddAssert("state is waiting", () => CurrentBlueprint?.PlacementActive == PlacementBlueprint.PlacementState.Waiting);
+ }
+
+ [Test]
+ public void TestOpacity()
+ {
+ AddMoveStep(100, 0);
+ AddClickStep(MouseButton.Left);
+ AddUntilStep("outline is semitransparent", () => Precision.DefinitelyBigger(1, timeSpanOutline.Alpha));
+ AddMoveStep(200, 0);
+ AddUntilStep("outline is opaque", () => Precision.AlmostEquals(timeSpanOutline.Alpha, 1));
+ AddMoveStep(100, 0);
+ AddUntilStep("outline is semitransparent", () => Precision.DefinitelyBigger(1, timeSpanOutline.Alpha));
+ }
+
+ private TimeSpanOutline timeSpanOutline => Content.ChildrenOfType().Single();
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs
new file mode 100644
index 0000000000..161c685043
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/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.Catch.Tests.Editor
+{
+ [TestFixture]
+ public class TestSceneEditor : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new CatchRuleset();
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs
new file mode 100644
index 0000000000..4b1c45ae2f
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs
@@ -0,0 +1,44 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public class TestSceneFruitPlacementBlueprint : CatchPlacementBlueprintTestScene
+ {
+ protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject);
+
+ protected override PlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
+
+ [Test]
+ public void TestFruitPlacementPosition()
+ {
+ const double time = 300;
+ const float x = CatchPlayfield.CENTER_X;
+
+ AddMoveStep(time, x);
+ AddClickStep(MouseButton.Left);
+
+ AddAssert("outline position is correct", () =>
+ {
+ var outline = FruitOutlines.Single();
+ return Precision.AlmostEquals(outline.X, x) &&
+ Precision.AlmostEquals(outline.Y, HitObjectContainer.PositionAtTime(time));
+ });
+
+ AddAssert("fruit time is correct", () => Precision.AlmostEquals(LastObject.StartTimeBindable.Value, time));
+ AddAssert("fruit position is correct", () => Precision.AlmostEquals(LastObject.X, x));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs
new file mode 100644
index 0000000000..1b96175020
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Tests.Editor
+{
+ public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene
+ {
+ public TestSceneJuiceStreamSelectionBlueprint()
+ {
+ var hitObject = new JuiceStream
+ {
+ OriginalX = 100,
+ StartTime = 100,
+ Path = new SliderPath(PathType.PerfectCurve, new[]
+ {
+ Vector2.Zero,
+ new Vector2(200, 100),
+ new Vector2(0, 200),
+ }),
+ };
+ var controlPoint = new ControlPointInfo();
+ controlPoint.Add(0, new TimingControlPoint
+ {
+ BeatLength = 100
+ });
+ hitObject.ApplyDefaults(controlPoint, new BeatmapDifficulty { CircleSize = 0 });
+ AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs
new file mode 100644
index 0000000000..5e4b6d9e1a
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs
@@ -0,0 +1,288 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Tests
+{
+ [TestFixture]
+ public class JuiceStreamPathTest
+ {
+ [TestCase(1e3, true, false)]
+ // When the coordinates are large, the slope invariant fails within the specified absolute allowance due to the floating-number precision.
+ [TestCase(1e9, false, false)]
+ // Using discrete values sometimes discover more edge cases.
+ [TestCase(10, true, true)]
+ public void TestRandomInsertSetPosition(double scale, bool checkSlope, bool integralValues)
+ {
+ var rng = new Random(1);
+ var path = new JuiceStreamPath();
+
+ for (int iteration = 0; iteration < 100000; iteration++)
+ {
+ if (rng.Next(10) == 0)
+ path.Clear();
+
+ int vertexCount = path.Vertices.Count;
+
+ switch (rng.Next(2))
+ {
+ case 0:
+ {
+ double distance = rng.NextDouble() * scale * 2 - scale;
+ if (integralValues)
+ distance = Math.Round(distance);
+
+ float oldX = path.PositionAtDistance(distance);
+ int index = path.InsertVertex(distance);
+ Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1));
+ Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
+ Assert.That(path.Vertices[index].X, Is.EqualTo(oldX));
+ break;
+ }
+
+ case 1:
+ {
+ int index = rng.Next(path.Vertices.Count);
+ double distance = path.Vertices[index].Distance;
+ float newX = (float)(rng.NextDouble() * scale * 2 - scale);
+ if (integralValues)
+ newX = MathF.Round(newX);
+
+ path.SetVertexPosition(index, newX);
+ Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount));
+ Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
+ Assert.That(path.Vertices[index].X, Is.EqualTo(newX));
+ break;
+ }
+ }
+
+ assertInvariants(path.Vertices, checkSlope);
+ }
+ }
+
+ [Test]
+ public void TestRemoveVertices()
+ {
+ var path = new JuiceStreamPath();
+ path.Add(10, 5);
+ path.Add(20, -5);
+
+ int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1);
+ Assert.That(removeCount, Is.EqualTo(1));
+ Assert.That(path.Vertices, Is.EqualTo(new[]
+ {
+ new JuiceStreamPathVertex(0, 0),
+ new JuiceStreamPathVertex(20, -5)
+ }));
+
+ removeCount = path.RemoveVertices((_, i) => i == 0);
+ Assert.That(removeCount, Is.EqualTo(1));
+ Assert.That(path.Vertices, Is.EqualTo(new[]
+ {
+ new JuiceStreamPathVertex(20, -5)
+ }));
+
+ removeCount = path.RemoveVertices((_, i) => true);
+ Assert.That(removeCount, Is.EqualTo(1));
+ Assert.That(path.Vertices, Is.EqualTo(new[]
+ {
+ new JuiceStreamPathVertex()
+ }));
+ }
+
+ [Test]
+ public void TestResampleVertices()
+ {
+ var path = new JuiceStreamPath();
+ path.Add(-100, -10);
+ path.Add(100, 50);
+ path.ResampleVertices(new double[]
+ {
+ -50,
+ 0,
+ 70,
+ 120
+ });
+ Assert.That(path.Vertices, Is.EqualTo(new[]
+ {
+ new JuiceStreamPathVertex(-100, -10),
+ new JuiceStreamPathVertex(-50, -5),
+ new JuiceStreamPathVertex(0, 0),
+ new JuiceStreamPathVertex(70, 35),
+ new JuiceStreamPathVertex(100, 50),
+ new JuiceStreamPathVertex(100, 50),
+ }));
+
+ path.Clear();
+ path.SetVertexPosition(0, 10);
+ path.ResampleVertices(Array.Empty());
+ Assert.That(path.Vertices, Is.EqualTo(new[]
+ {
+ new JuiceStreamPathVertex(0, 10)
+ }));
+ }
+
+ [Test]
+ public void TestRandomConvertFromSliderPath()
+ {
+ var rng = new Random(1);
+ var path = new JuiceStreamPath();
+ var sliderPath = new SliderPath();
+
+ for (int iteration = 0; iteration < 10000; iteration++)
+ {
+ sliderPath.ControlPoints.Clear();
+
+ do
+ {
+ int start = sliderPath.ControlPoints.Count;
+
+ do
+ {
+ float x = (float)(rng.NextDouble() * 1e3);
+ float y = (float)(rng.NextDouble() * 1e3);
+ sliderPath.ControlPoints.Add(new PathControlPoint(new Vector2(x, y)));
+ } while (rng.Next(2) != 0);
+
+ int length = sliderPath.ControlPoints.Count - start + 1;
+ sliderPath.ControlPoints[start].Type.Value = length <= 2 ? PathType.Linear : length == 3 ? PathType.PerfectCurve : PathType.Bezier;
+ } while (rng.Next(3) != 0);
+
+ if (rng.Next(5) == 0)
+ sliderPath.ExpectedDistance.Value = rng.NextDouble() * 3e3;
+ else
+ sliderPath.ExpectedDistance.Value = null;
+
+ path.ConvertFromSliderPath(sliderPath);
+ Assert.That(path.Vertices[0].Distance, Is.EqualTo(0));
+ Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3));
+ assertInvariants(path.Vertices, true);
+
+ double[] sampleDistances = Enumerable.Range(0, 10)
+ .Select(_ => rng.NextDouble() * sliderPath.Distance)
+ .ToArray();
+
+ foreach (double distance in sampleDistances)
+ {
+ float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
+ Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
+ }
+
+ path.ResampleVertices(sampleDistances);
+ assertInvariants(path.Vertices, true);
+
+ foreach (double distance in sampleDistances)
+ {
+ float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
+ Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
+ }
+ }
+ }
+
+ [Test]
+ public void TestRandomConvertToSliderPath()
+ {
+ var rng = new Random(1);
+ var path = new JuiceStreamPath();
+ var sliderPath = new SliderPath();
+
+ for (int iteration = 0; iteration < 10000; iteration++)
+ {
+ path.Clear();
+
+ do
+ {
+ double distance = rng.NextDouble() * 1e3;
+ float x = (float)(rng.NextDouble() * 1e3);
+ path.Add(distance, x);
+ } while (rng.Next(5) != 0);
+
+ float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT);
+
+ path.ConvertToSliderPath(sliderPath, sliderStartY);
+ Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3));
+ Assert.That(sliderPath.ControlPoints[0].Position.Value.X, Is.EqualTo(path.Vertices[0].X));
+ assertInvariants(path.Vertices, true);
+
+ foreach (var point in sliderPath.ControlPoints)
+ {
+ Assert.That(point.Type.Value, Is.EqualTo(PathType.Linear).Or.Null);
+ Assert.That(sliderStartY + point.Position.Value.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT));
+ }
+
+ for (int i = 0; i < 10; i++)
+ {
+ double distance = rng.NextDouble() * path.Distance;
+ float expected = path.PositionAtDistance(distance);
+ Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3));
+ }
+ }
+ }
+
+ [Test]
+ public void TestInvalidation()
+ {
+ var path = new JuiceStreamPath();
+ Assert.That(path.InvalidationID, Is.EqualTo(1));
+ int previousId = path.InvalidationID;
+
+ path.InsertVertex(10);
+ checkNewId();
+
+ path.SetVertexPosition(1, 5);
+ checkNewId();
+
+ path.Add(20, 0);
+ checkNewId();
+
+ path.RemoveVertices((v, _) => v.Distance == 20);
+ checkNewId();
+
+ path.ResampleVertices(new double[] { 5, 10, 15 });
+ checkNewId();
+
+ path.Clear();
+ checkNewId();
+
+ path.ConvertFromSliderPath(new SliderPath());
+ checkNewId();
+
+ void checkNewId()
+ {
+ Assert.That(path.InvalidationID, Is.Not.EqualTo(previousId));
+ previousId = path.InvalidationID;
+ }
+ }
+
+ private void assertInvariants(IReadOnlyList vertices, bool checkSlope)
+ {
+ Assert.That(vertices, Is.Not.Empty);
+
+ for (int i = 0; i < vertices.Count; i++)
+ {
+ Assert.That(double.IsFinite(vertices[i].Distance));
+ Assert.That(float.IsFinite(vertices[i].X));
+ }
+
+ for (int i = 1; i < vertices.Count; i++)
+ {
+ Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance));
+
+ if (!checkSlope) continue;
+
+ float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X);
+ double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance;
+ Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini
new file mode 100644
index 0000000000..94c6b5b58d
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini
@@ -0,0 +1,2 @@
+[General]
+// no version specified means v1
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs
index 1248409b2a..09362929d2 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs
@@ -4,10 +4,8 @@
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;
@@ -21,12 +19,6 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public class TestSceneCatchModHidden : ModTestScene
{
- [BackgroundDependencyLoader]
- private void load()
- {
- LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, false);
- }
-
[Test]
public void TestJuiceStream()
{
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs
new file mode 100644
index 0000000000..ec186bcfb2
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.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.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.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Catch.Judgements;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.Skinning;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Skinning;
+using osu.Game.Tests.Visual;
+using Direction = osu.Game.Rulesets.Catch.UI.Direction;
+
+namespace osu.Game.Rulesets.Catch.Tests
+{
+ public class TestSceneCatchSkinConfiguration : OsuTestScene
+ {
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ private Catcher catcher;
+
+ private readonly Container container;
+
+ public TestSceneCatchSkinConfiguration()
+ {
+ Add(droppedObjectContainer = new DroppedObjectContainer());
+ Add(container = new Container { RelativeSizeAxes = Axes.Both });
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void TestCatcherPlateFlipping(bool flip)
+ {
+ AddStep("setup catcher", () =>
+ {
+ var skin = new TestSkin { FlipCatcherPlate = flip };
+ container.Child = new SkinProvidingContainer(skin)
+ {
+ Child = catcher = new Catcher(new Container())
+ {
+ Anchor = Anchor.Centre
+ }
+ };
+ });
+
+ Fruit fruit = new Fruit();
+
+ AddStep("catch fruit", () => catchFruit(fruit, 20));
+
+ float position = 0;
+
+ AddStep("record fruit position", () => position = getCaughtObjectPosition(fruit));
+
+ AddStep("face left", () => catcher.VisualDirection = Direction.Left);
+
+ if (flip)
+ AddAssert("fruit position changed", () => !Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
+ else
+ AddAssert("fruit position unchanged", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
+
+ AddStep("face right", () => catcher.VisualDirection = Direction.Right);
+
+ AddAssert("fruit position restored", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
+ }
+
+ private float getCaughtObjectPosition(Fruit fruit)
+ {
+ var caughtObject = catcher.ChildrenOfType().Single(c => c.HitObject == fruit);
+ return caughtObject.Parent.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
+ }
+
+ private void catchFruit(Fruit fruit, float x)
+ {
+ fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+ var drawableFruit = new DrawableFruit(fruit) { X = x };
+ var judgement = fruit.CreateJudgement();
+ catcher.OnNewResult(drawableFruit, new CatchJudgementResult(fruit, judgement)
+ {
+ Type = judgement.MaxResult
+ });
+ }
+
+ private class TestSkin : DefaultSkin
+ {
+ public bool FlipCatcherPlate { get; set; }
+
+ public TestSkin()
+ : base(null)
+ {
+ }
+
+ public override IBindable GetConfig(TLookup lookup)
+ {
+ if (lookup is CatchSkinConfiguration config)
+ {
+ if (config == CatchSkinConfiguration.FlipCatcherPlate)
+ return SkinUtils.As(new Bindable(FlipCatcherPlate));
+ }
+
+ return base.GetConfig(lookup);
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
index 517027a9fc..0a2dff6a21 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
@@ -6,8 +6,8 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics;
+using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
@@ -31,10 +31,10 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved]
private OsuConfigManager config { get; set; }
- private Container droppedObjectContainer;
-
private TestCatcher catcher;
+ private DroppedObjectContainer droppedObjectContainer;
+
[SetUp]
public void SetUp() => Schedule(() =>
{
@@ -43,19 +43,24 @@ namespace osu.Game.Rulesets.Catch.Tests
CircleSize = 0,
};
- var trailContainer = new Container();
- droppedObjectContainer = new Container();
- catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty);
-
- Child = new Container
+ var trailContainer = new Container
{
Anchor = Anchor.Centre,
+ };
+ droppedObjectContainer = new DroppedObjectContainer();
+ Child = new DependencyProvidingContainer
+ {
+ CachedDependencies = new (Type, object)[]
+ {
+ (typeof(DroppedObjectContainer), droppedObjectContainer),
+ },
Children = new Drawable[]
{
- trailContainer,
droppedObjectContainer,
- catcher
- }
+ catcher = new TestCatcher(trailContainer, difficulty),
+ trailContainer
+ },
+ Anchor = Anchor.Centre
};
});
@@ -188,9 +193,9 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9));
checkPlate(10);
AddAssert("caught objects are stacked", () =>
- catcher.CaughtObjects.All(obj => obj.Y <= Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) &&
- catcher.CaughtObjects.Any(obj => obj.Y == Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) &&
- catcher.CaughtObjects.Any(obj => obj.Y < -25));
+ catcher.CaughtObjects.All(obj => obj.Y <= 0) &&
+ catcher.CaughtObjects.Any(obj => obj.Y == 0) &&
+ catcher.CaughtObjects.Any(obj => obj.Y < 0));
}
[Test]
@@ -216,7 +221,7 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true));
AddStep("catch fruit", () => attemptCatch(new Fruit()));
AddAssert("correct hit lighting colour", () =>
- catcher.ChildrenOfType().First()?.ObjectColour == fruitColour);
+ catcher.ChildrenOfType().First()?.Entry?.ObjectColour == fruitColour);
}
[Test]
@@ -293,8 +298,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public IEnumerable CaughtObjects => this.ChildrenOfType();
- public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty)
- : base(trailsTarget, droppedObjectTarget, difficulty)
+ public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty)
+ : base(trailsTarget, difficulty)
{
}
}
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
index 4af5098451..877e115e2f 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
@@ -6,7 +6,6 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Framework.Utils;
@@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests
SetContents(_ =>
{
- var droppedObjectContainer = new Container
- {
- RelativeSizeAxes = Axes.Both
- };
-
return new CatchInputManager(catchRuleset)
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
- droppedObjectContainer,
- new TestCatcherArea(droppedObjectContainer, beatmapDifficulty)
+ new TestCatcherArea(beatmapDifficulty)
{
Anchor = Anchor.Centre,
Origin = Anchor.TopCentre,
@@ -126,9 +119,13 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestCatcherArea : CatcherArea
{
- public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty)
- : base(droppedObjectContainer, beatmapDifficulty)
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ public TestCatcherArea(BeatmapDifficulty beatmapDifficulty)
+ : base(beatmapDifficulty)
{
+ AddInternal(droppedObjectContainer = new DroppedObjectContainer());
}
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 3e4995482d..fd6a9c7b7b 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs
@@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Catch.Tests
private void addToPlayfield(DrawableCatchHitObject drawable)
{
- foreach (var mod in SelectedMods.Value.OfType())
- mod.ApplyToDrawableHitObjects(new[] { drawable });
+ foreach (var mod in SelectedMods.Value.OfType())
+ mod.ApplyToDrawableHitObject(drawable);
drawableRuleset.Playfield.Add(drawable);
}
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
index 683a776dcc..e7b0259ea2 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
@@ -118,11 +118,10 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("create hyper-dashing catcher", () =>
{
- Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container())
+ Child = setupSkinHierarchy(catcherArea = new TestCatcherArea
{
Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Scale = new Vector2(4f),
+ Origin = Anchor.Centre
}, skin);
});
@@ -139,7 +138,7 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("finish hyper-dashing", () =>
{
- catcherArea.MovableCatcher.SetHyperDashState(1);
+ catcherArea.MovableCatcher.SetHyperDashState();
catcherArea.MovableCatcher.FinishTransforms();
});
@@ -206,5 +205,18 @@ namespace osu.Game.Rulesets.Catch.Tests
{
}
}
+
+ private class TestCatcherArea : CatcherArea
+ {
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ public TestCatcherArea()
+ {
+ Scale = new Vector2(4f);
+
+ AddInternal(droppedObjectContainer = new DroppedObjectContainer());
+ }
+ }
}
}
diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
index 83d0744588..484da8e22e 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
@@ -4,7 +4,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs
index 34964fc4ae..7774a7da09 100644
--- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs
@@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap, CancellationToken cancellationToken)
{
- var positionData = obj as IHasXPosition;
+ var xPositionData = obj as IHasXPosition;
+ var yPositionData = obj as IHasYPosition;
var comboData = obj as IHasCombo;
switch (obj)
@@ -36,10 +37,11 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
Path = curveData.Path,
NodeSamples = curveData.NodeSamples,
RepeatCount = curveData.RepeatCount,
- X = positionData?.X ?? 0,
+ X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
- LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0
+ LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
+ LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y
}.Yield();
case IHasDuration endTime:
@@ -59,7 +61,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
Samples = obj.Samples,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
- X = positionData?.X ?? 0
+ X = xPositionData?.X ?? 0,
+ LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y
}.Yield();
}
}
diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs
index fac5d03833..3a5322ce82 100644
--- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs
+++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs
@@ -8,7 +8,6 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.MathUtils;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
-using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Catch.Beatmaps
@@ -17,6 +16,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
{
public const int RNG_SEED = 1337;
+ public bool HardRockOffsets { get; set; }
+
public CatchBeatmapProcessor(IBeatmap beatmap)
: base(beatmap)
{
@@ -43,11 +44,10 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
}
}
- public static void ApplyPositionOffsets(IBeatmap beatmap, params Mod[] mods)
+ public void ApplyPositionOffsets(IBeatmap beatmap)
{
var rng = new FastRandom(RNG_SEED);
- bool shouldApplyHardRockOffset = mods.Any(m => m is ModHardRock);
float? lastPosition = null;
double lastStartTime = 0;
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
switch (obj)
{
case Fruit fruit:
- if (shouldApplyHardRockOffset)
+ if (HardRockOffsets)
applyHardRockOffset(fruit, ref lastPosition, ref lastStartTime, rng);
break;
diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs
index 23ce444560..76863acc78 100644
--- a/osu.Game.Rulesets.Catch/CatchRuleset.cs
+++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs
@@ -22,7 +22,9 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using System;
using osu.Framework.Extensions.EnumExtensions;
+using osu.Game.Rulesets.Catch.Edit;
using osu.Game.Rulesets.Catch.Skinning.Legacy;
+using osu.Game.Rulesets.Edit;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Catch
@@ -175,12 +177,14 @@ namespace osu.Game.Rulesets.Catch
public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap);
- public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new CatchLegacySkinTransformer(source);
+ public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new CatchLegacySkinTransformer(skin);
public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new CatchPerformanceCalculator(this, attributes, score);
public int LegacyID => 2;
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame();
+
+ public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
}
}
diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs
index 668f7197be..e736d68740 100644
--- a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs
+++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs
@@ -8,9 +8,7 @@ namespace osu.Game.Rulesets.Catch
Fruit,
Banana,
Droplet,
- CatcherIdle,
- CatcherFail,
- CatcherKiai,
+ Catcher,
CatchComboCounter
}
}
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
index fa9011d826..4e05b1e3e0 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
@@ -7,6 +7,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyAttributes : DifficultyAttributes
{
- public double ApproachRate;
+ public double ApproachRate { get; set; }
}
}
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
index f5cce47186..9feaa55051 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
}
}
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f;
@@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return new Skill[]
{
- new Movement(mods, halfCatcherWidth),
+ new Movement(mods, halfCatcherWidth, clockRate),
};
}
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs
index 6a3a16ed33..439890dac2 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Extensions;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
@@ -33,15 +32,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
mods = Score.Mods;
- fruitsHit = Score.Statistics.GetOrDefault(HitResult.Great);
- ticksHit = Score.Statistics.GetOrDefault(HitResult.LargeTickHit);
- tinyTicksHit = Score.Statistics.GetOrDefault(HitResult.SmallTickHit);
- tinyTicksMissed = Score.Statistics.GetOrDefault(HitResult.SmallTickMiss);
- misses = Score.Statistics.GetOrDefault(HitResult.Miss);
-
- // Don't count scores made with supposedly unranked mods
- if (mods.Any(m => !m.Ranked))
- return 0;
+ fruitsHit = Score.Statistics.GetValueOrDefault(HitResult.Great);
+ ticksHit = Score.Statistics.GetValueOrDefault(HitResult.LargeTickHit);
+ tinyTicksHit = Score.Statistics.GetValueOrDefault(HitResult.SmallTickHit);
+ tinyTicksMissed = Score.Statistics.GetValueOrDefault(HitResult.SmallTickMiss);
+ misses = Score.Statistics.GetValueOrDefault(HitResult.Miss);
// We are heavily relying on aim in catch the beat
double value = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0;
diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs
index d936ef97ac..e19098c580 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs
@@ -24,8 +24,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
///
public readonly double StrainTime;
- public readonly double ClockRate;
-
public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth)
: base(hitObject, lastObject, clockRate)
{
@@ -37,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
// 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 75e17f6c48..4372ed938c 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs
@@ -28,10 +28,21 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
private float lastDistanceMoved;
private double lastStrainTime;
- public Movement(Mod[] mods, float halfCatcherWidth)
+ ///
+ /// The speed multiplier applied to the player's catcher.
+ ///
+ private readonly double catcherSpeedMultiplier;
+
+ public Movement(Mod[] mods, float halfCatcherWidth, double clockRate)
: base(mods)
{
HalfCatcherWidth = halfCatcherWidth;
+
+ // In catch, clockrate adjustments do not only affect the timings of hitobjects,
+ // but also the speed of the player's catcher, which has an impact on difficulty
+ // TODO: Support variable clockrates caused by mods such as ModTimeRamp
+ // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing)
+ catcherSpeedMultiplier = clockRate;
}
protected override double StrainValueOf(DifficultyHitObject current)
@@ -48,7 +59,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
float distanceMoved = playerPosition - lastPlayerPosition.Value;
- double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catchCurrent.ClockRate);
+ double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);
@@ -81,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
playerPosition = catchCurrent.NormalizedPosition;
}
- 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
+ distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
lastPlayerPosition = playerPosition;
diff --git a/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs
new file mode 100644
index 0000000000..31075db7d1
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Tools;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class BananaShowerCompositionTool : HitObjectCompositionTool
+ {
+ public BananaShowerCompositionTool()
+ : base(nameof(BananaShower))
+ {
+ }
+
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
+
+ public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs
new file mode 100644
index 0000000000..6dea8b0712
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.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.Input.Events;
+using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class BananaShowerPlacementBlueprint : CatchPlacementBlueprint
+ {
+ private readonly TimeSpanOutline outline;
+
+ public BananaShowerPlacementBlueprint()
+ {
+ InternalChild = outline = new TimeSpanOutline();
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ outline.UpdateFrom(HitObjectContainer, HitObject);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ switch (PlacementActive)
+ {
+ case PlacementState.Waiting:
+ if (e.Button != MouseButton.Left) break;
+
+ BeginPlacement(true);
+ return true;
+
+ case PlacementState.Active:
+ if (e.Button != MouseButton.Right) break;
+
+ // If the duration is negative, swap the start and the end time to make the duration positive.
+ if (HitObject.Duration < 0)
+ {
+ HitObject.StartTime = HitObject.EndTime;
+ HitObject.Duration = -HitObject.Duration;
+ }
+
+ EndPlacement(HitObject.Duration > 0);
+ return true;
+ }
+
+ return base.OnMouseDown(e);
+ }
+
+ public override void UpdateTimeAndPosition(SnapResult result)
+ {
+ base.UpdateTimeAndPosition(result);
+
+ if (!(result.Time is double time)) return;
+
+ switch (PlacementActive)
+ {
+ case PlacementState.Waiting:
+ HitObject.StartTime = time;
+ break;
+
+ case PlacementState.Active:
+ HitObject.EndTime = time;
+ break;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs
new file mode 100644
index 0000000000..9132b1a9e8
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.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.Catch.Objects;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class BananaShowerSelectionBlueprint : CatchSelectionBlueprint
+ {
+ public BananaShowerSelectionBlueprint(BananaShower hitObject)
+ : base(hitObject)
+ {
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs
new file mode 100644
index 0000000000..5a32d241ad
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class CatchPlacementBlueprint : PlacementBlueprint
+ where THitObject : CatchHitObject, new()
+ {
+ protected new THitObject HitObject => (THitObject)base.HitObject;
+
+ protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer;
+
+ [Resolved]
+ private Playfield playfield { get; set; }
+
+ public CatchPlacementBlueprint()
+ : base(new THitObject())
+ {
+ }
+
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs
new file mode 100644
index 0000000000..7e566c810c
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.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.Allocation;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public abstract class CatchSelectionBlueprint : HitObjectSelectionBlueprint
+ where THitObject : CatchHitObject
+ {
+ protected override bool AlwaysShowWhenSelected => true;
+
+ public override Vector2 ScreenSpaceSelectionPoint
+ {
+ get
+ {
+ Vector2 position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
+ return HitObjectContainer.ToScreenSpace(position + new Vector2(0, HitObjectContainer.DrawHeight));
+ }
+ }
+
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SelectionQuad.Contains(screenSpacePos);
+
+ protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer;
+
+ [Resolved]
+ private Playfield playfield { get; set; }
+
+ protected CatchSelectionBlueprint(THitObject hitObject)
+ : base(hitObject)
+ {
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs
new file mode 100644
index 0000000000..0c03068e26
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs
@@ -0,0 +1,34 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Skinning.Default;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
+{
+ public class FruitOutline : CompositeDrawable
+ {
+ public FruitOutline()
+ {
+ Anchor = Anchor.BottomLeft;
+ Origin = Anchor.Centre;
+ InternalChild = new BorderPiece();
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour osuColour)
+ {
+ Colour = osuColour.Yellow;
+ }
+
+ public void UpdateFrom(CatchHitObject hitObject)
+ {
+ Scale = new Vector2(hitObject.Scale);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs
new file mode 100644
index 0000000000..cf916b27a4
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs
@@ -0,0 +1,48 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.UI.Scrolling;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
+{
+ public class NestedOutlineContainer : CompositeDrawable
+ {
+ private readonly List nestedHitObjects = new List();
+
+ public NestedOutlineContainer()
+ {
+ Anchor = Anchor.BottomLeft;
+ }
+
+ public void UpdateNestedObjectsFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject parentHitObject)
+ {
+ nestedHitObjects.Clear();
+ nestedHitObjects.AddRange(parentHitObject.NestedHitObjects
+ .OfType()
+ .Where(h => !(h is TinyDroplet)));
+
+ while (nestedHitObjects.Count < InternalChildren.Count)
+ RemoveInternal(InternalChildren[^1]);
+
+ while (InternalChildren.Count < nestedHitObjects.Count)
+ AddInternal(new FruitOutline());
+
+ for (int i = 0; i < nestedHitObjects.Count; i++)
+ {
+ var hitObject = nestedHitObjects[i];
+ var outline = (FruitOutline)InternalChildren[i];
+ outline.Position = CatchHitObjectUtils.GetStartPosition(hitObjectContainer, hitObject) - Position;
+ outline.UpdateFrom(hitObject);
+ outline.Scale *= hitObject is Droplet ? 0.5f : 1;
+ }
+ }
+
+ protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs
new file mode 100644
index 0000000000..109bf61ea5
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs
@@ -0,0 +1,77 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Lines;
+using osu.Framework.Graphics.Primitives;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
+{
+ public class ScrollingPath : CompositeDrawable
+ {
+ private readonly Path drawablePath;
+
+ private readonly List<(double Distance, float X)> vertices = new List<(double, float)>();
+
+ public ScrollingPath()
+ {
+ Anchor = Anchor.BottomLeft;
+
+ InternalChildren = new Drawable[]
+ {
+ drawablePath = new SmoothPath
+ {
+ PathRadius = 2,
+ Alpha = 0.5f
+ },
+ };
+ }
+
+ public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject)
+ {
+ double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity);
+
+ computeDistanceXs(hitObject);
+ drawablePath.Vertices = vertices
+ .Select(v => new Vector2(v.X, (float)(v.Distance * distanceToYFactor)))
+ .ToArray();
+ drawablePath.OriginPosition = drawablePath.PositionInBoundingBox(Vector2.Zero);
+ }
+
+ private void computeDistanceXs(JuiceStream hitObject)
+ {
+ vertices.Clear();
+
+ var sliderVertices = new List();
+ hitObject.Path.GetPathToProgress(sliderVertices, 0, 1);
+
+ if (sliderVertices.Count == 0)
+ return;
+
+ double distance = 0;
+ Vector2 lastPosition = Vector2.Zero;
+
+ for (int repeat = 0; repeat < hitObject.RepeatCount + 1; repeat++)
+ {
+ foreach (var position in sliderVertices)
+ {
+ distance += Vector2.Distance(lastPosition, position);
+ lastPosition = position;
+
+ vertices.Add((distance, position.X));
+ }
+
+ sliderVertices.Reverse();
+ }
+ }
+
+ // Because this has 0x0 size, the contents are otherwise masked away if the start position is outside the screen.
+ protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs
new file mode 100644
index 0000000000..65dfce0493
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs
@@ -0,0 +1,63 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using 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.Catch.Objects;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
+{
+ public class TimeSpanOutline : CompositeDrawable
+ {
+ private const float border_width = 4;
+
+ private const float opacity_when_empty = 0.5f;
+
+ private bool isEmpty = true;
+
+ public TimeSpanOutline()
+ {
+ Anchor = Origin = Anchor.BottomLeft;
+ RelativeSizeAxes = Axes.X;
+
+ Masking = true;
+ BorderThickness = border_width;
+ Alpha = opacity_when_empty;
+
+ // A box is needed to make the border visible.
+ InternalChild = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.Transparent
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour osuColour)
+ {
+ BorderColour = osuColour.Yellow;
+ }
+
+ public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, BananaShower hitObject)
+ {
+ float startY = hitObjectContainer.PositionAtTime(hitObject.StartTime);
+ float endY = hitObjectContainer.PositionAtTime(hitObject.EndTime);
+
+ Y = Math.Max(startY, endY);
+ float height = Math.Abs(startY - endY);
+
+ bool wasEmpty = isEmpty;
+ isEmpty = height == 0;
+ if (wasEmpty != isEmpty)
+ this.FadeTo(isEmpty ? opacity_when_empty : 1f, 150);
+
+ Height = Math.Max(height, border_width);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs
new file mode 100644
index 0000000000..e169e3b75c
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs
@@ -0,0 +1,51 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Input.Events;
+using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class FruitPlacementBlueprint : CatchPlacementBlueprint
+ {
+ private readonly FruitOutline outline;
+
+ public FruitPlacementBlueprint()
+ {
+ InternalChild = outline = new FruitOutline();
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ BeginPlacement();
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
+ outline.UpdateFrom(HitObject);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ if (e.Button != MouseButton.Left) return base.OnMouseDown(e);
+
+ EndPlacement(true);
+ return true;
+ }
+
+ public override void UpdateTimeAndPosition(SnapResult result)
+ {
+ base.UpdateTimeAndPosition(result);
+
+ HitObject.X = ToLocalSpace(result.ScreenSpacePosition).X;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs
new file mode 100644
index 0000000000..150297badb
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.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.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class FruitSelectionBlueprint : CatchSelectionBlueprint
+ {
+ private readonly FruitOutline outline;
+
+ public FruitSelectionBlueprint(Fruit hitObject)
+ : base(hitObject)
+ {
+ InternalChild = outline = new FruitOutline();
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (!IsSelected) return;
+
+ outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
+ outline.UpdateFrom(HitObject);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs
new file mode 100644
index 0000000000..0614c4c24d
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs
@@ -0,0 +1,91 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Caching;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Primitives;
+using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Objects;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit.Blueprints
+{
+ public class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint
+ {
+ public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight)));
+
+ private float minNestedX;
+ private float maxNestedX;
+
+ private readonly ScrollingPath scrollingPath;
+
+ private readonly NestedOutlineContainer nestedOutlineContainer;
+
+ private readonly Cached pathCache = new Cached();
+
+ public JuiceStreamSelectionBlueprint(JuiceStream hitObject)
+ : base(hitObject)
+ {
+ InternalChildren = new Drawable[]
+ {
+ scrollingPath = new ScrollingPath(),
+ nestedOutlineContainer = new NestedOutlineContainer()
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ HitObject.DefaultsApplied += onDefaultsApplied;
+ computeObjectBounds();
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (!IsSelected) return;
+
+ nestedOutlineContainer.Position = scrollingPath.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
+
+ if (pathCache.IsValid) return;
+
+ scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
+ nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
+
+ pathCache.Validate();
+ }
+
+ private void onDefaultsApplied(HitObject _)
+ {
+ computeObjectBounds();
+ pathCache.Invalidate();
+ }
+
+ private void computeObjectBounds()
+ {
+ minNestedX = HitObject.NestedHitObjects.OfType().Min(nested => nested.OriginalX) - HitObject.OriginalX;
+ maxNestedX = HitObject.NestedHitObjects.OfType().Max(nested => nested.OriginalX) - HitObject.OriginalX;
+ }
+
+ private RectangleF getBoundingBox()
+ {
+ float left = HitObject.OriginalX + minNestedX;
+ float right = HitObject.OriginalX + maxNestedX;
+ float top = HitObjectContainer.PositionAtTime(HitObject.EndTime);
+ float bottom = HitObjectContainer.PositionAtTime(HitObject.StartTime);
+ float objectRadius = CatchHitObject.OBJECT_RADIUS * HitObject.Scale;
+ return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius);
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ HitObject.DefaultsApplied -= onDefaultsApplied;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs
new file mode 100644
index 0000000000..7f2782a474
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Screens.Edit.Compose.Components;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class CatchBlueprintContainer : ComposeBlueprintContainer
+ {
+ public CatchBlueprintContainer(CatchHitObjectComposer composer)
+ : base(composer)
+ {
+ }
+
+ protected override SelectionHandler CreateSelectionHandler() => new CatchSelectionHandler();
+
+ public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject)
+ {
+ switch (hitObject)
+ {
+ case Fruit fruit:
+ return new FruitSelectionBlueprint(fruit);
+
+ case JuiceStream juiceStream:
+ return new JuiceStreamSelectionBlueprint(juiceStream);
+
+ case BananaShower bananaShower:
+ return new BananaShowerSelectionBlueprint(bananaShower);
+ }
+
+ return base.CreateHitObjectBlueprintFor(hitObject);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs
new file mode 100644
index 0000000000..d383eb9ba6
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs
@@ -0,0 +1,27 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.UI;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class CatchEditorPlayfield : CatchPlayfield
+ {
+ // TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen.
+ public CatchEditorPlayfield(BeatmapDifficulty difficulty)
+ : base(difficulty)
+ {
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ // TODO: honor "hit animation" setting?
+ CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
+
+ // TODO: disable hit lighting as well
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs
new file mode 100644
index 0000000000..d360274aa6
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs
@@ -0,0 +1,54 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Tools;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class CatchHitObjectComposer : HitObjectComposer
+ {
+ public CatchHitObjectComposer(CatchRuleset ruleset)
+ : base(ruleset)
+ {
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ LayerBelowRuleset.Add(new PlayfieldBorder
+ {
+ RelativeSizeAxes = Axes.Both,
+ PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners }
+ });
+ }
+
+ protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) =>
+ new DrawableCatchEditorRuleset(ruleset, beatmap, mods);
+
+ protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[]
+ {
+ new FruitCompositionTool(),
+ new BananaShowerCompositionTool()
+ };
+
+ public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition)
+ {
+ var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition);
+ // TODO: implement position snap
+ result.ScreenSpacePosition.X = screenSpacePosition.X;
+ return result;
+ }
+
+ protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs
new file mode 100644
index 0000000000..beffdf0362
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.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.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ ///
+ /// Utility functions used by the editor.
+ ///
+ public static class CatchHitObjectUtils
+ {
+ ///
+ /// Get the position of the hit object in the playfield based on and .
+ ///
+ public static Vector2 GetStartPosition(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject)
+ {
+ return new Vector2(hitObject.OriginalX, hitObjectContainer.PositionAtTime(hitObject.StartTime));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs
new file mode 100644
index 0000000000..7eebf04ca2
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs
@@ -0,0 +1,116 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class CatchSelectionHandler : EditorSelectionHandler
+ {
+ protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer;
+
+ [Resolved]
+ private Playfield playfield { get; set; }
+
+ public override bool HandleMovement(MoveSelectionEvent moveEvent)
+ {
+ var blueprint = moveEvent.Blueprint;
+ Vector2 originalPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint);
+ Vector2 targetPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta);
+
+ float deltaX = targetPosition.X - originalPosition.X;
+ deltaX = limitMovement(deltaX, EditorBeatmap.SelectedHitObjects);
+
+ if (deltaX == 0)
+ {
+ // Even if there is no positional change, there may be a time change.
+ return true;
+ }
+
+ EditorBeatmap.PerformOnSelection(h =>
+ {
+ if (!(h is CatchHitObject hitObject)) return;
+
+ hitObject.OriginalX += deltaX;
+
+ // Move the nested hit objects to give an instant result before nested objects are recreated.
+ foreach (var nested in hitObject.NestedHitObjects.OfType())
+ nested.OriginalX += deltaX;
+ });
+
+ return true;
+ }
+
+ ///
+ /// Limit positional movement of the objects by the constraint that moved objects should stay in bounds.
+ ///
+ /// The positional movement.
+ /// The objects to be moved.
+ /// The positional movement with the restriction applied.
+ private float limitMovement(float deltaX, IEnumerable movingObjects)
+ {
+ float minX = float.PositiveInfinity;
+ float maxX = float.NegativeInfinity;
+
+ foreach (float x in movingObjects.SelectMany(getOriginalPositions))
+ {
+ minX = Math.Min(minX, x);
+ maxX = Math.Max(maxX, x);
+ }
+
+ // To make an object with position `x` stay in bounds after `deltaX` movement, `0 <= x + deltaX <= WIDTH` should be satisfied.
+ // Subtracting `x`, we get `-x <= deltaX <= WIDTH - x`.
+ // We only need to apply the inequality to extreme values of `x`.
+ float lowerBound = -minX;
+ float upperBound = CatchPlayfield.WIDTH - maxX;
+ // The inequality may be unsatisfiable if the objects were already out of bounds.
+ // In that case, don't move objects at all.
+ if (lowerBound > upperBound)
+ return 0;
+
+ return Math.Clamp(deltaX, lowerBound, upperBound);
+ }
+
+ ///
+ /// Enumerate X positions that should be contained in-bounds after move offset is applied.
+ ///
+ private IEnumerable getOriginalPositions(HitObject hitObject)
+ {
+ switch (hitObject)
+ {
+ case Fruit fruit:
+ yield return fruit.OriginalX;
+
+ break;
+
+ case JuiceStream juiceStream:
+ foreach (var nested in juiceStream.NestedHitObjects.OfType())
+ {
+ // Even if `OriginalX` is outside the playfield, tiny droplets can be moved inside the playfield after the random offset application.
+ if (!(nested is TinyDroplet))
+ yield return nested.OriginalX;
+ }
+
+ break;
+
+ case BananaShower _:
+ // A banana shower occupies the whole screen width.
+ // If the selection contains a banana shower, the selection cannot be moved horizontally.
+ yield return 0;
+ yield return CatchPlayfield.WIDTH;
+
+ break;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs
new file mode 100644
index 0000000000..0344709d45
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.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.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class DrawableCatchEditorRuleset : DrawableCatchRuleset
+ {
+ public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null)
+ : base(ruleset, beatmap, mods)
+ {
+ }
+
+ protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs
new file mode 100644
index 0000000000..f776fe39c1
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Edit.Blueprints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Tools;
+
+namespace osu.Game.Rulesets.Catch.Edit
+{
+ public class FruitCompositionTool : HitObjectCompositionTool
+ {
+ public FruitCompositionTool()
+ : base(nameof(Fruit))
+ {
+ }
+
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
+
+ public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs
index e1eceea606..f1b51e51d0 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs
@@ -3,7 +3,6 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
-using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
@@ -11,7 +10,7 @@ using osu.Game.Users;
namespace osu.Game.Rulesets.Catch.Mods
{
- public class CatchModAutoplay : ModAutoplay
+ public class CatchModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs
index 5f1736450a..e59a0a0431 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs
@@ -5,39 +5,35 @@ using System.Linq;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
+using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Catch.Mods
{
- public class CatchModDifficultyAdjust : ModDifficultyAdjust
+ public class CatchModDifficultyAdjust : ModDifficultyAdjust, IApplicableToBeatmapProcessor
{
- [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)]
- public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension
+ [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))]
+ public DifficultyBindable CircleSize { get; } = new DifficultyBindable
{
Precision = 0.1f,
MinValue = 1,
MaxValue = 10,
- Default = 5,
- Value = 5,
+ ExtendedMaxValue = 11,
+ ReadCurrentFromDifficulty = diff => diff.CircleSize,
};
- [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)]
- public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension
+ [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))]
+ public DifficultyBindable ApproachRate { get; } = new DifficultyBindable
{
Precision = 0.1f,
MinValue = 1,
MaxValue = 10,
- Default = 5,
- Value = 5,
+ ExtendedMaxValue = 11,
+ ReadCurrentFromDifficulty = diff => diff.ApproachRate,
};
- protected override void ApplyLimits(bool extended)
- {
- base.ApplyLimits(extended);
-
- CircleSize.MaxValue = extended ? 11 : 10;
- ApproachRate.MaxValue = extended ? 11 : 10;
- }
+ [SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")]
+ public BindableBool HardRockOffsets { get; } = new BindableBool();
public override string SettingDescription
{
@@ -45,30 +41,30 @@ namespace osu.Game.Rulesets.Catch.Mods
{
string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}";
string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}";
+ string spicyPatterns = HardRockOffsets.IsDefault ? string.Empty : "Spicy patterns";
return string.Join(", ", new[]
{
circleSize,
base.SettingDescription,
- approachRate
+ approachRate,
+ spicyPatterns,
}.Where(s => !string.IsNullOrEmpty(s)));
}
}
- protected override void TransferSettings(BeatmapDifficulty difficulty)
- {
- base.TransferSettings(difficulty);
-
- TransferSetting(CircleSize, difficulty.CircleSize);
- TransferSetting(ApproachRate, difficulty.ApproachRate);
- }
-
protected override void ApplySettings(BeatmapDifficulty difficulty)
{
base.ApplySettings(difficulty);
- ApplySetting(CircleSize, cs => difficulty.CircleSize = cs);
- ApplySetting(ApproachRate, ar => difficulty.ApproachRate = ar);
+ if (CircleSize.Value != null) difficulty.CircleSize = CircleSize.Value.Value;
+ if (ApproachRate.Value != null) difficulty.ApproachRate = ApproachRate.Value.Value;
+ }
+
+ public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor)
+ {
+ var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
+ catchProcessor.HardRockOffsets = HardRockOffsets.Value;
}
}
}
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs
index ced1900ba9..68b6ce96a3 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs
@@ -7,11 +7,14 @@ using osu.Game.Rulesets.Catch.Beatmaps;
namespace osu.Game.Rulesets.Catch.Mods
{
- public class CatchModHardRock : ModHardRock, IApplicableToBeatmap
+ public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor
{
public override double ScoreMultiplier => 1.12;
- public override bool Ranked => true;
- public void ApplyToBeatmap(IBeatmap beatmap) => CatchBeatmapProcessor.ApplyPositionOffsets(beatmap, this);
+ public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor)
+ {
+ var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
+ catchProcessor.HardRockOffsets = true;
+ }
}
}
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
index 7bad4c79cb..f9e106f097 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
@@ -29,8 +29,7 @@ namespace osu.Game.Rulesets.Catch.Mods
}
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
- {
- }
+ => ApplyNormalVisibilityState(hitObject, state);
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs
index 1e42c6a240..73b60f51a4 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs
@@ -33,13 +33,13 @@ namespace osu.Game.Rulesets.Catch.Mods
private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition
{
- private readonly Catcher catcher;
+ private readonly CatcherArea catcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public MouseInputHelper(CatchPlayfield playfield)
{
- catcher = playfield.CatcherArea.MovableCatcher;
+ catcherArea = playfield.CatcherArea;
RelativeSizeAxes = Axes.Both;
}
@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Mods
protected override bool OnMouseMove(MouseMoveEvent e)
{
- catcher.UpdatePosition(e.MousePosition.X / DrawSize.X * CatchPlayfield.WIDTH);
+ catcherArea.SetCatcherPosition(e.MousePosition.X / DrawSize.X * CatchPlayfield.WIDTH);
return base.OnMouseMove(e);
}
}
diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs
index 178306b3bc..e5a36d08db 100644
--- a/osu.Game.Rulesets.Catch/Objects/Banana.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs
@@ -9,6 +9,7 @@ using osu.Game.Audio;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK.Graphics;
@@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Objects
}
// override any external colour changes with banananana
- Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => getBananaColour();
+ Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour();
private Color4 getBananaColour()
{
diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
index ae45182960..f979e3e0ca 100644
--- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -8,10 +9,11 @@ using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
+using osuTK;
namespace osu.Game.Rulesets.Catch.Objects
{
- public abstract class CatchHitObject : HitObject, IHasXPosition, IHasComboInformation
+ public abstract class CatchHitObject : HitObject, IHasPosition, IHasComboInformation
{
public const float OBJECT_RADIUS = 64;
@@ -20,13 +22,16 @@ namespace osu.Game.Rulesets.Catch.Objects
///
/// The horizontal position of the hit object between 0 and .
///
+ ///
+ /// Only setter is exposed.
+ /// Use or to get the horizontal position.
+ ///
+ [JsonIgnore]
public float X
{
set => OriginalXBindable.Value = value;
}
- float IHasXPosition.X => OriginalXBindable.Value;
-
public readonly Bindable XOffsetBindable = new Bindable();
///
@@ -34,6 +39,7 @@ namespace osu.Game.Rulesets.Catch.Objects
///
public float XOffset
{
+ get => XOffsetBindable.Value;
set => XOffsetBindable.Value = value;
}
@@ -44,7 +50,11 @@ namespace osu.Game.Rulesets.Catch.Objects
/// This value is the original value specified in the beatmap, not affected by the beatmap processing.
/// Use for a gameplay.
///
- public float OriginalX => OriginalXBindable.Value;
+ public float OriginalX
+ {
+ get => OriginalXBindable.Value;
+ set => OriginalXBindable.Value = value;
+ }
///
/// The effective horizontal position of the hit object between 0 and .
@@ -53,9 +63,9 @@ namespace osu.Game.Rulesets.Catch.Objects
/// This value is the original value plus the offset applied by the beatmap processing.
/// Use if a value not affected by the offset is desired.
///
- public float EffectiveX => OriginalXBindable.Value + XOffsetBindable.Value;
+ public float EffectiveX => OriginalX + XOffset;
- public double TimePreempt = 1000;
+ public double TimePreempt { get; set; } = 1000;
public readonly Bindable IndexInBeatmapBindable = new Bindable();
@@ -120,5 +130,24 @@ namespace osu.Game.Rulesets.Catch.Objects
}
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
+
+ #region Hit object conversion
+
+ // The half of the height of the osu! playfield.
+ public const float DEFAULT_LEGACY_CONVERT_Y = 192;
+
+ ///
+ /// The Y position of the hit object is not used in the normal osu!catch gameplay.
+ /// It is preserved to maximize the backward compatibility with the legacy editor, in which the mappers use the Y position to organize the patterns.
+ ///
+ public float LegacyConvertedY { get; set; } = DEFAULT_LEGACY_CONVERT_Y;
+
+ float IHasXPosition.X => OriginalX;
+
+ float IHasYPosition.Y => LegacyConvertedY;
+
+ Vector2 IHasPosition.Position => new Vector2(OriginalX, LegacyConvertedY);
+
+ #endregion
}
}
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs
index 140b411c88..7c88090a20 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Framework.Bindables;
using osu.Game.Rulesets.Catch.Skinning.Default;
namespace osu.Game.Rulesets.Catch.Objects.Drawables
@@ -9,21 +8,11 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
///
/// Represents a caught by the catcher.
///
- public class CaughtFruit : CaughtObject, IHasFruitState
+ public class CaughtFruit : CaughtObject
{
- public Bindable VisualRepresentation { get; } = new Bindable();
-
public CaughtFruit()
: base(CatchSkinComponents.Fruit, _ => new FruitPiece())
{
}
-
- public override void CopyStateFrom(IHasCatchObjectState objectState)
- {
- base.CopyStateFrom(objectState);
-
- var fruitState = (IHasFruitState)objectState;
- VisualRepresentation.Value = fruitState.VisualRepresentation.Value;
- }
}
}
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs
index 524505d588..d8bce9bb6d 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs
@@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
public PalpableCatchHitObject HitObject { get; private set; }
public Bindable AccentColour { get; } = new Bindable();
public Bindable HyperDash { get; } = new Bindable();
+ public Bindable IndexInBeatmap { get; } = new Bindable();
public Vector2 DisplaySize => Size * Scale;
@@ -51,6 +52,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
Rotation = objectState.DisplayRotation;
AccentColour.Value = objectState.AccentColour.Value;
HyperDash.Value = objectState.HyperDash.Value;
+ IndexInBeatmap.Value = objectState.IndexInBeatmap.Value;
}
protected override void FreeAfterUse()
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs
index 0b89c46480..0af7ee6c30 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs
@@ -3,17 +3,14 @@
using JetBrains.Annotations;
using osu.Framework.Allocation;
-using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
- public class DrawableFruit : DrawablePalpableCatchHitObject, IHasFruitState
+ public class DrawableFruit : DrawablePalpableCatchHitObject
{
- public Bindable VisualRepresentation { get; } = new Bindable();
-
public DrawableFruit()
: this(null)
{
@@ -27,11 +24,6 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
[BackgroundDependencyLoader]
private void load()
{
- IndexInBeatmap.BindValueChanged(change =>
- {
- VisualRepresentation.Value = (FruitVisualRepresentation)(change.NewValue % 4);
- }, true);
-
ScalingContainer.Child = new SkinnableDrawable(
new CatchSkinComponent(CatchSkinComponents.Fruit),
_ => new FruitPiece());
@@ -44,12 +36,4 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
ScalingContainer.RotateTo((RandomSingle(1) - 0.5f) * 40);
}
}
-
- public enum FruitVisualRepresentation
- {
- Pear,
- Grape,
- Pineapple,
- Raspberry,
- }
}
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs
index 81b61f0959..be0ee2821e 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs
@@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
Bindable HyperDash { get; }
+ Bindable IndexInBeatmap { get; }
+
Vector2 DisplaySize { get; }
float DisplayRotation { get; }
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.cs
deleted file mode 100644
index 2d4de543c3..0000000000
--- a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.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.Framework.Bindables;
-
-namespace osu.Game.Rulesets.Catch.Objects.Drawables
-{
- ///
- /// Provides a visual state of a .
- ///
- public interface IHasFruitState : IHasCatchObjectState
- {
- Bindable VisualRepresentation { get; }
- }
-}
diff --git a/osu.Game.Rulesets.Catch/Objects/Fruit.cs b/osu.Game.Rulesets.Catch/Objects/Fruit.cs
index 43486796ad..4818fe2cad 100644
--- a/osu.Game.Rulesets.Catch/Objects/Fruit.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Fruit.cs
@@ -9,5 +9,7 @@ namespace osu.Game.Rulesets.Catch.Objects
public class Fruit : PalpableCatchHitObject
{
public override Judgement CreateJudgement() => new CatchJudgement();
+
+ public static FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => (FruitVisualRepresentation)(indexInBeatmap % 4);
}
}
diff --git a/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.cs b/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.cs
new file mode 100644
index 0000000000..7ec7050245
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.cs
@@ -0,0 +1,13 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Catch.Objects
+{
+ public enum FruitVisualRepresentation
+ {
+ Pear,
+ Grape,
+ Pineapple,
+ Raspberry,
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
index 35fd58826e..3088d024d1 100644
--- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
+++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using Newtonsoft.Json;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -25,7 +26,10 @@ namespace osu.Game.Rulesets.Catch.Objects
public int RepeatCount { get; set; }
+ [JsonIgnore]
public double Velocity { get; private set; }
+
+ [JsonIgnore]
public double TickDistance { get; private set; }
///
@@ -113,6 +117,7 @@ namespace osu.Game.Rulesets.Catch.Objects
public float EndX => OriginalX + this.CurvePositionAt(1).X;
+ [JsonIgnore]
public double Duration
{
get => this.SpanCount() * Path.Distance / Velocity;
diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs
new file mode 100644
index 0000000000..f1cdb39e91
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs
@@ -0,0 +1,340 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Utils;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osuTK;
+
+#nullable enable
+
+namespace osu.Game.Rulesets.Catch.Objects
+{
+ ///
+ /// Represents the path of a juice stream.
+ ///
+ /// A holds a legacy as the representation of the path.
+ /// However, the representation is difficult to work with.
+ /// This represents the path in a more convenient way, a polyline connecting list of s.
+ ///
+ ///
+ /// The path can be regarded as a function from the closed interval [Vertices[0].Distance, Vertices[^1].Distance] to the x position, given by .
+ /// To ensure the path is convertible to a , the slope of the function must not be more than 1 everywhere,
+ /// and this slope condition is always maintained as an invariant.
+ ///
+ ///
+ public class JuiceStreamPath
+ {
+ ///
+ /// The height of legacy osu!standard playfield.
+ /// The sliders converted by are vertically contained in this height.
+ ///
+ internal const float OSU_PLAYFIELD_HEIGHT = 384;
+
+ ///
+ /// The list of vertices of the path, which is represented as a polyline connecting the vertices.
+ ///
+ public IReadOnlyList Vertices => vertices;
+
+ ///
+ /// The current version number.
+ /// This starts from 1 and incremented whenever this is modified.
+ ///
+ public int InvalidationID { get; private set; } = 1;
+
+ ///
+ /// The difference between first vertex's and last vertex's .
+ ///
+ public double Distance => vertices[^1].Distance - vertices[0].Distance;
+
+ ///
+ /// This list should always be non-empty.
+ ///
+ private readonly List vertices = new List
+ {
+ new JuiceStreamPathVertex()
+ };
+
+ ///
+ /// Compute the x-position of the path at the given .
+ ///
+ ///
+ /// When the given distance is outside of the path, the x position at the corresponding endpoint is returned,
+ ///
+ public float PositionAtDistance(double distance)
+ {
+ int index = vertexIndexAtDistance(distance);
+ return positionAtDistance(distance, index);
+ }
+
+ ///
+ /// Remove all vertices of this path, then add a new vertex (0, 0).
+ ///
+ public void Clear()
+ {
+ vertices.Clear();
+ vertices.Add(new JuiceStreamPathVertex());
+ invalidate();
+ }
+
+ ///
+ /// Insert a vertex at given .
+ /// The is used as the position of the new vertex.
+ /// Thus, the set of points of the path is not changed (up to floating-point precision).
+ ///
+ /// The index of the new vertex.
+ public int InsertVertex(double distance)
+ {
+ if (!double.IsFinite(distance))
+ throw new ArgumentOutOfRangeException(nameof(distance));
+
+ int index = vertexIndexAtDistance(distance);
+ float x = positionAtDistance(distance, index);
+ vertices.Insert(index, new JuiceStreamPathVertex(distance, x));
+
+ invalidate();
+ return index;
+ }
+
+ ///
+ /// Move the vertex of given to the given position .
+ /// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards .
+ ///
+ public void SetVertexPosition(int index, float newX)
+ {
+ if (index < 0 || index >= vertices.Count)
+ throw new ArgumentOutOfRangeException(nameof(index));
+
+ if (!float.IsFinite(newX))
+ throw new ArgumentOutOfRangeException(nameof(newX));
+
+ var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX);
+
+ for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--)
+ {
+ float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
+ vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
+ }
+
+ for (int i = index + 1; i < vertices.Count; i++)
+ {
+ float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
+ vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
+ }
+
+ vertices[index] = newVertex;
+
+ invalidate();
+ }
+
+ ///
+ /// Add a new vertex at given and position.
+ /// Adjacent vertices are moved when necessary in the same way as .
+ ///
+ public void Add(double distance, float x)
+ {
+ int index = InsertVertex(distance);
+ SetVertexPosition(index, x);
+ }
+
+ ///
+ /// Remove all vertices that satisfy the given .
+ ///
+ ///
+ /// If all vertices are removed, a new vertex (0, 0) is added.
+ ///
+ /// The predicate to determine whether a vertex should be removed given the vertex and its index in the path.
+ /// The number of removed vertices.
+ public int RemoveVertices(Func predicate)
+ {
+ int index = 0;
+ int removeCount = vertices.RemoveAll(vertex => predicate(vertex, index++));
+
+ if (vertices.Count == 0)
+ vertices.Add(new JuiceStreamPathVertex());
+
+ if (removeCount != 0)
+ invalidate();
+
+ return removeCount;
+ }
+
+ ///
+ /// Recreate this path by using difference set of vertices at given distances.
+ /// In addition to the given , the first vertex and the last vertex are always added to the new path.
+ /// New vertices use the positions on the original path. Thus, s at are preserved.
+ ///
+ public void ResampleVertices(IEnumerable sampleDistances)
+ {
+ var sampledVertices = new List();
+
+ foreach (double distance in sampleDistances)
+ {
+ if (!double.IsFinite(distance))
+ throw new ArgumentOutOfRangeException(nameof(sampleDistances));
+
+ double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance);
+ float x = PositionAtDistance(clampedDistance);
+ sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x));
+ }
+
+ sampledVertices.Sort();
+
+ // The first vertex and the last vertex are always used in the result.
+ vertices.RemoveRange(1, vertices.Count - (vertices.Count == 1 ? 1 : 2));
+ vertices.InsertRange(1, sampledVertices);
+
+ invalidate();
+ }
+
+ ///
+ /// Convert a to list of vertices and write the result to this .
+ ///
+ ///
+ /// Duplicated vertices are automatically removed.
+ ///
+ public void ConvertFromSliderPath(SliderPath sliderPath)
+ {
+ var sliderPathVertices = new List();
+ sliderPath.GetPathToProgress(sliderPathVertices, 0, 1);
+
+ double distance = 0;
+
+ vertices.Clear();
+ vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X));
+
+ for (int i = 1; i < sliderPathVertices.Count; i++)
+ {
+ distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]);
+
+ if (!Precision.AlmostEquals(vertices[^1].Distance, distance))
+ vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X));
+ }
+
+ invalidate();
+ }
+
+ ///
+ /// Convert the path of this to a and write the result to .
+ /// The resulting slider is "folded" to make it vertically contained in the playfield `(0..)` assuming the slider start position is .
+ ///
+ public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY)
+ {
+ const float margin = 1;
+
+ // Note: these two variables and `sliderPath` are modified by the local functions.
+ double currentDistance = 0;
+ Vector2 lastPosition = new Vector2(vertices[0].X, 0);
+
+ sliderPath.ControlPoints.Clear();
+ sliderPath.ControlPoints.Add(new PathControlPoint(lastPosition));
+
+ for (int i = 1; i < vertices.Count; i++)
+ {
+ sliderPath.ControlPoints[^1].Type.Value = PathType.Linear;
+
+ float deltaX = vertices[i].X - lastPosition.X;
+ double length = vertices[i].Distance - currentDistance;
+
+ // Should satisfy `deltaX^2 + deltaY^2 = length^2`.
+ // By invariants, the expression inside the `sqrt` is (almost) non-negative.
+ double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX));
+
+ // When `deltaY` is small, one segment is always enough.
+ // This case is handled separately to prevent divide-by-zero.
+ if (deltaY <= OSU_PLAYFIELD_HEIGHT / 2 - margin)
+ {
+ float nextX = vertices[i].X;
+ float nextY = (float)(lastPosition.Y + getYDirection() * deltaY);
+ addControlPoint(nextX, nextY);
+ continue;
+ }
+
+ // When `deltaY` is large or when the slider velocity is fast, the segment must be partitioned to subsegments to stay in bounds.
+ for (double currentProgress = 0; currentProgress < deltaY;)
+ {
+ double nextProgress = Math.Min(currentProgress + getMaxDeltaY(), deltaY);
+ float nextX = (float)(vertices[i - 1].X + nextProgress / deltaY * deltaX);
+ float nextY = (float)(lastPosition.Y + getYDirection() * (nextProgress - currentProgress));
+ addControlPoint(nextX, nextY);
+ currentProgress = nextProgress;
+ }
+ }
+
+ int getYDirection()
+ {
+ float lastSliderY = sliderStartY + lastPosition.Y;
+ return lastSliderY < OSU_PLAYFIELD_HEIGHT / 2 ? 1 : -1;
+ }
+
+ float getMaxDeltaY()
+ {
+ float lastSliderY = sliderStartY + lastPosition.Y;
+ return Math.Max(lastSliderY, OSU_PLAYFIELD_HEIGHT - lastSliderY) - margin;
+ }
+
+ void addControlPoint(float nextX, float nextY)
+ {
+ Vector2 nextPosition = new Vector2(nextX, nextY);
+ sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition));
+ currentDistance += Vector2.Distance(lastPosition, nextPosition);
+ lastPosition = nextPosition;
+ }
+ }
+
+ ///
+ /// Find the index at which a new vertex with can be inserted.
+ ///
+ private int vertexIndexAtDistance(double distance)
+ {
+ // The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed.
+ int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity));
+ return i < 0 ? ~i : i;
+ }
+
+ ///
+ /// Compute the position at the given , assuming is the vertex index returned by .
+ ///
+ private float positionAtDistance(double distance, int index)
+ {
+ if (index <= 0)
+ return vertices[0].X;
+ if (index >= vertices.Count)
+ return vertices[^1].X;
+
+ double length = vertices[index].Distance - vertices[index - 1].Distance;
+ if (Precision.AlmostEquals(length, 0))
+ return vertices[index].X;
+
+ float deltaX = vertices[index].X - vertices[index - 1].X;
+
+ return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length));
+ }
+
+ ///
+ /// Check the two vertices can connected directly while satisfying the slope condition.
+ ///
+ private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0)
+ {
+ double xDistance = Math.Abs((double)vertex2.X - vertex1.X);
+ float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance);
+ return xDistance <= length + allowance;
+ }
+
+ ///
+ /// Move the position of towards the position of
+ /// until the vertex pair satisfies the condition .
+ ///
+ /// The resulting position of .
+ private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex)
+ {
+ float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance);
+ return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length);
+ }
+
+ private void invalidate() => InvalidationID++;
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs
new file mode 100644
index 0000000000..58c50603c4
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs
@@ -0,0 +1,33 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+
+#nullable enable
+
+namespace osu.Game.Rulesets.Catch.Objects
+{
+ ///
+ /// A vertex of a .
+ ///
+ public readonly struct JuiceStreamPathVertex : IComparable
+ {
+ public readonly double Distance;
+
+ public readonly float X;
+
+ public JuiceStreamPathVertex(double distance, float x)
+ {
+ Distance = distance;
+ X = x;
+ }
+
+ public int CompareTo(JuiceStreamPathVertex other)
+ {
+ int c = Distance.CompareTo(other.Distance);
+ return c != 0 ? c : X.CompareTo(other.X);
+ }
+
+ public override string ToString() => $"({Distance}, {X})";
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs
index 0cd3af01df..4001a4ea76 100644
--- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs
@@ -1,9 +1,10 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System.Collections.Generic;
+using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Objects
@@ -33,6 +34,7 @@ namespace osu.Game.Rulesets.Catch.Objects
///
/// The target fruit if we are to initiate a hyperdash.
///
+ [JsonIgnore]
public CatchHitObject HyperDashTarget
{
get => hyperDashTarget;
@@ -43,6 +45,6 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
- Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count];
+ Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1);
}
}
diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs
deleted file mode 100644
index 0a444d923e..0000000000
--- a/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Scoring;
-
-namespace osu.Game.Rulesets.Catch.Scoring
-{
- public class CatchHitWindows : HitWindows
- {
- public override bool IsHitResultAllowed(HitResult result)
- {
- switch (result)
- {
- case HitResult.Great:
- case HitResult.Miss:
- return true;
- }
-
- return false;
- }
- }
-}
diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.cs
new file mode 100644
index 0000000000..ea8d742b1a
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.cs
@@ -0,0 +1,13 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Catch.Skinning
+{
+ public enum CatchSkinConfiguration
+ {
+ ///
+ /// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
+ ///
+ FlipCatcherPlate
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs
index 51c06c8e37..2db3bae034 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs
@@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
{
public readonly Bindable AccentColour = new Bindable();
public readonly Bindable HyperDash = new Bindable();
+ public readonly Bindable IndexInBeatmap = new Bindable();
[Resolved]
protected IHasCatchObjectState ObjectState { get; private set; }
@@ -37,6 +38,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
AccentColour.BindTo(ObjectState.AccentColour);
HyperDash.BindTo(ObjectState.HyperDash);
+ IndexInBeatmap.BindTo(ObjectState.IndexInBeatmap);
HyperDash.BindValueChanged(hyper =>
{
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs
new file mode 100644
index 0000000000..e423f21b98
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.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 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.Rulesets.Catch.UI;
+
+namespace osu.Game.Rulesets.Catch.Skinning.Default
+{
+ public class DefaultCatcher : CompositeDrawable
+ {
+ public Bindable CurrentState { get; } = new Bindable();
+
+ private readonly Sprite sprite;
+
+ private readonly Dictionary textures = new Dictionary();
+
+ public DefaultCatcher()
+ {
+ RelativeSizeAxes = Axes.Both;
+ InternalChild = sprite = new Sprite
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ RelativeSizeAxes = Axes.Both,
+ FillMode = FillMode.Fit
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(TextureStore store, Bindable currentState)
+ {
+ CurrentState.BindTo(currentState);
+
+ textures[CatcherAnimationState.Idle] = store.Get(@"Gameplay/catch/fruit-catcher-idle");
+ textures[CatcherAnimationState.Fail] = store.Get(@"Gameplay/catch/fruit-catcher-fail");
+ textures[CatcherAnimationState.Kiai] = store.Get(@"Gameplay/catch/fruit-catcher-kiai");
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ CurrentState.BindValueChanged(state => sprite.Texture = textures[state.NewValue], true);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs
index 49f128c960..cfe0df0c97 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs
@@ -3,7 +3,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
-using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.Objects;
namespace osu.Game.Rulesets.Catch.Skinning.Default
{
@@ -39,8 +39,10 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
{
base.LoadComplete();
- var fruitState = (IHasFruitState)ObjectState;
- VisualRepresentation.BindTo(fruitState.VisualRepresentation);
+ IndexInBeatmap.BindValueChanged(index =>
+ {
+ VisualRepresentation.Value = Fruit.GetVisualRepresentation(index.NewValue);
+ }, true);
}
}
}
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs
index 88e0b5133a..f097361d2a 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs
@@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
-using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.Objects;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Default
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
index 8c9e602cd4..5e744ec001 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
@@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
///
private bool providesComboCounter => this.HasFont(LegacyFont.Combo);
- public CatchLegacySkinTransformer(ISkinSource source)
- : base(source)
+ public CatchLegacySkinTransformer(ISkin skin)
+ : base(skin)
{
}
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (targetComponent.Target)
{
case SkinnableTarget.MainHUDComponents:
- var components = Source.GetDrawableComponent(component) as SkinnableTargetComponentsContainer;
+ var components = base.GetDrawableComponent(component) as SkinnableTargetComponentsContainer;
if (providesComboCounter && components != null)
{
@@ -65,27 +65,31 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
return null;
- case CatchSkinComponents.CatcherIdle:
- return this.GetAnimation("fruit-catcher-idle", true, true, true) ??
- this.GetAnimation("fruit-ryuuta", true, true, true);
+ case CatchSkinComponents.Catcher:
+ var version = GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value ?? 1;
- case CatchSkinComponents.CatcherFail:
- return this.GetAnimation("fruit-catcher-fail", true, true, true) ??
- this.GetAnimation("fruit-ryuuta", true, true, true);
+ if (version < 2.3m)
+ {
+ if (GetTexture(@"fruit-ryuuta") != null ||
+ GetTexture(@"fruit-ryuuta-0") != null)
+ return new LegacyCatcherOld();
+ }
- case CatchSkinComponents.CatcherKiai:
- return this.GetAnimation("fruit-catcher-kiai", true, true, true) ??
- this.GetAnimation("fruit-ryuuta", true, true, true);
+ if (GetTexture(@"fruit-catcher-idle") != null ||
+ GetTexture(@"fruit-catcher-idle-0") != null)
+ return new LegacyCatcherNew();
+
+ return null;
case CatchSkinComponents.CatchComboCounter:
if (providesComboCounter)
- return new LegacyCatchComboCounter(Source);
+ return new LegacyCatchComboCounter(Skin);
return null;
}
}
- return Source.GetDrawableComponent(component);
+ return base.GetDrawableComponent(component);
}
public override IBindable GetConfig(TLookup lookup)
@@ -93,15 +97,28 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (lookup)
{
case CatchSkinColour colour:
- var result = (Bindable)Source.GetConfig(new SkinCustomColourLookup(colour));
+ var result = (Bindable)base.GetConfig(new SkinCustomColourLookup(colour));
if (result == null)
return null;
result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value);
return (IBindable)result;
+
+ case CatchSkinConfiguration config:
+ switch (config)
+ {
+ case CatchSkinConfiguration.FlipCatcherPlate:
+ // Don't flip catcher plate contents if the catcher is provided by this legacy skin.
+ if (GetDrawableComponent(new CatchSkinComponent(CatchSkinComponents.Catcher)) != null)
+ return (IBindable)new Bindable();
+
+ break;
+ }
+
+ break;
}
- return Source.GetConfig(lookup);
+ return base.GetConfig(lookup);
}
}
}
diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs
similarity index 91%
rename from osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs
rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs
index f80e50c8c0..5bd5b0d4bb 100644
--- a/osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs
@@ -3,7 +3,7 @@
using osu.Framework.Graphics.Textures;
-namespace osu.Game.Rulesets.Catch.Skinning
+namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public class LegacyBananaPiece : LegacyCatchHitObjectPiece
{
diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs
similarity index 94%
rename from osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs
rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs
index 4b1f5a4724..f78724615a 100644
--- a/osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs
@@ -13,12 +13,13 @@ using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
-namespace osu.Game.Rulesets.Catch.Skinning
+namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public abstract class LegacyCatchHitObjectPiece : PoolableDrawable
{
public readonly Bindable AccentColour = new Bindable();
public readonly Bindable HyperDash = new Bindable();
+ public readonly Bindable IndexInBeatmap = new Bindable();
private readonly Sprite colouredSprite;
private readonly Sprite overlaySprite;
@@ -64,6 +65,7 @@ namespace osu.Game.Rulesets.Catch.Skinning
AccentColour.BindTo(ObjectState.AccentColour);
HyperDash.BindTo(ObjectState.HyperDash);
+ IndexInBeatmap.BindTo(ObjectState.IndexInBeatmap);
hyperSprite.Colour = Skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ??
Skin.GetConfig(CatchSkinColour.HyperDash)?.Value ??
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs
new file mode 100644
index 0000000000..9df87c92ea
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.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;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Animations;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Skinning;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Skinning.Legacy
+{
+ public class LegacyCatcherNew : CompositeDrawable
+ {
+ [Resolved]
+ private Bindable currentState { get; set; }
+
+ private readonly Dictionary drawables = new Dictionary();
+
+ private Drawable currentDrawable;
+
+ public LegacyCatcherNew()
+ {
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ISkinSource skin)
+ {
+ foreach (var state in Enum.GetValues(typeof(CatcherAnimationState)).Cast())
+ {
+ AddInternal(drawables[state] = getDrawableFor(state).With(d =>
+ {
+ d.Anchor = Anchor.TopCentre;
+ d.Origin = Anchor.TopCentre;
+ d.RelativeSizeAxes = Axes.Both;
+ d.Size = Vector2.One;
+ d.FillMode = FillMode.Fit;
+ d.Alpha = 0;
+ }));
+ }
+
+ currentDrawable = drawables[CatcherAnimationState.Idle];
+
+ Drawable getDrawableFor(CatcherAnimationState state) =>
+ skin.GetAnimation(@$"fruit-catcher-{state.ToString().ToLowerInvariant()}", true, true, true) ??
+ skin.GetAnimation(@"fruit-catcher-idle", true, true, true);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ currentState.BindValueChanged(state =>
+ {
+ currentDrawable.Alpha = 0;
+ currentDrawable = drawables[state.NewValue];
+ currentDrawable.Alpha = 1;
+
+ (currentDrawable as IFramedAnimation)?.GotoFrame(0);
+ }, true);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs
new file mode 100644
index 0000000000..3e679171b2
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.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.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Skinning;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.Skinning.Legacy
+{
+ public class LegacyCatcherOld : CompositeDrawable
+ {
+ public LegacyCatcherOld()
+ {
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ISkinSource skin)
+ {
+ InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true).With(d =>
+ {
+ d.Anchor = Anchor.TopCentre;
+ d.Origin = Anchor.TopCentre;
+ d.RelativeSizeAxes = Axes.Both;
+ d.Size = Vector2.One;
+ d.FillMode = FillMode.Fit;
+ });
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs
similarity index 93%
rename from osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs
rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs
index 8f4331d2a3..2c5cbe1e41 100644
--- a/osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs
@@ -4,7 +4,7 @@
using osu.Framework.Graphics.Textures;
using osuTK;
-namespace osu.Game.Rulesets.Catch.Skinning
+namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public class LegacyDropletPiece : LegacyCatchHitObjectPiece
{
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs
index 969cc38e5b..f002bab219 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs
@@ -1,23 +1,20 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Framework.Bindables;
-using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.Objects;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
internal class LegacyFruitPiece : LegacyCatchHitObjectPiece
{
- public readonly Bindable VisualRepresentation = new Bindable();
-
protected override void LoadComplete()
{
base.LoadComplete();
- var fruitState = (IHasFruitState)ObjectState;
- VisualRepresentation.BindTo(fruitState.VisualRepresentation);
-
- VisualRepresentation.BindValueChanged(visual => setTexture(visual.NewValue), true);
+ IndexInBeatmap.BindValueChanged(index =>
+ {
+ setTexture(Fruit.GetVisualRepresentation(index.NewValue));
+ }, true);
}
private void setTexture(FruitVisualRepresentation visualRepresentation)
diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs
index 75feb21298..ad344ff2dd 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs
@@ -25,9 +25,9 @@ namespace osu.Game.Rulesets.Catch.UI
{
}
- protected override void SkinChanged(ISkinSource skin, bool allowFallback)
+ protected override void SkinChanged(ISkinSource skin)
{
- base.SkinChanged(skin, allowFallback);
+ base.SkinChanged(skin);
ComboCounter?.UpdateCombo(currentCombo);
}
diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
index 0e1ef90737..05cd29dff5 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.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 osu.Framework.Allocation;
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.Drawables;
@@ -28,20 +26,18 @@ namespace osu.Game.Rulesets.Catch.UI
///
public const float CENTER_X = WIDTH / 2;
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
internal readonly CatcherArea CatcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
// only check the X position; handle all vertical space.
base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y));
- public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation)
+ public CatchPlayfield(BeatmapDifficulty difficulty)
{
- var droppedObjectContainer = new Container
- {
- RelativeSizeAxes = Axes.Both,
- };
-
- CatcherArea = new CatcherArea(droppedObjectContainer, difficulty)
+ CatcherArea = new CatcherArea(difficulty)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
@@ -49,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.UI
InternalChildren = new[]
{
- droppedObjectContainer,
+ droppedObjectContainer = new DroppedObjectContainer(),
CatcherArea.MovableCatcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs
index 0d6a577d1e..57523d3505 100644
--- a/osu.Game.Rulesets.Catch/UI/Catcher.cs
+++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs
@@ -7,10 +7,8 @@ using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
-using osu.Framework.Input.Bindings;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
@@ -25,7 +23,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI
{
- public class Catcher : SkinReloadableDrawable, IKeyBindingHandler
+ public class Catcher : SkinReloadableDrawable
{
///
/// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail
@@ -54,9 +52,9 @@ namespace osu.Game.Rulesets.Catch.UI
public const double BASE_SPEED = 1.0;
///
- /// The amount by which caught fruit should be offset from the plate surface to make them look visually "caught".
+ /// The current speed of the catcher.
///
- public const float CAUGHT_FRUIT_VERTICAL_OFFSET = -5;
+ public double Speed => (Dashing ? 1 : 0.5) * BASE_SPEED * hyperDashModifier;
///
/// The amount by which caught fruit should be scaled down to fit on the plate.
@@ -76,26 +74,26 @@ namespace osu.Game.Rulesets.Catch.UI
///
/// Contains objects dropped from the plate.
///
- private readonly Container droppedObjectTarget;
+ [Resolved]
+ private DroppedObjectContainer droppedObjectTarget { get; set; }
- public CatcherAnimationState CurrentState { get; private set; }
+ public CatcherAnimationState CurrentState
+ {
+ get => Body.AnimationState.Value;
+ private set => Body.AnimationState.Value = value;
+ }
///
/// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable.
///
public 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
+ set
{
if (value == dashing) return;
@@ -105,38 +103,40 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
+ ///
+ /// The currently facing direction.
+ ///
+ public Direction VisualDirection { get; set; } = Direction.Right;
+
+ ///
+ /// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
+ ///
+ private bool flipCatcherPlate;
+
///
/// Width of the area that can be used to attempt catches during gameplay.
///
private readonly float catchWidth;
- private readonly CatcherSprite catcherIdle;
- private readonly CatcherSprite catcherKiai;
- private readonly CatcherSprite catcherFail;
-
- private CatcherSprite currentCatcher;
+ internal readonly SkinnableCatcher Body;
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;
- private readonly DrawablePool hitExplosionPool;
- private readonly Container hitExplosionContainer;
+ private readonly HitExplosionContainer hitExplosionContainer;
private readonly DrawablePool caughtFruitPool;
private readonly DrawablePool caughtBananaPool;
private readonly DrawablePool caughtDropletPool;
- public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null)
+ public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
{
this.trailsTarget = trailsTarget;
- this.droppedObjectTarget = droppedObjectTarget;
Origin = Anchor.TopCentre;
@@ -148,7 +148,6 @@ namespace osu.Game.Rulesets.Catch.UI
InternalChildren = new Drawable[]
{
- hitExplosionPool = new DrawablePool(10),
caughtFruitPool = new DrawablePool(50),
caughtBananaPool = new DrawablePool(100),
// less capacity is needed compared to fruit because droplet is not stacked
@@ -157,23 +156,11 @@ namespace osu.Game.Rulesets.Catch.UI
{
Anchor = Anchor.TopCentre,
Origin = Anchor.BottomCentre,
+ // offset fruit vertically to better place "above" the plate.
+ Y = -5
},
- 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,
- },
- hitExplosionContainer = new Container
+ Body = new SkinnableCatcher(),
+ hitExplosionContainer = new HitExplosionContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.BottomCentre,
@@ -186,8 +173,6 @@ namespace osu.Game.Rulesets.Catch.UI
{
hitLighting = config.GetBindable(OsuSetting.HitLighting);
trails = new CatcherTrailDisplay(this);
-
- updateCatcher();
}
protected override void LoadComplete()
@@ -275,17 +260,16 @@ namespace osu.Game.Rulesets.Catch.UI
SetHyperDashState();
if (result.IsHit)
- updateState(hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle);
+ CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle;
else if (!(hitObject is Banana))
- updateState(CatcherAnimationState.Fail);
+ CurrentState = CatcherAnimationState.Fail;
}
public void OnRevertResult(DrawableCatchHitObject drawableObject, JudgementResult result)
{
var catchResult = (CatchJudgementResult)result;
- if (CurrentState != catchResult.CatcherAnimationState)
- updateState(catchResult.CatcherAnimationState);
+ CurrentState = catchResult.CatcherAnimationState;
if (HyperDashing != catchResult.CatcherHyperDash)
{
@@ -297,7 +281,6 @@ namespace osu.Game.Rulesets.Catch.UI
caughtObjectContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject);
droppedObjectTarget.RemoveAll(d => d.HitObject == drawableObject.HitObject);
- hitExplosionContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject);
}
///
@@ -331,55 +314,6 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
- 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;
- }
-
- 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;
- }
- }
-
///
/// Drop any fruit off the plate.
///
@@ -399,9 +333,9 @@ namespace osu.Game.Rulesets.Catch.UI
private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing;
- protected override void SkinChanged(ISkinSource skin, bool allowFallback)
+ protected override void SkinChanged(ISkinSource skin)
{
- base.SkinChanged(skin, allowFallback);
+ base.SkinChanged(skin);
hyperDashColour =
skin.GetConfig(CatchSkinColour.HyperDash)?.Value ??
@@ -414,6 +348,8 @@ namespace osu.Game.Rulesets.Catch.UI
trails.HyperDashTrailsColour = hyperDashColour;
trails.EndGlowSpritesColour = hyperDashEndGlowColour;
+ flipCatcherPlate = skin.GetConfig(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true;
+
runHyperDashStateTransition(HyperDashing);
}
@@ -421,14 +357,9 @@ namespace osu.Game.Rulesets.Catch.UI
{
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));
+ var scaleFromDirection = new Vector2((int)VisualDirection, 1);
+ Body.Scale = scaleFromDirection;
+ caughtObjectContainer.Scale = hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One;
// Correct overshooting.
if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) ||
@@ -439,38 +370,6 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
- 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 placeCaughtObject(DrawablePalpableCatchHitObject drawableObject, Vector2 position)
{
var caughtObject = getCaughtObject(drawableObject.HitObject);
@@ -496,9 +395,6 @@ namespace osu.Game.Rulesets.Catch.UI
float adjustedRadius = displayRadius * lenience_adjust;
float checkDistance = MathF.Pow(adjustedRadius, 2);
- // offset fruit vertically to better place "above" the plate.
- position.Y += CAUGHT_FRUIT_VERTICAL_OFFSET;
-
while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance))
{
position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius);
@@ -508,15 +404,8 @@ namespace osu.Game.Rulesets.Catch.UI
return position;
}
- private void addLighting(CatchHitObject hitObject, float x, Color4 colour)
- {
- HitExplosion hitExplosion = hitExplosionPool.Get();
- hitExplosion.HitObject = hitObject;
- hitExplosion.X = x;
- hitExplosion.Scale = new Vector2(hitObject.Scale);
- hitExplosion.ObjectColour = colour;
- hitExplosionContainer.Add(hitExplosion);
- }
+ private void addLighting(CatchHitObject hitObject, float x, Color4 colour) =>
+ hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, x, hitObject.Scale, colour, hitObject.RandomSeed));
private CaughtObject getCaughtObject(PalpableCatchHitObject source)
{
@@ -580,7 +469,7 @@ namespace osu.Game.Rulesets.Catch.UI
break;
case DroppedObjectAnimation.Explode:
- var originalX = droppedObjectTarget.ToSpaceOfOtherDrawable(d.DrawPosition, caughtObjectContainer).X * Scale.X;
+ float originalX = droppedObjectTarget.ToSpaceOfOtherDrawable(d.DrawPosition, caughtObjectContainer).X * caughtObjectContainer.Scale.X;
d.MoveToY(d.Y - 50, 250, Easing.OutSine).Then().MoveToY(d.Y + 50, 500, Easing.InSine);
d.MoveToX(d.X + originalX * 6, 1000);
d.FadeOut(750);
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
index 44adbd5512..fea314df8d 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.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.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Input.Bindings;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects.Drawables;
@@ -14,14 +16,21 @@ using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
- public class CatcherArea : Container
+ public class CatcherArea : Container, IKeyBindingHandler
{
public const float CATCHER_SIZE = 106.75f;
public readonly Catcher MovableCatcher;
private readonly CatchComboDisplay comboDisplay;
- public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null)
+ ///
+ /// -1 when only left button is pressed.
+ /// 1 when only right button is pressed.
+ /// 0 when none or both left and right buttons are pressed.
+ ///
+ private int currentDirection;
+
+ public CatcherArea(BeatmapDifficulty difficulty = null)
{
Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE);
Children = new Drawable[]
@@ -35,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.UI
Margin = new MarginPadding { Bottom = 350f },
X = CatchPlayfield.CENTER_X
},
- MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X },
+ MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X },
};
}
@@ -63,16 +72,73 @@ namespace osu.Game.Rulesets.Catch.UI
MovableCatcher.OnRevertResult(hitObject, result);
}
+ protected override void Update()
+ {
+ base.Update();
+
+ var replayState = (GetContainingInputManager().CurrentState as RulesetInputManagerInputState)?.LastReplayState as CatchFramedReplayInputHandler.CatchReplayState;
+
+ SetCatcherPosition(
+ replayState?.CatcherX ??
+ (float)(MovableCatcher.X + MovableCatcher.Speed * currentDirection * Clock.ElapsedFrameTime));
+ }
+
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
- var state = (GetContainingInputManager().CurrentState as RulesetInputManagerInputState)?.LastReplayState as CatchFramedReplayInputHandler.CatchReplayState;
-
- if (state?.CatcherX != null)
- MovableCatcher.X = state.CatcherX.Value;
-
comboDisplay.X = MovableCatcher.X;
}
+
+ public void SetCatcherPosition(float X)
+ {
+ float lastPosition = MovableCatcher.X;
+ float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH);
+
+ MovableCatcher.X = newPosition;
+
+ if (lastPosition < newPosition)
+ MovableCatcher.VisualDirection = Direction.Right;
+ else if (lastPosition > newPosition)
+ MovableCatcher.VisualDirection = Direction.Left;
+ }
+
+ public bool OnPressed(CatchAction action)
+ {
+ switch (action)
+ {
+ case CatchAction.MoveLeft:
+ currentDirection--;
+ return true;
+
+ case CatchAction.MoveRight:
+ currentDirection++;
+ return true;
+
+ case CatchAction.Dash:
+ MovableCatcher.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:
+ MovableCatcher.Dashing = false;
+ break;
+ }
+ }
}
}
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs
deleted file mode 100644
index ef69e3d2d1..0000000000
--- a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Allocation;
-using osu.Framework.Graphics;
-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 : SkinnableDrawable
- {
- 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.5f, 0.06f) * CatcherArea.CATCHER_SIZE;
- }
-
- private static CatchSkinComponents componentFromState(CatcherAnimationState state)
- {
- switch (state)
- {
- 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/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs
new file mode 100644
index 0000000000..c961d98dc5
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.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.Pooling;
+using osu.Framework.Timing;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ ///
+ /// A trail of the catcher.
+ /// It also represents a hyper dash afterimage.
+ ///
+ public class CatcherTrail : PoolableDrawable
+ {
+ public CatcherAnimationState AnimationState
+ {
+ set => body.AnimationState.Value = value;
+ }
+
+ private readonly SkinnableCatcher body;
+
+ public CatcherTrail()
+ {
+ Size = new Vector2(CatcherArea.CATCHER_SIZE);
+ Origin = Anchor.TopCentre;
+ Blending = BlendingParameters.Additive;
+ InternalChild = body = new SkinnableCatcher
+ {
+ // Using a frozen clock because trails should not be animated when the skin has an animated catcher.
+ // TODO: The animation should be frozen at the animation frame at the time of the trail generation.
+ Clock = new FramedClock(new ManualClock()),
+ };
+ }
+
+ protected override void FreeAfterUse()
+ {
+ ClearTransforms();
+ Alpha = 1;
+ base.FreeAfterUse();
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs
index fa65190032..b59fabcb70 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs
@@ -4,10 +4,8 @@
using System;
using JetBrains.Annotations;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
-using osu.Framework.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
@@ -21,11 +19,11 @@ namespace osu.Game.Rulesets.Catch.UI
{
private readonly Catcher catcher;
- private readonly DrawablePool trailPool;
+ private readonly DrawablePool trailPool;
- private readonly Container dashTrails;
- private readonly Container hyperDashTrails;
- private readonly Container endGlowSprites;
+ private readonly Container dashTrails;
+ private readonly Container hyperDashTrails;
+ private readonly Container endGlowSprites;
private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR;
@@ -85,10 +83,10 @@ namespace osu.Game.Rulesets.Catch.UI
InternalChildren = new Drawable[]
{
- trailPool = new DrawablePool(30),
- dashTrails = new Container { RelativeSizeAxes = Axes.Both },
- hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
- endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
+ trailPool = new DrawablePool(30),
+ dashTrails = new Container { RelativeSizeAxes = Axes.Both },
+ hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
+ endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
};
}
@@ -118,17 +116,12 @@ namespace osu.Game.Rulesets.Catch.UI
Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50);
}
- private CatcherTrailSprite createTrailSprite(Container target)
+ private CatcherTrail createTrailSprite(Container target)
{
- var texture = (catcher.CurrentDrawableCatcher as TextureAnimation)?.CurrentFrame ?? ((Sprite)catcher.CurrentDrawableCatcher).Texture;
+ CatcherTrail sprite = trailPool.Get();
- CatcherTrailSprite sprite = trailPool.Get();
-
- sprite.Texture = texture;
- sprite.Anchor = catcher.Anchor;
- sprite.Scale = catcher.Scale;
- sprite.Blending = BlendingParameters.Additive;
- sprite.RelativePositionAxes = catcher.RelativePositionAxes;
+ sprite.AnimationState = catcher.CurrentState;
+ sprite.Scale = catcher.Scale * catcher.Body.Scale;
sprite.Position = catcher.Position;
target.Add(sprite);
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs
deleted file mode 100644
index 0e3e409fac..0000000000
--- a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.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.Graphics;
-using osu.Framework.Graphics.Pooling;
-using osu.Framework.Graphics.Sprites;
-using osu.Framework.Graphics.Textures;
-using osuTK;
-
-namespace osu.Game.Rulesets.Catch.UI
-{
- public class CatcherTrailSprite : PoolableDrawable
- {
- public Texture Texture
- {
- set => sprite.Texture = value;
- }
-
- private readonly Sprite sprite;
-
- public CatcherTrailSprite()
- {
- InternalChild = sprite = new Sprite
- {
- RelativeSizeAxes = Axes.Both
- };
-
- 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;
- }
-
- protected override void FreeAfterUse()
- {
- ClearTransforms();
- base.FreeAfterUse();
- }
- }
-}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs b/osu.Game.Rulesets.Catch/UI/Direction.cs
similarity index 60%
rename from osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs
rename to osu.Game.Rulesets.Catch/UI/Direction.cs
index 219dad566d..65f064b7fb 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs
+++ b/osu.Game.Rulesets.Catch/UI/Direction.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.
-namespace osu.Game.Rulesets.Mania.Edit.Blueprints
+namespace osu.Game.Rulesets.Catch.UI
{
- public enum HoldNotePosition
+ public enum Direction
{
- Start,
- End
+ Right = 1,
+ Left = -1
}
}
diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
index 9389fa803b..8b6a074426 100644
--- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
+++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield);
- protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation);
+ protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer();
diff --git a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs
new file mode 100644
index 0000000000..b44b0caae4
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.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;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ public class DroppedObjectContainer : Container
+ {
+ public DroppedObjectContainer()
+ {
+ RelativeSizeAxes = Axes.Both;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs
index 26627422e1..d9ab428231 100644
--- a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs
+++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs
@@ -5,31 +5,16 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
-using osu.Framework.Graphics.Pooling;
using osu.Framework.Utils;
-using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Objects.Pooling;
+using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI
{
- public class HitExplosion : PoolableDrawable
+ public class HitExplosion : PoolableDrawableWithLifetime
{
- private Color4 objectColour;
- public CatchHitObject HitObject;
-
- public Color4 ObjectColour
- {
- get => objectColour;
- set
- {
- if (objectColour == value) return;
-
- objectColour = value;
- onColourChanged();
- }
- }
-
private readonly CircularContainer largeFaint;
private readonly CircularContainer smallFaint;
private readonly CircularContainer directionalGlow1;
@@ -83,9 +68,19 @@ namespace osu.Game.Rulesets.Catch.UI
};
}
- protected override void PrepareForUse()
+ protected override void OnApply(HitExplosionEntry entry)
{
- base.PrepareForUse();
+ X = entry.Position;
+ Scale = new Vector2(entry.Scale);
+ setColour(entry.ObjectColour);
+
+ using (BeginAbsoluteSequence(entry.LifetimeStart))
+ applyTransforms(entry.RNGSeed);
+ }
+
+ private void applyTransforms(int randomSeed)
+ {
+ ClearTransforms(true);
const double duration = 400;
@@ -96,14 +91,13 @@ namespace osu.Game.Rulesets.Catch.UI
.FadeOut(duration * 2);
const float angle_variangle = 15; // should be less than 45
- directionalGlow1.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle);
- directionalGlow2.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle);
+ directionalGlow1.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 4);
+ directionalGlow2.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 5);
- this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out);
- Expire(true);
+ this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out).Expire();
}
- private void onColourChanged()
+ private void setColour(Color4 objectColour)
{
const float roundness = 100;
diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs
new file mode 100644
index 0000000000..094d88243a
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.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.Pooling;
+using osu.Game.Rulesets.Objects.Pooling;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ public class HitExplosionContainer : PooledDrawableWithLifetimeContainer
+ {
+ protected override bool RemoveRewoundEntry => true;
+
+ private readonly DrawablePool pool;
+
+ public HitExplosionContainer()
+ {
+ AddInternal(pool = new DrawablePool(10));
+ }
+
+ protected override HitExplosion GetDrawable(HitExplosionEntry entry) => pool.Get(d => d.Apply(entry));
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs
new file mode 100644
index 0000000000..b142962a8a
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.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.Performance;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ public class HitExplosionEntry : LifetimeEntry
+ {
+ public readonly float Position;
+ public readonly float Scale;
+ public readonly Color4 ObjectColour;
+ public readonly int RNGSeed;
+
+ public HitExplosionEntry(double startTime, float position, float scale, Color4 objectColour, int rngSeed)
+ {
+ LifetimeStart = startTime;
+ Position = position;
+ Scale = scale;
+ ObjectColour = objectColour;
+ RNGSeed = rngSeed;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs
new file mode 100644
index 0000000000..fc34ba4c8b
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs
@@ -0,0 +1,33 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Game.Rulesets.Catch.Skinning.Default;
+using osu.Game.Skinning;
+using osuTK;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ ///
+ /// The visual representation of the .
+ /// It includes the body part of the catcher and the catcher plate.
+ ///
+ public class SkinnableCatcher : SkinnableDrawable
+ {
+ ///
+ /// This is used by skin elements to determine which texture of the catcher is used.
+ ///
+ [Cached]
+ public readonly Bindable AnimationState = new Bindable();
+
+ public SkinnableCatcher()
+ : base(new CatchSkinComponent(CatchSkinComponents.Catcher), _ => new DefaultCatcher())
+ {
+ Anchor = Anchor.TopCentre;
+ // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling.
+ OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
index 176fbba921..124e1a35f9 100644
--- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs
@@ -1,31 +1,54 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
-using osu.Framework.Timing;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.UI;
+using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Tests.Visual;
-using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene
{
- [Cached(Type = typeof(IAdjustableClock))]
- private readonly IAdjustableClock clock = new StopwatchClock();
+ protected override Container Content => blueprints ?? base.Content;
- protected ManiaSelectionBlueprintTestScene()
+ private readonly Container blueprints;
+
+ [Cached(typeof(Playfield))]
+ public Playfield Playfield { get; }
+
+ private readonly ScrollingTestContainer scrollingTestContainer;
+
+ protected ScrollingDirection Direction
{
- Add(new Column(0)
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- AccentColour = Color4.OrangeRed,
- Clock = new FramedClock(new StopwatchClock()), // No scroll
- });
+ set => scrollingTestContainer.Direction = value;
}
- public ManiaPlayfield Playfield => null;
+ protected ManiaSelectionBlueprintTestScene(int columns)
+ {
+ var stageDefinitions = new List { new StageDefinition { Columns = columns } };
+ base.Content.Child = scrollingTestContainer = new ScrollingTestContainer(ScrollingDirection.Up)
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ Playfield = new ManiaPlayfield(stageDefinitions)
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ blueprints = new Container
+ {
+ RelativeSizeAxes = Axes.Both
+ }
+ }
+ };
+
+ AddToggleStep("Downward scroll", b => Direction = b ? ScrollingDirection.Down : ScrollingDirection.Up);
+ }
}
}
diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
index 5e99264d7d..9953b8e3c0 100644
--- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs
@@ -1,55 +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.Beatmaps.ControlPoints;
-using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
-using osu.Game.Rulesets.UI.Scrolling;
-using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene
{
- private readonly DrawableHoldNote drawableObject;
-
- protected override Container Content => content ?? base.Content;
- private readonly Container content;
-
public TestSceneHoldNoteSelectionBlueprint()
+ : base(4)
{
- var holdNote = new HoldNote { Column = 0, Duration = 1000 };
- holdNote.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
-
- base.Content.Child = content = new ScrollingTestContainer(ScrollingDirection.Down)
+ for (int i = 0; i < 4; i++)
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- AutoSizeAxes = Axes.Y,
- Width = 50,
- Child = drawableObject = new DrawableHoldNote(holdNote)
+ var holdNote = new HoldNote
{
- Height = 300,
- AccentColour = { Value = OsuColour.Gray(0.3f) }
- }
- };
+ Column = i,
+ StartTime = i * 100,
+ Duration = 500
+ };
+ holdNote.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
- AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableObject);
- }
-
- protected override void Update()
- {
- base.Update();
-
- foreach (var nested in drawableObject.NestedHitObjects)
- {
- double finalPosition = (nested.HitObject.StartTime - drawableObject.HitObject.StartTime) / drawableObject.HitObject.Duration;
- nested.Y = (float)(-finalPosition * content.DrawHeight);
+ var drawableHitObject = new DrawableHoldNote(holdNote);
+ Playfield.Add(drawableHitObject);
+ AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableHitObject);
}
}
}
diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
index 8474279b01..01d80881fa 100644
--- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs
@@ -12,7 +12,7 @@ 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.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Skinning.Default;
@@ -184,8 +184,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
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);
+ 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)
diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
index 9c3ad0b4ff..3586eecc44 100644
--- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs
@@ -1,40 +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.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
-using osu.Game.Rulesets.UI.Scrolling;
-using osu.Game.Tests.Visual;
-using osuTK;
namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene
{
- protected override Container Content => content ?? base.Content;
- private readonly Container content;
-
public TestSceneNoteSelectionBlueprint()
+ : base(4)
{
- var note = new Note { Column = 0 };
- note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
-
- DrawableNote drawableObject;
-
- base.Content.Child = content = new ScrollingTestContainer(ScrollingDirection.Down)
+ for (int i = 0; i < 4; i++)
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(50, 20),
- Child = drawableObject = new DrawableNote(note)
- };
+ var note = new Note
+ {
+ Column = i,
+ StartTime = i * 200,
+ };
+ note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
- AddBlueprint(new NoteSelectionBlueprint(note), drawableObject);
+ var drawableHitObject = new DrawableNote(note);
+ Playfield.Add(drawableHitObject);
+ AddBlueprint(new NoteSelectionBlueprint(note), drawableHitObject);
+ }
}
}
}
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 b2a0912d19..6df555617b 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
@@ -4,7 +4,7 @@
-
+
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs
index 0b58d1efc6..628d77107f 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs
@@ -7,7 +7,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaDifficultyAttributes : DifficultyAttributes
{
- public double GreatHitWindow;
- public double ScoreMultiplier;
+ public double GreatHitWindow { get; set; }
+ public double ScoreMultiplier { get; set; }
}
}
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
index 8c0b9ed8b7..a7a6677b68 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
@@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
// Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required.
protected override IEnumerable SortObjects(IEnumerable input) => input;
- protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[]
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[]
{
new Strain(mods, ((ManiaBeatmap)beatmap).TotalColumns)
};
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
index 00bec18a45..b04ff3548f 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Extensions;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
@@ -37,15 +36,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
mods = Score.Mods;
scaledScore = Score.TotalScore;
- countPerfect = Score.Statistics.GetOrDefault(HitResult.Perfect);
- countGreat = Score.Statistics.GetOrDefault(HitResult.Great);
- countGood = Score.Statistics.GetOrDefault(HitResult.Good);
- countOk = Score.Statistics.GetOrDefault(HitResult.Ok);
- countMeh = Score.Statistics.GetOrDefault(HitResult.Meh);
- countMiss = Score.Statistics.GetOrDefault(HitResult.Miss);
-
- if (mods.Any(m => !m.Ranked))
- return 0;
+ countPerfect = Score.Statistics.GetValueOrDefault(HitResult.Perfect);
+ countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great);
+ countGood = Score.Statistics.GetValueOrDefault(HitResult.Good);
+ countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok);
+ countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh);
+ countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss);
IEnumerable scoreIncreaseMods = Ruleset.GetModsFor(ModType.DifficultyIncrease);
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs
deleted file mode 100644
index 6933571be8..0000000000
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.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.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
-using osu.Game.Rulesets.Mania.Objects.Drawables;
-
-namespace osu.Game.Rulesets.Mania.Edit.Blueprints
-{
- public class HoldNoteNoteOverlay : CompositeDrawable
- {
- private readonly HoldNoteSelectionBlueprint holdNoteBlueprint;
- private readonly HoldNotePosition position;
-
- public HoldNoteNoteOverlay(HoldNoteSelectionBlueprint holdNoteBlueprint, HoldNotePosition position)
- {
- this.holdNoteBlueprint = holdNoteBlueprint;
- this.position = position;
-
- InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X };
- }
-
- protected override void Update()
- {
- base.Update();
-
- var drawableObject = holdNoteBlueprint.DrawableObject;
-
- // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly.
- if (drawableObject.IsLoaded)
- {
- DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)drawableObject.Head : drawableObject.Tail;
-
- Anchor = note.Anchor;
- Origin = note.Origin;
-
- Size = note.DrawSize;
- Position = note.DrawPosition;
- }
- }
- }
-}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
index d04c5cd4aa..5259fcbd5f 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
@@ -2,14 +2,13 @@
// 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.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
+using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
-using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
@@ -17,13 +16,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint
{
- public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject;
-
- private readonly IBindable direction = new Bindable();
-
[Resolved]
private OsuColour colours { get; set; }
+ private EditNotePiece head;
+ private EditNotePiece tail;
+
public HoldNoteSelectionBlueprint(HoldNote hold)
: base(hold)
{
@@ -32,12 +30,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
[BackgroundDependencyLoader]
private void load(IScrollingInfo scrollingInfo)
{
- direction.BindTo(scrollingInfo.Direction);
-
InternalChildren = new Drawable[]
{
- new HoldNoteNoteOverlay(this, HoldNotePosition.Start),
- new HoldNoteNoteOverlay(this, HoldNotePosition.End),
+ head = new EditNotePiece { RelativeSizeAxes = Axes.X },
+ tail = new EditNotePiece { RelativeSizeAxes = Axes.X },
new Container
{
RelativeSizeAxes = Axes.Both,
@@ -58,21 +54,13 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
base.Update();
- // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly.
- if (DrawableObject.IsLoaded)
- {
- Size = DrawableObject.DrawSize + new Vector2(0, DrawableObject.Tail.DrawHeight);
-
- // This is a side-effect of not matching the hitobject's anchors/origins, which is kinda hard to do
- // When scrolling upwards our origin is already at the top of the head note (which is the intended location),
- // but when scrolling downwards our origin is at the _bottom_ of the tail note (where we need to be at the _top_ of the tail note)
- if (direction.Value == ScrollingDirection.Down)
- Y -= DrawableObject.Tail.DrawHeight;
- }
+ head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime);
+ tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime);
+ Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight;
}
public override Quad SelectionQuad => ScreenSpaceDrawQuad;
- public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.Head.ScreenSpaceDrawQuad.Centre;
+ public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre;
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
index e744bd3c83..955336db57 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
@@ -5,20 +5,23 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Objects;
-using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
-using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public abstract class ManiaSelectionBlueprint : HitObjectSelectionBlueprint
where T : ManiaHitObject
{
- public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject;
+ [Resolved]
+ private Playfield playfield { get; set; }
[Resolved]
private IScrollingInfo scrollingInfo { get; set; }
+ protected ScrollingHitObjectContainer HitObjectContainer => ((ManiaPlayfield)playfield).GetColumn(HitObject.Column).HitObjectContainer;
+
protected ManiaSelectionBlueprint(T hitObject)
: base(hitObject)
{
@@ -29,19 +32,13 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
base.Update();
- Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero));
- }
+ var anchor = scrollingInfo.Direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
+ Anchor = Origin = anchor;
+ foreach (var child in InternalChildren)
+ child.Anchor = child.Origin = anchor;
- public override void Show()
- {
- DrawableObject.AlwaysAlive = true;
- base.Show();
- }
-
- public override void Hide()
- {
- DrawableObject.AlwaysAlive = false;
- base.Hide();
+ Position = Parent.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition;
+ Width = HitObjectContainer.DrawWidth;
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
index e2b6ee0048..e7a03905d2 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
@@ -14,14 +14,5 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X });
}
-
- protected override void Update()
- {
- base.Update();
-
- // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly.
- if (DrawableObject.IsLoaded)
- Size = DrawableObject.DrawSize;
- }
}
}
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index fbb9b3c466..fe736766d9 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Mania
public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this);
- public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new ManiaLegacySkinTransformer(source, beatmap);
+ public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new ManiaLegacySkinTransformer(skin, beatmap);
public override IEnumerable ConvertFromLegacyMods(LegacyMods mods)
{
diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
index 1c89d9cd00..36fa336d0c 100644
--- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
@@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Configuration;
@@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mania
{
public class ManiaSettingsSubsection : RulesetSettingsSubsection
{
- protected override string Header => "osu!mania";
+ protected override LocalisableString Header => "osu!mania";
public ManiaSettingsSubsection(ManiaRuleset ruleset)
: base(ruleset)
@@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania
private class TimeSlider : OsuSliderBar
{
- public override string TooltipText => Current.Value.ToString("N0") + "ms";
+ public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs
index 8fd5950dfb..050b302bd8 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs
@@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public abstract int KeyCount { get; }
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 1; // TODO: Implement the mania key mod score multiplier
- public override bool Ranked => true;
public void ApplyToBeatmapConverter(IBeatmapConverter beatmapConverter)
{
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs
index 105d88129c..6ae854e7f3 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs
@@ -4,7 +4,6 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
-using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
@@ -12,7 +11,7 @@ using osu.Game.Users;
namespace osu.Game.Rulesets.Mania.Mods
{
- public class ManiaModAutoplay : ModAutoplay
+ public class ManiaModAutoplay : ModAutoplay
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score
{
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
index 078394b1d8..614ef76a3b 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
@@ -24,8 +24,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override ModType Type => ModType.Conversion;
- public override bool Ranked => false;
-
public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
{
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs
index 12f379bddb..cf404cc98e 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs
@@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override ModType Type => ModType.Conversion;
public override string Description => "Notes are flipped horizontally.";
public override double ScoreMultiplier => 1;
- public override bool Ranked => true;
public void ApplyToBeatmap(IBeatmap beatmap)
{
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs
index 699c58c373..6f2d4fe91e 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.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.Extensions.IEnumerableExtensions;
using osu.Framework.Utils;
@@ -17,8 +18,11 @@ namespace osu.Game.Rulesets.Mania.Mods
public void ApplyToBeatmap(IBeatmap beatmap)
{
+ Seed.Value ??= RNG.Next();
+ var rng = new Random((int)Seed.Value);
+
var availableColumns = ((ManiaBeatmap)beatmap).TotalColumns;
- var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => RNG.Next()).ToList();
+ var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => rng.Next()).ToList();
beatmap.HitObjects.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]);
}
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
index 380ab35339..5aff4e200b 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
@@ -22,6 +22,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected readonly IBindable Direction = new Bindable();
+ // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms.
+ // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1.
+ protected override double InitialLifetimeOffset => 30000;
+
[Resolved(canBeNull: true)]
private ManiaPlayfield playfield { get; set; }
@@ -85,63 +89,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
AccentColour.UnbindFrom(ParentHitObject.AccentColour);
}
- 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)
{
Anchor = Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
index 261b8b1fad..814a737034 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
@@ -50,29 +50,25 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{ HitResult.Miss, "mania-hit0" }
};
- private Lazy isLegacySkin;
+ private readonly Lazy isLegacySkin;
///
/// Whether texture for the keys exists.
/// Used to determine if the mania ruleset is skinned.
///
- private Lazy hasKeyTexture;
+ private readonly Lazy hasKeyTexture;
- public ManiaLegacySkinTransformer(ISkinSource source, IBeatmap beatmap)
- : base(source)
+ public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap)
+ : base(skin)
{
this.beatmap = (ManiaBeatmap)beatmap;
- Source.SourceChanged += sourceChanged;
- sourceChanged();
- }
-
- 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);
+ isLegacySkin = new Lazy(() => GetConfig(LegacySkinConfiguration.LegacySetting.Version) != null);
+ hasKeyTexture = new Lazy(() =>
+ {
+ var keyImage = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.KeyImage, 0)?.Value ?? "mania-key1";
+ return this.GetAnimation(keyImage, true, true) != null;
+ });
}
public override Drawable GetDrawableComponent(ISkinComponent component)
@@ -125,7 +121,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
break;
}
- return Source.GetDrawableComponent(component);
+ return base.GetDrawableComponent(component);
}
private Drawable getResult(HitResult result)
@@ -146,15 +142,15 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered)
return new SampleVirtual();
- return Source.GetSample(sampleInfo);
+ return base.GetSample(sampleInfo);
}
public override IBindable GetConfig(TLookup lookup)
{
if (lookup is ManiaSkinConfigurationLookup maniaLookup)
- return Source.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn));
+ return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn));
- return Source.GetConfig(lookup);
+ return base.GetConfig(lookup);
}
}
}
diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs
index 8f7880dafa..b75b586ecf 100644
--- a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs
+++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs
@@ -33,9 +33,9 @@ namespace osu.Game.Rulesets.Mania.UI.Components
Direction.BindValueChanged(onDirectionChanged, true);
}
- protected override void SkinChanged(ISkinSource skin, bool allowFallback)
+ protected override void SkinChanged(ISkinSource skin)
{
- base.SkinChanged(skin, allowFallback);
+ base.SkinChanged(skin);
UpdateHitPosition();
}
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index e497646a13..614a7b00c7 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI
///
/// The minimum time range. This occurs at a of 40.
///
- public const double MIN_TIME_RANGE = 340;
+ public const double MIN_TIME_RANGE = 290;
///
/// The maximum time range. This occurs at a of 1.
///
- public const double MAX_TIME_RANGE = 13720;
+ public const double MAX_TIME_RANGE = 11485;
protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield;
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.UI
protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod;
private readonly Bindable configDirection = new Bindable();
- private readonly Bindable configTimeRange = new BindableDouble();
+ private readonly BindableDouble configTimeRange = new BindableDouble();
// Stores the current speed adjustment active in gameplay.
private readonly Track speedAdjustmentTrack = new TrackVirtual(0);
@@ -103,6 +103,8 @@ namespace osu.Game.Rulesets.Mania.UI
configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true);
Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange);
+ TimeRange.MinValue = configTimeRange.MinValue;
+ TimeRange.MaxValue = configTimeRange.MaxValue;
}
protected override void AdjustScrollSpeed(int amount)
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs
new file mode 100644
index 0000000000..fd17d11d10
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs
@@ -0,0 +1,260 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using Moq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckLowDiffOverlapsTest
+ {
+ private CheckLowDiffOverlaps check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckLowDiffOverlaps();
+ }
+
+ [Test]
+ public void TestNoOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapClose()
+ {
+ assertShouldProbablyOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 167, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapTooClose()
+ {
+ assertShouldOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapTooCloseExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestOverlapClose()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 167, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestAlmostOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ // Default circle diameter is 128 px, but part of that is the fade/border of the circle.
+ // We want this to only be a problem when it actually looks like an overlap.
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(125, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestAlmostNotOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(110, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestOverlapFarApartExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestOverlapTooFarApart()
+ {
+ // Far apart enough to where the objects are not visible at the same time, and so overlapping is fine.
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 2000, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailOverlapClose()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailNoOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailNoOverlapClose()
+ {
+ // If these were circles they would need to overlap, but overlapping with slider tails is not required.
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
+ {
+ var mockSlider = new Mock();
+ mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
+ mockSlider.SetupGet(s => s.Position).Returns(startPosition);
+ mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
+ mockSlider.As().Setup(d => d.EndTime).Returns(endTime);
+
+ return mockSlider;
+ }
+
+ private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertShouldProbablyOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldProbablyOverlap));
+ }
+
+ private void assertShouldOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldOverlap));
+ }
+
+ private void assertShouldNotOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldNotOverlap));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs
new file mode 100644
index 0000000000..49a6fd12fa
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.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.Collections.Generic;
+using System.Linq;
+using Moq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTimeDistanceEqualityTest
+ {
+ private CheckTimeDistanceEquality check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTimeDistanceEquality();
+ }
+
+ [Test]
+ public void TestCirclesEquidistant()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(100, 0) },
+ new HitCircle { StartTime = 1500, Position = new Vector2(150, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneSlightlyOff()
+ {
+ assertWarning(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(80, 0) }, // Distance a quite low compared to previous.
+ new HitCircle { StartTime = 1500, Position = new Vector2(130, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesTwoOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
+ new HitCircle { StartTime = 1500, Position = new Vector2(250, 0) } // Also twice the regular spacing.
+ }
+ }, count: 2);
+ }
+
+ [Test]
+ public void TestCirclesStacked()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50, 0) }, // Stacked, is fine.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesStacking()
+ {
+ assertWarning(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50, 0), StackHeight = 1 },
+ new HitCircle { StartTime = 1500, Position = new Vector2(50, 0), StackHeight = 2 },
+ new HitCircle { StartTime = 2000, Position = new Vector2(50, 0), StackHeight = 3 },
+ new HitCircle { StartTime = 2500, Position = new Vector2(50, 0), StackHeight = 4 }, // Ends up far from (50; 0), causing irregular spacing.
+ new HitCircle { StartTime = 3000, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesHalfStack()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(55, 0) }, // Basically stacked, so is fine.
+ new HitCircle { StartTime = 1500, Position = new Vector2(105, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesPartialOverlap()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(65, 0) }, // Really low distance compared to previous.
+ new HitCircle { StartTime = 1500, Position = new Vector2(115, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesSlightlyDifferent()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ // Does not need to be perfect, as long as the distance is approximately correct it's sight-readable.
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(52, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(97, 0) },
+ new HitCircle { StartTime = 1500, Position = new Vector2(165, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesSlowlyChanging()
+ {
+ const float multiplier = 1.2f;
+
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) },
+ // This gap would be a warning if it weren't for the previous pushing the average spacing up.
+ new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesQuicklyChanging()
+ {
+ const float multiplier = 1.6f;
+
+ var beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, // Warning
+ new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } // Problem
+ }
+ };
+
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.First().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning);
+ Assert.That(issues.Last().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem);
+ }
+
+ [Test]
+ public void TestCirclesTooFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 4000, Position = new Vector2(200, 0) }, // 2 seconds apart from previous, so can start from wherever.
+ new HitCircle { StartTime = 4500, Position = new Vector2(250, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneOffExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Jumps are allowed in higher difficulties.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestSpinner()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new Spinner { StartTime = 500, EndTime = 1000 }, // Distance to and from the spinner should be ignored. If it isn't this should give a problem.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) },
+ new HitCircle { StartTime = 2000, Position = new Vector2(150, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliders()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
+ getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(200, 0), endPosition: new Vector2(250, 0)).Object,
+ new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSlidersOneOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
+ getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(250, 0), endPosition: new Vector2(300, 0)).Object, // Twice the spacing.
+ new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
+ {
+ var mockSlider = new Mock();
+ mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
+ mockSlider.SetupGet(s => s.Position).Returns(startPosition);
+ mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
+ mockSlider.As().Setup(d => d.EndTime).Returns(endTime);
+
+ return mockSlider;
+ }
+
+ private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertWarning(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning));
+ }
+
+ private void assertProblem(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs
new file mode 100644
index 0000000000..2eab5a4ce6
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs
@@ -0,0 +1,145 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTooShortSlidersTest
+ {
+ private CheckTooShortSliders check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTooShortSliders();
+ }
+
+ [Test]
+ public void TestLongSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(100, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider });
+ }
+
+ [Test]
+ public void TestShortSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(25, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider });
+ }
+
+ [Test]
+ public void TestTooShortSliderExpert()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestTooShortSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertTooShort(new List { slider });
+ }
+
+ [Test]
+ public void TestTooShortSliderWithRepeats()
+ {
+ // Would be ok if we looked at the duration, but not if we look at the span duration.
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 2,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertTooShort(new List { slider });
+ }
+
+ private void assertOk(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ Assert.That(check.Run(getContext(hitObjects, difficultyRating)), Is.Empty);
+ }
+
+ private void assertTooShort(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var issues = check.Run(getContext(hitObjects, difficultyRating)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckTooShortSliders.IssueTemplateTooShort);
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects, DifficultyRating difficultyRating)
+ {
+ var beatmap = new Beatmap { HitObjects = hitObjects };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs
new file mode 100644
index 0000000000..6a3f168ee1
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs
@@ -0,0 +1,116 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTooShortSpinnersTest
+ {
+ private CheckTooShortSpinners check;
+ private BeatmapDifficulty difficulty;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTooShortSpinners();
+ difficulty = new BeatmapDifficulty();
+ }
+
+ [Test]
+ public void TestLongSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 4000 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertOk(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestShortSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 750 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertOk(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestVeryShortSpinner()
+ {
+ // Spinners at a certain duration only get 1000 points if approached by auto at a certain angle, making it difficult to determine.
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 475 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertVeryShort(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestTooShortSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 400 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertTooShort(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestTooShortSpinnerVaryingOd()
+ {
+ const double duration = 450;
+
+ var difficultyLowOd = new BeatmapDifficulty { OverallDifficulty = 1 };
+ Spinner spinnerLowOd = new Spinner { StartTime = 0, Duration = duration };
+ spinnerLowOd.ApplyDefaults(new ControlPointInfo(), difficultyLowOd);
+
+ var difficultyHighOd = new BeatmapDifficulty { OverallDifficulty = 10 };
+ Spinner spinnerHighOd = new Spinner { StartTime = 0, Duration = duration };
+ spinnerHighOd.ApplyDefaults(new ControlPointInfo(), difficultyHighOd);
+
+ assertOk(new List { spinnerLowOd }, difficultyLowOd);
+ assertTooShort(new List