Merge branch 'master' of https://hub.fastgit.org/ppy/osu into auto-restart

This commit is contained in:
PercyDan
2021-08-11 20:18:05 +08:00
1001 changed files with 25311 additions and 7809 deletions

View File

@ -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,23 +14,23 @@
"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.725.0",
"commands": [
"localisation"
]
}
}
}
}

View File

@ -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
@ -190,3 +190,5 @@ dotnet_diagnostic.CA2225.severity = none
# Banned APIs
dotnet_diagnostic.RS0030.severity = error
dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text.

94
.github/workflows/ci.yml vendored Normal file
View File

@ -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

32
.github/workflows/report-nunit.yml vendored Normal file
View File

@ -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

3
.gitignore vendored
View File

@ -336,3 +336,6 @@ inspectcode
/BenchmarkDotNet.Artifacts
*.GeneratedMSBuildEditorConfig.editorconfig
# Fody (pulled in by Realm) - schema file
FodyWeavers.xsd

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="osu-client-sqlite" uuid="1aa4b9be-cd8d-47ae-8186-30a13cd724a5">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$USER_HOME$/.local/share/osu/client.db</jdbc-url>
<driver-properties>
<property name="enable_load_extension" value="true" />
</driver-properties>
</data-source>
</component>
</project>

View File

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dual client test" type="CompoundRunConfigurationType">
<toRun name="osu!" type="DotNetProject" />
<toRun name="osu! (Second Client)" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="osu! (Second Client)" type="DotNetProject" factoryName=".NET Project" folderName="osu!" activateToolWindowBeforeRun="false">
<option name="EXE_PATH" value="$PROJECT_DIR$/osu.Desktop/bin/Debug/net5.0/osu!.dll" />
<option name="PROGRAM_PARAMETERS" value="--debug-client-id=1" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/osu.Desktop/bin/Debug/net5.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/osu.Desktop/osu.Desktop.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net5.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

14
.vscode/launch.json vendored
View File

@ -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
}
]
}

View File

@ -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<T> or EqualityComparer<T>.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<T> 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.

3
FodyWeavers.xml Normal file
View File

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Realm DisableAnalytics="true" />
</Weavers>

View File

@ -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
# 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

6
InspectCode.sh Executable file
View File

@ -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

View File

@ -4,14 +4,14 @@
# osu!
[![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu)
[![Build status](https://github.com/ppy/osu/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/ppy/osu/actions/workflows/ci.yml)
[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest)
[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu)
[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy)
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!

View File

@ -12,7 +12,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>
<ItemGroup>

View File

@ -12,7 +12,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>
<ItemGroup>

View File

@ -12,7 +12,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>
<ItemGroup>

View File

@ -12,7 +12,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>
<ItemGroup>

View File

@ -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:

View File

@ -1,17 +0,0 @@
<Project Sdk="Microsoft.Build.Traversal">
<ItemGroup>
<ProjectReference Include="..\osu.Desktop\osu.Desktop.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Catch.Tests\osu.Game.Rulesets.Catch.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Mania.Tests\osu.Game.Rulesets.Mania.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Osu.Tests\osu.Game.Rulesets.Osu.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Taiko.Tests\osu.Game.Rulesets.Taiko.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
<ProjectReference Include="..\osu.Game.Tests\osu.Game.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Tournament.Tests\osu.Game.Tournament.Tests.csproj" />
<ProjectReference Include="..\osu.Game.Tournament\osu.Game.Tournament.csproj" />
<ProjectReference Include="..\osu.Game\osu.Game.csproj" />
</ItemGroup>
</Project>

View File

@ -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);

View File

@ -1,5 +0,0 @@
[Nuget]
Source=https://api.nuget.org/v3/index.json
UseInProcessClient=true
LoadDependencies=true

View File

@ -51,7 +51,11 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.525.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.608.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.810.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.811.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
<PackageReference Include="Realm" Version="10.3.0" />
</ItemGroup>
</Project>

View File

@ -64,7 +64,7 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Xamarin.Essentials" Version="1.6.1" />
<PackageReference Include="Xamarin.Essentials" Version="1.7.0" />
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Xamarin\Android\Xamarin.Android.CSharp.targets" />
</Project>

View File

@ -3,7 +3,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework;
@ -17,13 +16,43 @@ namespace osu.Desktop
{
public static class Program
{
private const string base_game_name = @"osu";
[STAThread]
public static int Main(string[] args)
{
// Back up the cwd before DesktopGameHost changes it
var cwd = Environment.CurrentDirectory;
using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true))
string gameName = base_game_name;
bool tournamentClient = false;
foreach (var arg in args)
{
var split = arg.Split('=');
var key = split[0];
var val = split.Length > 1 ? split[1] : string.Empty;
switch (key)
{
case "--tournament":
tournamentClient = true;
break;
case "--debug-client-id":
if (!DebugUtils.IsDebugBuild)
throw new InvalidOperationException("Cannot use this argument in a non-debug build.");
if (!int.TryParse(val, out int clientID))
throw new ArgumentException("Provided client ID must be an integer.");
gameName = $"{base_game_name}-{clientID}";
break;
}
}
using (DesktopGameHost host = Host.GetSuitableHost(gameName, true))
{
host.ExceptionThrown += handleException;
@ -48,16 +77,10 @@ namespace osu.Desktop
return 0;
}
switch (args.FirstOrDefault() ?? string.Empty)
{
default:
host.Run(new OsuGameDesktop(args));
break;
case "--tournament":
host.Run(new TournamentGame());
break;
}
if (tournamentClient)
host.Run(new TournamentGame());
else
host.Run(new OsuGameDesktop(args));
return 0;
}

View File

@ -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;
};
}

View File

@ -5,8 +5,8 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description>
<AssemblyName>osu!</AssemblyName>
<Title>osu!lazer</Title>
<Product>osu!lazer</Product>
<Title>osu!</Title>
<Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.0.0</Version>

View File

@ -3,7 +3,7 @@
<metadata>
<id>osulazer</id>
<version>0.0.0</version>
<title>osu!lazer</title>
<title>osu!</title>
<authors>ppy Pty Ltd</authors>
<owners>Dean Herbert</owners>
<projectUrl>https://osu.ppy.sh/</projectUrl>
@ -20,4 +20,3 @@
<file src="**.config" target="lib\net45\"/>
</files>
</package>

View File

@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.0" />
<PackageReference Include="nunit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Drawable> 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 })
{
}
}
}
}

View File

@ -0,0 +1,79 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Drawable> 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<FruitOutline> FruitOutlines => Content.ChildrenOfType<FruitOutline>();
// 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;
}
}
}

View File

@ -0,0 +1,72 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public abstract class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene
{
protected ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer;
protected override Container<Drawable> Content => contentContainer;
[Cached(typeof(EditorBeatmap))]
[Cached(typeof(IBeatSnapProvider))]
protected readonly EditorBeatmap EditorBeatmap;
private readonly CatchEditorTestSceneContainer contentContainer;
protected CatchSelectionBlueprintTestScene()
{
EditorBeatmap = new EditorBeatmap(new CatchBeatmap());
EditorBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = 0;
EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint
{
BeatLength = 100
});
base.Content.Add(new EditorBeatmapDependencyContainer(EditorBeatmap, new BindableBeatDivisor())
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
EditorBeatmap,
contentContainer = new CatchEditorTestSceneContainer()
},
});
}
protected void AddMouseMoveStep(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);
});
private class EditorBeatmapDependencyContainer : Container
{
[Cached]
private readonly EditorClock editorClock;
[Cached]
private readonly BindableBeatDivisor beatDivisor;
public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor)
{
editorClock = new EditorClock(beatmap, beatDivisor);
this.beatDivisor = beatDivisor;
}
}
}
}

View File

@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<TimeSpanOutline>().Single();
}
}

View File

@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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();
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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));
}
}
}

View File

@ -0,0 +1,155 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.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.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene
{
private const double velocity = 0.5;
private JuiceStream lastObject => LastObject?.HitObject as JuiceStream;
[BackgroundDependencyLoader]
private void load()
{
Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderTickRate = 5;
Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity * 10;
}
[Test]
public void TestBasicPlacement()
{
double[] times = { 300, 800 };
float[] positions = { 100, 200 };
addPlacementSteps(times, positions);
AddAssert("juice stream is placed", () => lastObject != null);
AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0]));
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1]));
}
[Test]
public void TestEmptyNotCommitted()
{
addMoveAndClickSteps(100, 100);
addMoveAndClickSteps(100, 100);
addMoveAndClickSteps(100, 100, true);
AddAssert("juice stream not placed", () => lastObject == null);
}
[Test]
public void TestMultipleSegments()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 100, 150, 100, 100 };
addPlacementSteps(times, positions);
AddAssert("has 4 vertices", () => lastObject.Path.ControlPoints.Count == 4);
addPathCheckStep(times, positions);
}
[Test]
public void TestVelocityLimit()
{
double[] times = { 100, 300 };
float[] positions = { 200, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300 });
}
[Test]
public void TestPreviousVerticesAreFixed()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 200, 400, 100, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300, 200, 300 });
}
[Test]
public void TestClampedPositionIsRestored()
{
double[] times = { 100, 300, 500 };
float[] positions = { 200, 200, 0, 250 };
addMoveAndClickSteps(times[0], positions[0]);
addMoveAndClickSteps(times[1], positions[1]);
AddMoveStep(times[2], positions[2]);
addMoveAndClickSteps(times[2], positions[3], true);
addPathCheckStep(times, new float[] { 200, 200, 250 });
}
[Test]
public void TestFirstVertexIsFixed()
{
double[] times = { 100, 200 };
float[] positions = { 100, 300 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 100, 150 });
}
[Test]
public void TestOutOfOrder()
{
double[] times = { 100, 700, 500, 300 };
float[] positions = { 100, 200, 150, 50 };
addPlacementSteps(times, positions);
addPathCheckStep(times, positions);
}
[Test]
public void TestMoveBeforeFirstVertex()
{
double[] times = { 300, 500, 100 };
float[] positions = { 100, 100, 100 };
addPlacementSteps(times, positions);
AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0]));
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1], 1e-3));
}
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
private void addMoveAndClickSteps(double time, float position, bool end = false)
{
AddMoveStep(time, position);
AddClickStep(end ? MouseButton.Right : MouseButton.Left);
}
private void addPlacementSteps(double[] times, float[] positions)
{
for (int i = 0; i < times.Length; i++)
addMoveAndClickSteps(times[i], positions[i], i == times.Length - 1);
}
private void addPathCheckStep(double[] times, float[] positions) => AddStep("assert path is correct", () =>
Assert.That(getPositions(times), Is.EqualTo(positions).Within(Precision.FLOAT_EPSILON)));
private float[] getPositions(IEnumerable<double> times)
{
JuiceStream hitObject = lastObject.AsNonNull();
return times
.Select(time => (time - hitObject.StartTime) * hitObject.Velocity)
.Select(distance => hitObject.EffectiveX + hitObject.Path.PositionAt(distance / hitObject.Distance).X)
.ToArray();
}
}
}

View File

@ -0,0 +1,286 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
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.Objects;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene
{
private JuiceStream hitObject;
private readonly ManualClock manualClock = new ManualClock();
[SetUp]
public void SetUp() => Schedule(() =>
{
EditorBeatmap.Clear();
Content.Clear();
manualClock.CurrentTime = 0;
Content.Clock = new FramedClock(manualClock);
InputManager.ReleaseButton(MouseButton.Left);
InputManager.ReleaseKey(Key.ShiftLeft);
InputManager.ReleaseKey(Key.ControlLeft);
});
[Test]
public void TestBasicComponentLayout()
{
double[] times = { 100, 300, 500 };
float[] positions = { 100, 200, 100 };
addBlueprintStep(times, positions);
for (int i = 0; i < times.Length; i++)
addVertexCheckStep(times.Length, i, times[i], positions[i]);
AddAssert("correct outline count", () =>
{
var expected = hitObject.NestedHitObjects.Count(h => !(h is TinyDroplet));
return this.ChildrenOfType<FruitOutline>().Count() == expected;
});
AddAssert("correct vertex piece count", () =>
this.ChildrenOfType<VertexPiece>().Count() == times.Length);
AddAssert("first vertex is semitransparent", () =>
Precision.DefinitelyBigger(1, this.ChildrenOfType<VertexPiece>().First().Alpha));
}
[Test]
public void TestVertexDrag()
{
double[] times = { 100, 400, 700 };
float[] positions = { 100, 100, 100 };
addBlueprintStep(times, positions);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addDragEndStep();
addDragStartStep(times[2], positions[2]);
AddMouseMoveStep(300, 50);
addVertexCheckStep(3, 1, 300, 50);
addVertexCheckStep(3, 2, 500, 150);
AddMouseMoveStep(-100, 100);
addVertexCheckStep(3, 1, times[0], positions[0]);
}
[Test]
public void TestMultipleDrag()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 100, 100, 100, 100 };
addBlueprintStep(times, positions);
AddMouseMoveStep(times[1], positions[1]);
AddStep("press left", () => InputManager.PressButton(MouseButton.Left));
AddStep("release left", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft));
addDragStartStep(times[2], positions[2]);
AddMouseMoveStep(times[2] - 50, positions[2] - 50);
addVertexCheckStep(4, 1, times[1] - 50, positions[1] - 50);
addVertexCheckStep(4, 2, times[2] - 50, positions[2] - 50);
}
[Test]
public void TestClampedPositionIsRestored()
{
const double velocity = 0.25;
double[] times = { 100, 500, 700 };
float[] positions = { 100, 100, 100 };
addBlueprintStep(times, positions, velocity);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 200);
addVertexCheckStep(3, 1, times[1], 200);
addVertexCheckStep(3, 2, times[2], 150);
AddMouseMoveStep(times[1], 100);
addVertexCheckStep(3, 1, times[1], 100);
// Stored position is restored.
addVertexCheckStep(3, 2, times[2], positions[2]);
AddMouseMoveStep(times[1], 300);
addDragEndStep();
addDragStartStep(times[1], 300);
AddMouseMoveStep(times[1], 100);
// Position is different because a changed position is committed when the previous drag is ended.
addVertexCheckStep(3, 2, times[2], 250);
}
[Test]
public void TestScrollWhileDrag()
{
double[] times = { 300, 500 };
float[] positions = { 100, 100 };
addBlueprintStep(times, positions);
addDragStartStep(times[1], positions[1]);
// This mouse move is necessary to start drag and capture the input.
AddMouseMoveStep(times[1], positions[1] + 50);
AddStep("scroll playfield", () => manualClock.CurrentTime += 200);
AddMouseMoveStep(times[1] + 200, positions[1] + 100);
addVertexCheckStep(2, 1, times[1] + 200, positions[1] + 100);
}
[Test]
public void TestUpdateFromHitObject()
{
double[] times = { 100, 300 };
float[] positions = { 200, 200 };
addBlueprintStep(times, positions);
AddStep("update hit object path", () =>
{
hitObject.Path = new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(100, 100),
new Vector2(0, 200),
});
EditorBeatmap.Update(hitObject);
});
AddAssert("path is updated", () => getVertices().Count > 2);
}
[Test]
public void TestAddVertex()
{
double[] times = { 100, 700 };
float[] positions = { 200, 200 };
addBlueprintStep(times, positions, 0.2);
addAddVertexSteps(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addAddVertexSteps(90, 220);
addVertexCheckStep(4, 1, times[0], positions[0]);
addAddVertexSteps(750, 180);
addVertexCheckStep(5, 4, 750, 180);
AddAssert("duration is changed", () => Precision.AlmostEquals(hitObject.Duration, 800 - times[0], 1e-3));
}
[Test]
public void TestDeleteVertex()
{
double[] times = { 100, 300, 500 };
float[] positions = { 100, 200, 150 };
addBlueprintStep(times, positions);
addDeleteVertexSteps(times[1], positions[1]);
addVertexCheckStep(2, 1, times[2], positions[2]);
// The first vertex cannot be deleted.
addDeleteVertexSteps(times[0], positions[0]);
addVertexCheckStep(2, 0, times[0], positions[0]);
addDeleteVertexSteps(times[2], positions[2]);
addVertexCheckStep(1, 0, times[0], positions[0]);
}
[Test]
public void TestVertexResampling()
{
addBlueprintStep(100, 100, new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(100, 100),
new Vector2(50, 200),
}), 0.5);
AddAssert("1 vertex per 1 nested HO", () => getVertices().Count == hitObject.NestedHitObjects.Count);
AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.PerfectCurve);
addAddVertexSteps(150, 150);
AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.Linear);
}
private void addBlueprintStep(double time, float x, SliderPath sliderPath, double velocity) => AddStep("add selection blueprint", () =>
{
hitObject = new JuiceStream
{
StartTime = time,
X = x,
Path = sliderPath,
};
EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity;
EditorBeatmap.Add(hitObject);
EditorBeatmap.Update(hitObject);
Assert.That(hitObject.Velocity, Is.EqualTo(velocity));
AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject));
});
private void addBlueprintStep(double[] times, float[] positions, double velocity = 0.5)
{
var path = new JuiceStreamPath();
for (int i = 1; i < times.Length; i++)
path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]);
var sliderPath = new SliderPath();
path.ConvertToSliderPath(sliderPath, 0);
addBlueprintStep(times[0], positions[0], sliderPath, velocity);
}
private IReadOnlyList<JuiceStreamPathVertex> getVertices() => this.ChildrenOfType<EditablePath>().Single().Vertices;
private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () =>
{
double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity;
float expectedX = x - hitObject.OriginalX;
var vertices = getVertices();
return vertices.Count == count &&
Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) &&
Precision.AlmostEquals(vertices[index].X, expectedX);
});
private void addDragStartStep(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left));
}
private void addDragEndStep() => AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
private void addAddVertexSteps(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("add vertex", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
}
private void addDeleteVertexSteps(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("delete vertex", () =>
{
InputManager.PressKey(Key.ShiftLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ShiftLeft);
});
}
}
}

View File

@ -0,0 +1,288 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<double>());
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<JuiceStreamPathVertex> 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));
}
}
}
}

View File

@ -0,0 +1,120 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osuTK;
namespace osu.Game.Rulesets.Catch.Tests.Mods
{
[TestFixture]
public class CatchModMirrorTest
{
[Test]
public void TestModMirror()
{
IBeatmap original = createBeatmap(false);
IBeatmap mirrored = createBeatmap(true);
assertEffectivePositionsMirrored(original, mirrored);
}
private static IBeatmap createBeatmap(bool withMirrorMod)
{
var beatmap = createRawBeatmap();
var mirrorMod = new CatchModMirror();
var beatmapProcessor = new CatchBeatmapProcessor(beatmap);
beatmapProcessor.PreProcess();
foreach (var hitObject in beatmap.HitObjects)
hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
beatmapProcessor.PostProcess();
if (withMirrorMod)
mirrorMod.ApplyToBeatmap(beatmap);
return beatmap;
}
private static IBeatmap createRawBeatmap() => new Beatmap
{
HitObjects = new List<HitObject>
{
new Fruit
{
OriginalX = 150,
StartTime = 0
},
new Fruit
{
OriginalX = 450,
StartTime = 500
},
new JuiceStream
{
OriginalX = 250,
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(new Vector2(-100, 1)),
new PathControlPoint(new Vector2(0, 2)),
new PathControlPoint(new Vector2(100, 3)),
new PathControlPoint(new Vector2(0, 4))
}
},
StartTime = 1000,
},
new BananaShower
{
StartTime = 5000,
Duration = 5000
}
}
};
private static void assertEffectivePositionsMirrored(IBeatmap original, IBeatmap mirrored)
{
if (original.HitObjects.Count != mirrored.HitObjects.Count)
Assert.Fail($"Top-level object count mismatch (original: {original.HitObjects.Count}, mirrored: {mirrored.HitObjects.Count})");
for (int i = 0; i < original.HitObjects.Count; ++i)
{
var originalObject = (CatchHitObject)original.HitObjects[i];
var mirroredObject = (CatchHitObject)mirrored.HitObjects[i];
// banana showers themselves are exempt, as we only really care about their nested bananas' positions.
if (!effectivePositionMirrored(originalObject, mirroredObject) && !(originalObject is BananaShower))
Assert.Fail($"{originalObject.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalObject, mirroredObject)})");
if (originalObject.NestedHitObjects.Count != mirroredObject.NestedHitObjects.Count)
Assert.Fail($"{originalObject.GetType().Name} nested object count mismatch (original: {originalObject.NestedHitObjects.Count}, mirrored: {mirroredObject.NestedHitObjects.Count})");
for (int j = 0; j < originalObject.NestedHitObjects.Count; ++j)
{
var originalNested = (CatchHitObject)originalObject.NestedHitObjects[j];
var mirroredNested = (CatchHitObject)mirroredObject.NestedHitObjects[j];
if (!effectivePositionMirrored(originalNested, mirroredNested))
Assert.Fail($"{originalObject.GetType().Name}'s nested {originalNested.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalNested, mirroredNested)})");
}
}
}
private static string printEffectivePositions(CatchHitObject original, CatchHitObject mirrored)
=> $"original X: {original.EffectiveX}, mirrored X is: {mirrored.EffectiveX}, mirrored X should be: {CatchPlayfield.WIDTH - original.EffectiveX}";
private static bool effectivePositionMirrored(CatchHitObject original, CatchHitObject mirrored)
=> Precision.AlmostEquals(original.EffectiveX, CatchPlayfield.WIDTH - mirrored.EffectiveX);
}
}

View File

@ -0,0 +1,2 @@
[General]
// no version specified means v1

View File

@ -0,0 +1,109 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.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
{
private Catcher catcher;
private readonly Container container;
public TestSceneCatchSkinConfiguration()
{
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 DroppedObjectContainer())
{
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<CaughtObject>().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<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
{
if (lookup is CatchSkinConfiguration config)
{
if (config == CatchSkinConfiguration.FlipCatcherPlate)
return SkinUtils.As<TValue>(new Bindable<bool>(FlipCatcherPlate));
}
return base.GetConfig<TLookup, TValue>(lookup);
}
}
}
}

View File

@ -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,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved]
private OsuConfigManager config { get; set; }
private Container<CaughtObject> droppedObjectContainer;
private DroppedObjectContainer droppedObjectContainer;
private TestCatcher catcher;
@ -43,18 +43,15 @@ namespace osu.Game.Rulesets.Catch.Tests
CircleSize = 0,
};
var trailContainer = new Container();
droppedObjectContainer = new Container<CaughtObject>();
catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty);
droppedObjectContainer = new DroppedObjectContainer();
Child = new Container
{
Anchor = Anchor.Centre,
Children = new Drawable[]
{
trailContainer,
droppedObjectContainer,
catcher
catcher = new TestCatcher(droppedObjectContainer, difficulty),
}
};
});
@ -188,9 +185,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]
@ -293,8 +290,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public IEnumerable<CaughtObject> CaughtObjects => this.ChildrenOfType<CaughtObject>();
public TestCatcher(Container trailsTarget, Container<CaughtObject> droppedObjectTarget, BeatmapDifficulty difficulty)
: base(trailsTarget, droppedObjectTarget, difficulty)
public TestCatcher(DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty)
: base(droppedObjectTarget, difficulty)
{
}
}

View File

@ -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;
@ -78,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Tests
{
area.OnNewResult(drawable, new CatchJudgementResult(fruit, new CatchJudgement())
{
Type = area.MovableCatcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss
Type = area.Catcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss
});
drawable.Expire();
@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests
SetContents(_ =>
{
var droppedObjectContainer = new Container<CaughtObject>
{
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,12 +119,18 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestCatcherArea : CatcherArea
{
public TestCatcherArea(Container<CaughtObject> droppedObjectContainer, BeatmapDifficulty beatmapDifficulty)
: base(droppedObjectContainer, beatmapDifficulty)
public TestCatcherArea(BeatmapDifficulty beatmapDifficulty)
{
var droppedObjectContainer = new DroppedObjectContainer();
Add(droppedObjectContainer);
Catcher = new Catcher(droppedObjectContainer, beatmapDifficulty)
{
X = CatchPlayfield.CENTER_X
};
}
public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1);
public void ToggleHyperDash(bool status) => Catcher.SetHyperDashState(status ? 2 : 1);
}
}
}

View File

@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive);
private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState;
private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).Catcher.CurrentState;
private void spawnFruits(bool hit = false)
{
@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Catch.Tests
float xCoords = CatchPlayfield.CENTER_X;
if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield)
catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset;
catchPlayfield.Catcher.X = xCoords - x_offset;
if (hit)
xCoords -= x_offset;
@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Catch.Tests
private void addToPlayfield(DrawableCatchHitObject drawable)
{
foreach (var mod in SelectedMods.Value.OfType<IApplicableToDrawableHitObjects>())
mod.ApplyToDrawableHitObjects(new[] { drawable });
foreach (var mod in SelectedMods.Value.OfType<IApplicableToDrawableHitObject>())
mod.ApplyToDrawableHitObject(drawable);
drawableRuleset.Playfield.Add(drawable);
}

View File

@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.Tests
// this needs to be done within the frame stable context due to how quickly hyperdash state changes occur.
Player.DrawableRuleset.FrameStableComponents.OnUpdate += d =>
{
var catcher = Player.ChildrenOfType<CatcherArea>().FirstOrDefault()?.MovableCatcher;
var catcher = Player.ChildrenOfType<Catcher>().FirstOrDefault();
if (catcher == null)
return;

View File

@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Catch.Tests
}
[Test]
public void TestCustomEndGlowColour()
public void TestCustomAfterImageColour()
{
var skin = new TestSkin
{
@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Tests
}
[Test]
public void TestCustomEndGlowColourPriority()
public void TestCustomAfterImageColourPriority()
{
var skin = new TestSkin
{
@ -111,39 +111,45 @@ namespace osu.Game.Rulesets.Catch.Tests
checkHyperDashFruitColour(skin, skin.HyperDashColour);
}
private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null)
private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedAfterImageColour = null)
{
CatcherArea catcherArea = null;
CatcherTrailDisplay trails = null;
Catcher catcher = null;
AddStep("create hyper-dashing catcher", () =>
{
Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container<CaughtObject>())
CatcherArea catcherArea;
Child = setupSkinHierarchy(new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(4f),
Child = catcherArea = new CatcherArea
{
Catcher = catcher = new Catcher(new DroppedObjectContainer())
{
Scale = new Vector2(4)
}
}
}, skin);
trails = catcherArea.ChildrenOfType<CatcherTrailDisplay>().Single();
});
AddStep("get trails container", () =>
AddStep("start hyper-dash", () =>
{
trails = catcherArea.OfType<CatcherTrailDisplay>().Single();
catcherArea.MovableCatcher.SetHyperDashState(2);
catcher.SetHyperDashState(2);
});
AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour);
AddUntilStep("catcher colour is correct", () => catcher.Colour == expectedCatcherColour);
AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour);
AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour));
AddAssert("catcher after-image colours are correct", () => trails.HyperDashAfterImageColour == (expectedAfterImageColour ?? expectedCatcherColour));
AddStep("finish hyper-dashing", () =>
{
catcherArea.MovableCatcher.SetHyperDashState(1);
catcherArea.MovableCatcher.FinishTransforms();
catcher.SetHyperDashState();
catcher.FinishTransforms();
});
AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White);
AddAssert("catcher colour returned to white", () => catcher.Colour == Color4.White);
}
private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour)

View File

@ -4,7 +4,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>
<PropertyGroup Label="Project">

View File

@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
protected override IEnumerable<CatchHitObject> 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();
}
}

View File

@ -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;

View File

@ -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
@ -115,6 +117,7 @@ namespace osu.Game.Rulesets.Catch
{
new CatchModDifficultyAdjust(),
new CatchModClassic(),
new CatchModMirror(),
};
case ModType.Automation:
@ -128,7 +131,8 @@ namespace osu.Game.Rulesets.Catch
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new CatchModFloatingFruits()
new CatchModFloatingFruits(),
new CatchModMuted(),
};
default:
@ -175,12 +179,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);
}
}

View File

@ -8,9 +8,7 @@ namespace osu.Game.Rulesets.Catch
Fruit,
Banana,
Droplet,
CatcherIdle,
CatcherFail,
CatcherKiai,
Catcher,
CatchComboCounter
}
}

View File

@ -7,6 +7,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyAttributes : DifficultyAttributes
{
public double ApproachRate;
public double ApproachRate { get; set; }
}
}

View File

@ -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;

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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();
}
}

View File

@ -0,0 +1,73 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<BananaShower>
{
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;
}
}
}
}

View File

@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<BananaShower>
{
public BananaShowerSelectionBlueprint(BananaShower hitObject)
: base(hitObject)
{
}
}
}

View File

@ -0,0 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<THitObject> : 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;
}
}

View File

@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<THitObject> : HitObjectSelectionBlueprint<THitObject>
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)
{
}
}
}

View File

@ -0,0 +1,190 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public abstract class EditablePath : CompositeDrawable
{
public int PathId => path.InvalidationID;
public IReadOnlyList<JuiceStreamPathVertex> Vertices => path.Vertices;
public int VertexCount => path.Vertices.Count;
protected readonly Func<float, double> PositionToDistance;
protected IReadOnlyList<VertexState> VertexStates => vertexStates;
private readonly JuiceStreamPath path = new JuiceStreamPath();
// Invariant: `path.Vertices.Count == vertexStates.Count`
private readonly List<VertexState> vertexStates = new List<VertexState>
{
new VertexState { IsFixed = true }
};
private readonly List<VertexState> previousVertexStates = new List<VertexState>();
[Resolved(CanBeNull = true)]
[CanBeNull]
private IBeatSnapProvider beatSnapProvider { get; set; }
protected EditablePath(Func<float, double> positionToDistance)
{
PositionToDistance = positionToDistance;
Anchor = Anchor.BottomLeft;
}
public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject)
{
while (path.Vertices.Count < InternalChildren.Count)
RemoveInternal(InternalChildren[^1]);
while (InternalChildren.Count < path.Vertices.Count)
AddInternal(new VertexPiece());
double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity);
for (int i = 0; i < VertexCount; i++)
{
var piece = (VertexPiece)InternalChildren[i];
var vertex = path.Vertices[i];
piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor));
piece.UpdateFrom(vertexStates[i]);
}
}
public void InitializeFromHitObject(JuiceStream hitObject)
{
var sliderPath = hitObject.Path;
path.ConvertFromSliderPath(sliderPath);
// If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices.
if (sliderPath.ControlPoints.Any(p => p.Type.Value != null && p.Type.Value != PathType.Linear))
{
path.ResampleVertices(hitObject.NestedHitObjects
.Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used.
.Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity));
}
vertexStates.Clear();
vertexStates.AddRange(path.Vertices.Select((_, i) => new VertexState
{
IsFixed = i == 0
}));
}
public void UpdateHitObjectFromPath(JuiceStream hitObject)
{
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY);
if (beatSnapProvider == null) return;
double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity;
double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime);
hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity;
}
public Vector2 ToRelativePosition(Vector2 screenSpacePosition)
{
return ToLocalSpace(screenSpacePosition) - new Vector2(0, DrawHeight);
}
protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
protected int AddVertex(double distance, float x)
{
int index = path.InsertVertex(distance);
path.SetVertexPosition(index, x);
vertexStates.Insert(index, new VertexState());
correctFixedVertexPositions();
Debug.Assert(vertexStates.Count == VertexCount);
return index;
}
protected bool RemoveVertex(int index)
{
if (index < 0 || index >= path.Vertices.Count)
return false;
if (vertexStates[index].IsFixed)
return false;
path.RemoveVertices((_, i) => i == index);
vertexStates.RemoveAt(index);
if (vertexStates.Count == 0)
vertexStates.Add(new VertexState());
Debug.Assert(vertexStates.Count == VertexCount);
return true;
}
protected void MoveSelectedVertices(double distanceDelta, float xDelta)
{
// Because the vertex list may be reordered due to distance change, the state list must be reordered as well.
previousVertexStates.Clear();
previousVertexStates.AddRange(vertexStates);
// We will recreate the path from scratch. Note that `Clear` leaves the first vertex.
int vertexCount = VertexCount;
path.Clear();
vertexStates.RemoveRange(1, vertexCount - 1);
for (int i = 1; i < vertexCount; i++)
{
var state = previousVertexStates[i];
double distance = state.VertexBeforeChange.Distance;
if (state.IsSelected)
distance += distanceDelta;
int newIndex = path.InsertVertex(Math.Max(0, distance));
vertexStates.Insert(newIndex, state);
}
// First, restore positions of the non-selected vertices.
for (int i = 0; i < vertexCount; i++)
{
if (!vertexStates[i].IsSelected && !vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X);
}
// Then, move the selected vertices.
for (int i = 0; i < vertexCount; i++)
{
if (vertexStates[i].IsSelected && !vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X + xDelta);
}
// Finally, correct the position of fixed vertices.
correctFixedVertexPositions();
}
private void correctFixedVertexPositions()
{
for (int i = 0; i < VertexCount; i++)
{
if (vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X);
}
}
}
}

View File

@ -0,0 +1,34 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
}
}
}

View File

@ -0,0 +1,48 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<CatchHitObject> nestedHitObjects = new List<CatchHitObject>();
public NestedOutlineContainer()
{
Anchor = Anchor.BottomLeft;
}
public void UpdateNestedObjectsFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject parentHitObject)
{
nestedHitObjects.Clear();
nestedHitObjects.AddRange(parentHitObject.NestedHitObjects
.OfType<CatchHitObject>()
.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;
}
}

View File

@ -0,0 +1,49 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Catch.Objects;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class PlacementEditablePath : EditablePath
{
/// <summary>
/// The original position of the last added vertex.
/// This is not same as the last vertex of the current path because the vertex ordering can change.
/// </summary>
private JuiceStreamPathVertex lastVertex;
public PlacementEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
{
}
public void AddNewVertex()
{
var endVertex = Vertices[^1];
int index = AddVertex(endVertex.Distance, endVertex.X);
for (int i = 0; i < VertexCount; i++)
{
VertexStates[i].IsSelected = i == index;
VertexStates[i].IsFixed = i != index;
VertexStates[i].VertexBeforeChange = Vertices[i];
}
lastVertex = Vertices[index];
}
/// <summary>
/// Move the vertex added by <see cref="AddNewVertex"/> in the last time.
/// </summary>
public void MoveLastVertex(Vector2 screenSpacePosition)
{
Vector2 position = ToRelativePosition(screenSpacePosition);
double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance;
float xDelta = position.X - lastVertex.X;
MoveSelectedVertices(distanceDelta, xDelta);
}
}
}

View File

@ -0,0 +1,77 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Vector2>();
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;
}
}

View File

@ -0,0 +1,130 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class SelectionEditablePath : EditablePath, IHasContextMenu
{
public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray();
// To handle when the editor is scrolled while dragging.
private Vector2 dragStartPosition;
[Resolved(CanBeNull = true)]
[CanBeNull]
private IEditorChangeHandler changeHandler { get; set; }
public SelectionEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
{
}
public void AddVertex(Vector2 relativePosition)
{
double distance = Math.Max(0, PositionToDistance(relativePosition.Y));
int index = AddVertex(distance, relativePosition.X);
selectOnly(index);
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => InternalChildren.Any(d => d.ReceivePositionalInputAt(screenSpacePos));
protected override bool OnMouseDown(MouseDownEvent e)
{
int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition);
if (index == -1 || VertexStates[index].IsFixed)
return false;
if (e.Button == MouseButton.Left && e.ShiftPressed)
{
RemoveVertex(index);
return true;
}
if (e.ControlPressed)
VertexStates[index].IsSelected = !VertexStates[index].IsSelected;
else if (!VertexStates[index].IsSelected)
selectOnly(index);
// Don't inhibit right click, to show the context menu
return e.Button != MouseButton.Right;
}
protected override bool OnDragStart(DragStartEvent e)
{
int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition);
if (index == -1 || VertexStates[index].IsFixed)
return false;
if (e.Button != MouseButton.Left)
return false;
dragStartPosition = ToRelativePosition(e.ScreenSpaceMouseDownPosition);
for (int i = 0; i < VertexCount; i++)
VertexStates[i].VertexBeforeChange = Vertices[i];
changeHandler?.BeginChange();
return true;
}
protected override void OnDrag(DragEvent e)
{
Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition);
double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y);
float xDelta = mousePosition.X - dragStartPosition.X;
MoveSelectedVertices(distanceDelta, xDelta);
}
protected override void OnDragEnd(DragEndEvent e)
{
changeHandler?.EndChange();
}
private int getMouseTargetVertex(Vector2 screenSpacePosition)
{
for (int i = InternalChildren.Count - 1; i >= 0; i--)
{
if (i < VertexCount && InternalChildren[i].ReceivePositionalInputAt(screenSpacePosition))
return i;
}
return -1;
}
private IEnumerable<MenuItem> getContextMenuItems()
{
int selectedCount = VertexStates.Count(state => state.IsSelected);
if (selectedCount != 0)
yield return new OsuMenuItem($"Delete selected {(selectedCount == 1 ? "vertex" : $"{selectedCount} vertices")}", MenuItemType.Destructive, deleteSelectedVertices);
}
private void selectOnly(int index)
{
for (int i = 0; i < VertexCount; i++)
VertexStates[i].IsSelected = i == index;
}
private void deleteSelectedVertices()
{
for (int i = VertexCount - 1; i >= 0; i--)
{
if (VertexStates[i].IsSelected)
RemoveVertex(i);
}
}
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
}
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class VertexPiece : Circle
{
[Resolved]
private OsuColour osuColour { get; set; }
public VertexPiece()
{
Anchor = Anchor.BottomLeft;
Origin = Anchor.Centre;
Size = new Vector2(15);
}
public void UpdateFrom(VertexState state)
{
Colour = state.IsSelected ? osuColour.Yellow.Lighten(1) : osuColour.Yellow;
Alpha = state.IsFixed ? 0.5f : 1;
}
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Objects;
#nullable enable
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
/// <summary>
/// Holds the state of a vertex in the path of a <see cref="EditablePath"/>.
/// </summary>
public class VertexState
{
/// <summary>
/// Whether the vertex is selected.
/// </summary>
public bool IsSelected { get; set; }
/// <summary>
/// Whether the vertex can be moved or deleted.
/// </summary>
public bool IsFixed { get; set; }
/// <summary>
/// The position of the vertex before a vertex moving operation starts.
/// This is used to implement "memory-less" moving operations (only the final position matters) to improve UX.
/// </summary>
public JuiceStreamPathVertex VertexBeforeChange { get; set; }
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Fruit>
{
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;
}
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Fruit>
{
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);
}
}
}

View File

@ -0,0 +1,128 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Input;
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;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint<JuiceStream>
{
private readonly ScrollingPath scrollingPath;
private readonly NestedOutlineContainer nestedOutlineContainer;
private readonly PlacementEditablePath editablePath;
private int lastEditablePathId = -1;
private InputManager inputManager;
public JuiceStreamPlacementBlueprint()
{
InternalChildren = new Drawable[]
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new PlacementEditablePath(positionToDistance)
};
}
protected override void Update()
{
base.Update();
if (PlacementActive == PlacementState.Active)
editablePath.UpdateFrom(HitObjectContainer, HitObject);
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
protected override bool OnMouseDown(MouseDownEvent e)
{
switch (PlacementActive)
{
case PlacementState.Waiting:
if (e.Button != MouseButton.Left) break;
editablePath.AddNewVertex();
BeginPlacement(true);
return true;
case PlacementState.Active:
switch (e.Button)
{
case MouseButton.Left:
editablePath.AddNewVertex();
return true;
case MouseButton.Right:
EndPlacement(HitObject.Duration > 0);
return true;
}
break;
}
return base.OnMouseDown(e);
}
public override void UpdateTimeAndPosition(SnapResult result)
{
switch (PlacementActive)
{
case PlacementState.Waiting:
if (!(result.Time is double snappedTime)) return;
HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X;
HitObject.StartTime = snappedTime;
break;
case PlacementState.Active:
Vector2 unsnappedPosition = inputManager.CurrentState.Mouse.Position;
editablePath.MoveLastVertex(unsnappedPosition);
break;
default:
return;
}
// Make sure the up-to-date position is used for outlines.
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
updateHitObjectFromPath();
}
private void updateHitObjectFromPath()
{
if (lastEditablePathId == editablePath.PathId)
return;
editablePath.UpdateHitObjectFromPath(HitObject);
ApplyDefaultsToHitObject();
scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
lastEditablePathId = editablePath.PathId;
}
private double positionToDistance(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
}
}
}

View File

@ -0,0 +1,187 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint<JuiceStream>
{
public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight)));
public override MenuItem[] ContextMenuItems => getContextMenuItems().ToArray();
private float minNestedX;
private float maxNestedX;
private readonly ScrollingPath scrollingPath;
private readonly NestedOutlineContainer nestedOutlineContainer;
private readonly Cached pathCache = new Cached();
private readonly SelectionEditablePath editablePath;
/// <summary>
/// The <see cref="JuiceStreamPath.InvalidationID"/> of the <see cref="JuiceStreamPath"/> corresponding the current <see cref="SliderPath"/> of the hit object.
/// When the path is edited, the change is detected and the <see cref="SliderPath"/> of the hit object is updated.
/// </summary>
private int lastEditablePathId = -1;
/// <summary>
/// The <see cref="SliderPath.Version"/> of the current <see cref="SliderPath"/> of the hit object.
/// When the <see cref="SliderPath"/> of the hit object is changed by external means, the change is detected and the <see cref="JuiceStreamPath"/> is re-initialized.
/// </summary>
private int lastSliderPathVersion = -1;
private Vector2 rightMouseDownPosition;
[Resolved(CanBeNull = true)]
[CanBeNull]
private EditorBeatmap editorBeatmap { get; set; }
public JuiceStreamSelectionBlueprint(JuiceStream hitObject)
: base(hitObject)
{
InternalChildren = new Drawable[]
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new SelectionEditablePath(positionToDistance)
};
}
[BackgroundDependencyLoader]
private void load()
{
HitObject.DefaultsApplied += onDefaultsApplied;
computeObjectBounds();
}
protected override void Update()
{
base.Update();
if (!IsSelected) return;
if (editablePath.PathId != lastEditablePathId)
updateHitObjectFromPath();
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
editablePath.UpdateFrom(HitObjectContainer, HitObject);
if (pathCache.IsValid) return;
scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
pathCache.Validate();
}
protected override void OnSelected()
{
initializeJuiceStreamPath();
base.OnSelected();
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (!IsSelected) return base.OnMouseDown(e);
switch (e.Button)
{
case MouseButton.Left when e.ControlPressed:
editablePath.AddVertex(editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition));
return true;
case MouseButton.Right:
// Record the mouse position to be used in the "add vertex" action.
rightMouseDownPosition = editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition);
break;
}
return base.OnMouseDown(e);
}
private void onDefaultsApplied(HitObject _)
{
computeObjectBounds();
pathCache.Invalidate();
if (lastSliderPathVersion != HitObject.Path.Version.Value)
initializeJuiceStreamPath();
}
private void computeObjectBounds()
{
minNestedX = HitObject.NestedHitObjects.OfType<CatchHitObject>().Min(nested => nested.OriginalX) - HitObject.OriginalX;
maxNestedX = HitObject.NestedHitObjects.OfType<CatchHitObject>().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);
}
private double positionToDistance(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
}
private void initializeJuiceStreamPath()
{
editablePath.InitializeFromHitObject(HitObject);
// Record the current ID to update the hit object only when a change is made to the path.
lastEditablePathId = editablePath.PathId;
lastSliderPathVersion = HitObject.Path.Version.Value;
}
private void updateHitObjectFromPath()
{
editablePath.UpdateHitObjectFromPath(HitObject);
editorBeatmap?.Update(HitObject);
lastEditablePathId = editablePath.PathId;
lastSliderPathVersion = HitObject.Path.Version.Value;
}
private IEnumerable<MenuItem> getContextMenuItems()
{
yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () =>
{
editablePath.AddVertex(rightMouseDownPosition);
});
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
HitObject.DefaultsApplied -= onDefaultsApplied;
}
}
}

View File

@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<HitObject> 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);
}
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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?
Catcher.CatchFruitOnPlate = false;
// TODO: disable hit lighting as well
}
}
}

View File

@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<CatchHitObject>
{
public CatchHitObjectComposer(CatchRuleset ruleset)
: base(ruleset)
{
}
[BackgroundDependencyLoader]
private void load()
{
LayerBelowRuleset.Add(new PlayfieldBorder
{
RelativeSizeAxes = Axes.Both,
PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners }
});
}
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods);
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
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);
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit
{
/// <summary>
/// Utility functions used by the editor.
/// </summary>
public static class CatchHitObjectUtils
{
/// <summary>
/// Get the position of the hit object in the playfield based on <see cref="CatchHitObject.OriginalX"/> and <see cref="HitObject.StartTime"/>.
/// </summary>
public static Vector2 GetStartPosition(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject)
{
return new Vector2(hitObject.OriginalX, hitObjectContainer.PositionAtTime(hitObject.StartTime));
}
/// <summary>
/// Get the range of horizontal position occupied by the hit object.
/// </summary>
/// <remarks>
/// <see cref="TinyDroplet"/>s are excluded and returns <see cref="PositionRange.EMPTY"/>.
/// </remarks>
public static PositionRange GetPositionRange(HitObject hitObject)
{
switch (hitObject)
{
case Fruit fruit:
return new PositionRange(fruit.OriginalX);
case Droplet droplet:
return droplet is TinyDroplet ? PositionRange.EMPTY : new PositionRange(droplet.OriginalX);
case JuiceStream _:
return GetPositionRange(hitObject.NestedHitObjects);
case BananaShower _:
// A banana shower occupies the whole screen width.
return new PositionRange(0, CatchPlayfield.WIDTH);
default:
return PositionRange.EMPTY;
}
}
/// <summary>
/// Get the range of horizontal position occupied by the hit objects.
/// </summary>
/// <remarks>
/// <see cref="TinyDroplet"/>s are excluded.
/// </remarks>
public static PositionRange GetPositionRange(IEnumerable<HitObject> hitObjects) => hitObjects.Select(GetPositionRange).Aggregate(PositionRange.EMPTY, PositionRange.Union);
}
}

View File

@ -0,0 +1,141 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
using Direction = osu.Framework.Graphics.Direction;
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<HitObject> 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, SelectedItems);
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 catchObject)) return;
catchObject.OriginalX += deltaX;
// Move the nested hit objects to give an instant result before nested objects are recreated.
foreach (var nested in catchObject.NestedHitObjects.OfType<CatchHitObject>())
nested.OriginalX += deltaX;
});
return true;
}
public override bool HandleFlip(Direction direction)
{
var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems);
bool changed = false;
EditorBeatmap.PerformOnSelection(h =>
{
if (h is CatchHitObject catchObject)
changed |= handleFlip(selectionRange, catchObject);
});
return changed;
}
public override bool HandleReverse()
{
double selectionStartTime = SelectedItems.Min(h => h.StartTime);
double selectionEndTime = SelectedItems.Max(h => h.GetEndTime());
EditorBeatmap.PerformOnSelection(hitObject =>
{
hitObject.StartTime = selectionEndTime - (hitObject.GetEndTime() - selectionStartTime);
if (hitObject is JuiceStream juiceStream)
{
juiceStream.Path.Reverse(out Vector2 positionalOffset);
juiceStream.OriginalX += positionalOffset.X;
juiceStream.LegacyConvertedY += positionalOffset.Y;
EditorBeatmap.Update(juiceStream);
}
});
return true;
}
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems);
SelectionBox.CanFlipX = selectionRange.Length > 0 && SelectedItems.Any(h => h is CatchHitObject && !(h is BananaShower));
SelectionBox.CanReverse = SelectedItems.Count > 1 || SelectedItems.Any(h => h is JuiceStream);
}
/// <summary>
/// Limit positional movement of the objects by the constraint that moved objects should stay in bounds.
/// </summary>
/// <param name="deltaX">The positional movement.</param>
/// <param name="movingObjects">The objects to be moved.</param>
/// <returns>The positional movement with the restriction applied.</returns>
private float limitMovement(float deltaX, IEnumerable<HitObject> movingObjects)
{
var range = CatchHitObjectUtils.GetPositionRange(movingObjects);
// 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 = -range.Min;
float upperBound = CatchPlayfield.WIDTH - range.Max;
// 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);
}
private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject)
{
switch (hitObject)
{
case BananaShower _:
return false;
case JuiceStream juiceStream:
juiceStream.OriginalX = selectionRange.GetFlippedPosition(juiceStream.OriginalX);
foreach (var point in juiceStream.Path.ControlPoints)
point.Position.Value *= new Vector2(-1, 1);
EditorBeatmap.Update(juiceStream);
return true;
default:
hitObject.OriginalX = selectionRange.GetFlippedPosition(hitObject.OriginalX);
return true;
}
}
}
}

View File

@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Mod> mods = null)
: base(ruleset, beatmap, mods)
{
}
protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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();
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 JuiceStreamCompositionTool : HitObjectCompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
}

View File

@ -0,0 +1,42 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Edit
{
/// <summary>
/// Represents either the empty range or a closed interval of horizontal positions in the playfield.
/// A <see cref="PositionRange"/> represents a closed interval if it is <see cref="Min"/> &lt;= <see cref="Max"/>, and represents the empty range otherwise.
/// </summary>
public readonly struct PositionRange
{
public readonly float Min;
public readonly float Max;
public float Length => Math.Max(0, Max - Min);
public PositionRange(float value)
: this(value, value)
{
}
public PositionRange(float min, float max)
{
Min = min;
Max = max;
}
public static PositionRange Union(PositionRange a, PositionRange b) => new PositionRange(Math.Min(a.Min, b.Min), Math.Max(a.Max, b.Max));
/// <summary>
/// Get the given position flipped (mirrored) for the axis at the center of this range.
/// Returns the given position unchanged if the range was empty.
/// </summary>
public float GetFlippedPosition(float x) => Min <= Max ? Max - (x - Min) : x;
public static readonly PositionRange EMPTY = new PositionRange(float.PositiveInfinity, float.NegativeInfinity);
}
}

View File

@ -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<float> 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<float> 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;
}
}
}

View File

@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Catch.Mods
{
base.Update();
var catcherArea = playfield.CatcherArea;
FlashlightPosition = catcherArea.ToSpaceOfOtherDrawable(catcherArea.MovableCatcher.DrawPosition, this);
FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this);
}
private float getSizeFor(int combo)

View File

@ -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;
}
}
}

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Catch.Mods
var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset;
var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield;
catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
catchPlayfield.Catcher.CatchFruitOnPlate = false;
}
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)

View File

@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osuTK;
namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModMirror : ModMirror, IApplicableToBeatmap
{
public override string Description => "Fruits are flipped horizontally.";
/// <remarks>
/// <see cref="IApplicableToBeatmap"/> is used instead of <see cref="IApplicableToHitObject"/>,
/// as <see cref="CatchBeatmapProcessor"/> applies offsets in <see cref="CatchBeatmapProcessor.PostProcess"/>.
/// <see cref="IApplicableToBeatmap"/> runs after post-processing, while <see cref="IApplicableToHitObject"/> runs before it.
/// </remarks>
public void ApplyToBeatmap(IBeatmap beatmap)
{
foreach (var hitObject in beatmap.HitObjects)
applyToHitObject(hitObject);
}
private void applyToHitObject(HitObject hitObject)
{
var catchObject = (CatchHitObject)hitObject;
switch (catchObject)
{
case Fruit fruit:
mirrorEffectiveX(fruit);
break;
case JuiceStream juiceStream:
mirrorEffectiveX(juiceStream);
mirrorJuiceStreamPath(juiceStream);
break;
case BananaShower bananaShower:
mirrorBananaShower(bananaShower);
break;
}
}
/// <summary>
/// Mirrors the effective X position of <paramref name="catchObject"/> and its nested hit objects.
/// </summary>
private static void mirrorEffectiveX(CatchHitObject catchObject)
{
catchObject.OriginalX = CatchPlayfield.WIDTH - catchObject.OriginalX;
catchObject.XOffset = -catchObject.XOffset;
foreach (var nested in catchObject.NestedHitObjects.Cast<CatchHitObject>())
{
nested.OriginalX = CatchPlayfield.WIDTH - nested.OriginalX;
nested.XOffset = -nested.XOffset;
}
}
/// <summary>
/// Mirrors the path of the <paramref name="juiceStream"/>.
/// </summary>
private static void mirrorJuiceStreamPath(JuiceStream juiceStream)
{
var controlPoints = juiceStream.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray();
foreach (var point in controlPoints)
point.Position.Value = new Vector2(-point.Position.Value.X, point.Position.Value.Y);
juiceStream.Path = new SliderPath(controlPoints, juiceStream.Path.ExpectedDistance.Value);
}
/// <summary>
/// Mirrors X positions of all bananas in the <paramref name="bananaShower"/>.
/// </summary>
private static void mirrorBananaShower(BananaShower bananaShower)
{
foreach (var banana in bananaShower.NestedHitObjects.OfType<Banana>())
banana.XOffset = CatchPlayfield.WIDTH - banana.XOffset;
}
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Mods;
namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModMuted : ModMuted<CatchHitObject>
{
}
}

View File

@ -33,13 +33,13 @@ namespace osu.Game.Rulesets.Catch.Mods
private class MouseInputHelper : Drawable, IKeyBindingHandler<CatchAction>, 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);
}
}

View File

@ -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<Color4> comboColours) => getBananaColour();
Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour();
private Color4 getBananaColour()
{

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
/// <summary>
/// The horizontal position of the hit object between 0 and <see cref="CatchPlayfield.WIDTH"/>.
/// </summary>
/// <remarks>
/// Only setter is exposed.
/// Use <see cref="OriginalX"/> or <see cref="EffectiveX"/> to get the horizontal position.
/// </remarks>
[JsonIgnore]
public float X
{
set => OriginalXBindable.Value = value;
}
float IHasXPosition.X => OriginalXBindable.Value;
public readonly Bindable<float> XOffsetBindable = new Bindable<float>();
/// <summary>
@ -34,6 +39,7 @@ namespace osu.Game.Rulesets.Catch.Objects
/// </summary>
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 <see cref="X"/> value specified in the beatmap, not affected by the beatmap processing.
/// Use <see cref="EffectiveX"/> for a gameplay.
/// </remarks>
public float OriginalX => OriginalXBindable.Value;
public float OriginalX
{
get => OriginalXBindable.Value;
set => OriginalXBindable.Value = value;
}
/// <summary>
/// The effective horizontal position of the hit object between 0 and <see cref="CatchPlayfield.WIDTH"/>.
@ -53,9 +63,9 @@ namespace osu.Game.Rulesets.Catch.Objects
/// This value is the original <see cref="X"/> value plus the offset applied by the beatmap processing.
/// Use <see cref="OriginalX"/> if a value not affected by the offset is desired.
/// </remarks>
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<int> IndexInBeatmapBindable = new Bindable<int>();
@ -85,6 +95,14 @@ namespace osu.Game.Rulesets.Catch.Objects
set => ComboIndexBindable.Value = value;
}
public Bindable<int> ComboIndexWithOffsetsBindable { get; } = new Bindable<int>();
public int ComboIndexWithOffsets
{
get => ComboIndexWithOffsetsBindable.Value;
set => ComboIndexWithOffsetsBindable.Value = value;
}
public Bindable<bool> LastInComboBindable { get; } = new Bindable<bool>();
/// <summary>
@ -120,5 +138,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;
/// <summary>
/// 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.
/// </summary>
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
}
}

View File

@ -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; }
/// <summary>
@ -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;

View File

@ -0,0 +1,340 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Represents the path of a juice stream.
/// <para>
/// A <see cref="JuiceStream"/> holds a legacy <see cref="SliderPath"/> as the representation of the path.
/// However, the <see cref="SliderPath"/> representation is difficult to work with.
/// This <see cref="JuiceStreamPath"/> represents the path in a more convenient way, a polyline connecting list of <see cref="JuiceStreamPathVertex"/>s.
/// </para>
/// <para>
/// The path can be regarded as a function from the closed interval <c>[Vertices[0].Distance, Vertices[^1].Distance]</c> to the x position, given by <see cref="PositionAtDistance"/>.
/// To ensure the path is convertible to a <see cref="SliderPath"/>, the slope of the function must not be more than <c>1</c> everywhere,
/// and this slope condition is always maintained as an invariant.
/// </para>
/// </summary>
public class JuiceStreamPath
{
/// <summary>
/// The height of legacy osu!standard playfield.
/// The sliders converted by <see cref="ConvertToSliderPath"/> are vertically contained in this height.
/// </summary>
internal const float OSU_PLAYFIELD_HEIGHT = 384;
/// <summary>
/// The list of vertices of the path, which is represented as a polyline connecting the vertices.
/// </summary>
public IReadOnlyList<JuiceStreamPathVertex> Vertices => vertices;
/// <summary>
/// The current version number.
/// This starts from <c>1</c> and incremented whenever this <see cref="JuiceStreamPath"/> is modified.
/// </summary>
public int InvalidationID { get; private set; } = 1;
/// <summary>
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Distance"/> and last vertex's <see cref="JuiceStreamPathVertex.Distance"/>.
/// </summary>
public double Distance => vertices[^1].Distance - vertices[0].Distance;
/// <remarks>
/// This list should always be non-empty.
/// </remarks>
private readonly List<JuiceStreamPathVertex> vertices = new List<JuiceStreamPathVertex>
{
new JuiceStreamPathVertex()
};
/// <summary>
/// Compute the x-position of the path at the given <paramref name="distance"/>.
/// </summary>
/// <remarks>
/// When the given distance is outside of the path, the x position at the corresponding endpoint is returned,
/// </remarks>
public float PositionAtDistance(double distance)
{
int index = vertexIndexAtDistance(distance);
return positionAtDistance(distance, index);
}
/// <summary>
/// Remove all vertices of this path, then add a new vertex <c>(0, 0)</c>.
/// </summary>
public void Clear()
{
vertices.Clear();
vertices.Add(new JuiceStreamPathVertex());
invalidate();
}
/// <summary>
/// Insert a vertex at given <paramref name="distance"/>.
/// The <see cref="PositionAtDistance"/> 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).
/// </summary>
/// <returns>The index of the new vertex.</returns>
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;
}
/// <summary>
/// Move the vertex of given <paramref name="index"/> to the given position <paramref name="newX"/>.
/// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards <paramref name="newX"/>.
/// </summary>
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();
}
/// <summary>
/// Add a new vertex at given <paramref name="distance"/> and position.
/// Adjacent vertices are moved when necessary in the same way as <see cref="SetVertexPosition"/>.
/// </summary>
public void Add(double distance, float x)
{
int index = InsertVertex(distance);
SetVertexPosition(index, x);
}
/// <summary>
/// Remove all vertices that satisfy the given <paramref name="predicate"/>.
/// </summary>
/// <remarks>
/// If all vertices are removed, a new vertex <c>(0, 0)</c> is added.
/// </remarks>
/// <param name="predicate">The predicate to determine whether a vertex should be removed given the vertex and its index in the path.</param>
/// <returns>The number of removed vertices.</returns>
public int RemoveVertices(Func<JuiceStreamPathVertex, int, bool> 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;
}
/// <summary>
/// Recreate this path by using difference set of vertices at given distances.
/// In addition to the given <paramref name="sampleDistances"/>, the first vertex and the last vertex are always added to the new path.
/// New vertices use the positions on the original path. Thus, <see cref="PositionAtDistance"/>s at <paramref name="sampleDistances"/> are preserved.
/// </summary>
public void ResampleVertices(IEnumerable<double> sampleDistances)
{
var sampledVertices = new List<JuiceStreamPathVertex>();
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();
}
/// <summary>
/// Convert a <see cref="SliderPath"/> to list of vertices and write the result to this <see cref="JuiceStreamPath"/>.
/// </summary>
/// <remarks>
/// Duplicated vertices are automatically removed.
/// </remarks>
public void ConvertFromSliderPath(SliderPath sliderPath)
{
var sliderPathVertices = new List<Vector2>();
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();
}
/// <summary>
/// Convert the path of this <see cref="JuiceStreamPath"/> to a <see cref="SliderPath"/> and write the result to <paramref name="sliderPath"/>.
/// The resulting slider is "folded" to make it vertically contained in the playfield `(0..<see cref="OSU_PLAYFIELD_HEIGHT"/>)` assuming the slider start position is <paramref name="sliderStartY"/>.
/// </summary>
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;
}
}
/// <summary>
/// Find the index at which a new vertex with <paramref name="distance"/> can be inserted.
/// </summary>
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;
}
/// <summary>
/// Compute the position at the given <paramref name="distance"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtDistance"/>.
/// </summary>
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));
}
/// <summary>
/// Check the two vertices can connected directly while satisfying the slope condition.
/// </summary>
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;
}
/// <summary>
/// Move the position of <paramref name="movableVertex"/> towards the position of <paramref name="fixedVertex"/>
/// until the vertex pair satisfies the condition <see cref="canConnect"/>.
/// </summary>
/// <returns>The resulting position of <paramref name="movableVertex"/>.</returns>
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++;
}
}

View File

@ -0,0 +1,33 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A vertex of a <see cref="JuiceStreamPath"/>.
/// </summary>
public readonly struct JuiceStreamPathVertex : IComparable<JuiceStreamPathVertex>
{
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})";
}
}

View File

@ -1,9 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
/// <summary>
/// The target fruit if we are to initiate a hyperdash.
/// </summary>
[JsonIgnore]
public CatchHitObject HyperDashTarget
{
get => hyperDashTarget;
@ -43,6 +45,6 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
Color4 IHasComboInformation.GetComboColour(IReadOnlyList<Color4> comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count];
Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1);
}
}

View File

@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Replays
bool impossibleJump = speedRequired > movement_speed * 2;
// todo: get correct catcher size, based on difficulty CS.
const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f;
const float catcher_width_half = Catcher.BASE_SIZE * 0.3f * 0.5f;
if (lastPosition - catcher_width_half < h.EffectiveX && lastPosition + catcher_width_half > h.EffectiveX)
{

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
FlipCatcherPlate
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<CatcherAnimationState> CurrentState { get; } = new Bindable<CatcherAnimationState>();
private readonly Sprite sprite;
private readonly Dictionary<CatcherAnimationState, Texture> textures = new Dictionary<CatcherAnimationState, Texture>();
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<CatcherAnimationState> 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);
}
}
}

View File

@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
/// </summary>
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, decimal>(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<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
@ -93,15 +97,28 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (lookup)
{
case CatchSkinColour colour:
var result = (Bindable<Color4>)Source.GetConfig<SkinCustomColourLookup, TValue>(new SkinCustomColourLookup(colour));
var result = (Bindable<Color4>)base.GetConfig<SkinCustomColourLookup, TValue>(new SkinCustomColourLookup(colour));
if (result == null)
return null;
result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value);
return (IBindable<TValue>)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<TValue>)new Bindable<bool>();
break;
}
break;
}
return Source.GetConfig<TLookup, TValue>(lookup);
return base.GetConfig<TLookup, TValue>(lookup);
}
}
}

View File

@ -0,0 +1,69 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<CatcherAnimationState> currentState { get; set; }
private readonly Dictionary<CatcherAnimationState, Drawable> drawables = new Dictionary<CatcherAnimationState, Drawable>();
private Drawable currentDrawable;
public LegacyCatcherNew()
{
RelativeSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
foreach (var state in Enum.GetValues(typeof(CatcherAnimationState)).Cast<CatcherAnimationState>())
{
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);
}
}
}

View File

@ -0,0 +1,32 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
});
}
}
}

View File

@ -1,10 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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,40 +26,47 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
public const float CENTER_X = WIDTH / 2;
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<CatchHitObject, DrawableHitObject<CatchHitObject>> createDrawableRepresentation)
internal Catcher Catcher { get; private set; }
internal CatcherArea CatcherArea { get; private set; }
private readonly BeatmapDifficulty difficulty;
public CatchPlayfield(BeatmapDifficulty difficulty)
{
var droppedObjectContainer = new Container<CaughtObject>
{
RelativeSizeAxes = Axes.Both,
};
CatcherArea = new CatcherArea(droppedObjectContainer, difficulty)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
};
InternalChildren = new[]
{
droppedObjectContainer,
CatcherArea.MovableCatcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
// make sure the up-to-date catcher position is used for the catcher catching logic of hit objects.
CatcherArea,
HitObjectContainer,
};
this.difficulty = difficulty;
}
[BackgroundDependencyLoader]
private void load()
{
var droppedObjectContainer = new DroppedObjectContainer();
Catcher = new Catcher(droppedObjectContainer, difficulty)
{
X = CENTER_X
};
AddRangeInternal(new[]
{
droppedObjectContainer,
Catcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
// make sure the up-to-date catcher position is used for the catcher catching logic of hit objects.
CatcherArea = new CatcherArea
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
Catcher = Catcher,
},
HitObjectContainer,
});
RegisterPool<Droplet, DrawableDroplet>(50);
RegisterPool<TinyDroplet, DrawableTinyDroplet>(50);
RegisterPool<Fruit, DrawableFruit>(100);
@ -84,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.UI
((DrawableCatchHitObject)d).CheckPosition = checkIfWeCanCatch;
}
private bool checkIfWeCanCatch(CatchHitObject obj) => CatcherArea.MovableCatcher.CanCatch(obj);
private bool checkIfWeCanCatch(CatchHitObject obj) => Catcher.CanCatch(obj);
private void onNewResult(DrawableHitObject judgedObject, JudgementResult result)
=> CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result);

Some files were not shown because too many files have changed in this diff Show More