1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 19:04:00 +08:00

Compare commits

...

1247 Commits

638 changed files with 14179 additions and 5191 deletions
-1
View File
@@ -339,6 +339,5 @@ inspectcode
# Fody (pulled in by Realm) - schema file
FodyWeavers.xsd
**/FodyWeavers.xml
.idea/.idea.osu.Desktop/.idea/misc.xml
+26 -15
View File
@@ -12,45 +12,48 @@
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! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
This is the future and final iteration of the [osu!](https://osu.ppy.sh) game client which marks the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
## Status
This project is under constant development, but we aim to keep things in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update.
This project is under constant development, but we do our best to keep things in a stable state. Players are encouraged to install from a release alongside their stable *osu!* client. This project will continue to evolve until we eventually reach the point where most users prefer it over the previous "osu!stable" release.
**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to a [stable release](https://osu.ppy.sh/home/download) of osu!. We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet.
We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project:
A few resources are available as starting points to getting involved and understanding the project:
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
- Track our current efforts [towards full "ranked play" support](https://github.com/orgs/ppy/projects/13?query=is%3Aopen+sort%3Aupdated-desc).
## Running osu!
If you are looking to install or test osu! without setting up a development environment, you can consume our [releases](https://github.com/ppy/osu/releases). You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). Failing that, you may use the links below to download the latest version for your operating system of choice:
If you are just looking to give the game a whirl, you can grab the latest release for your platform:
**Latest release:**
### Latest release:
| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) |
| ------------- | ------------- | ------------- | ------------- | ------------- |
- The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets.
You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download).
If your platform is not listed above, there is still a chance you can manually build it by following the instructions below.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
## Developing a custom ruleset
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).
osu! is designed to allow user-created gameplay variations, called "rulesets". Building one of these allows a developer to harness the power of the osu! beatmap library, game engine, and general UX for a new style of gameplay. 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/discussions/13096).
## Developing osu!
### Prerequisites
Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/).
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed.
### Downloading the source code
@@ -69,9 +72,19 @@ git pull
### Building
Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing).
#### From an IDE
- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will allow access to template run configurations.
You should load the solution via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will reduce dependencies and hide platforms that you don't care about. Valid `.slnf` files are:
- `osu.Desktop.slnf` (most common)
- `osu.Android.slnf`
- `osu.iOS.slnf`
Run configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `osu! (Tests)` project/configuration. More information on this is provided [below](#contributing).
To build for mobile platforms, you will likely need to run `sudo dotnet workload restore` if you haven't done so previously. This will install Android/iOS tooling required to complete the build.
#### From CLI
You can also build and run *osu!* from the command-line with a single command:
@@ -79,12 +92,10 @@ You can also build and run *osu!* from the command-line with a single command:
dotnet run --project osu.Desktop
```
If you are not interested in debugging *osu!*, you can add `-c Release` to gain performance. In this case, you must replace `Debug` with `Release` in any commands mentioned in this document.
When running locally to do any kind of performance testing, make sure to add `-c Release` to the build command, as the overhead of running with the default `Debug` configuration can be large (especially when testing with local framework modifications as below).
If the build fails, try to restore NuGet packages with `dotnet restore`.
_Due to a historical feature gap between .NET Core and Xamarin, running `dotnet` CLI from the root directory will not work for most commands. This can be resolved by specifying a target `.csproj` or the helper project at `build/Desktop.proj`. Configurations have been provided to work around this issue for all supported IDEs mentioned above._
### Testing with resource/framework modifications
Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands:
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
+1 -5
View File
@@ -8,13 +8,9 @@
<!-- NullabilityInfoContextSupport is disabled by default for Android -->
<NullabilityInfoContextSupport>true</NullabilityInfoContextSupport>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<AndroidManifestMerger>manifestmerger.jar</AndroidManifestMerger>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.716.0" />
</ItemGroup>
<ItemGroup>
<AndroidManifestOverlay Include="$(MSBuildThisFileDirectory)osu.Android\Properties\AndroidManifestOverlay.xml" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.922.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+4 -1
View File
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@drawable/lazer" />
<!-- for editor usage -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
</manifest>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
</intent>
</queries>
</manifest>
-3
View File
@@ -54,9 +54,6 @@ namespace osu.Desktop
client.OnReady += onReady;
// safety measure for now, until we performance test / improve backoff for failed connections.
client.OnConnectionFailed += (_, _) => client.Deinitialize();
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network);
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
@@ -75,7 +75,7 @@ namespace osu.Desktop.LegacyIpc
case LegacyIpcDifficultyCalculationRequest req:
try
{
WorkingBeatmap beatmap = new FlatFileWorkingBeatmap(req.BeatmapFile);
WorkingBeatmap beatmap = new FlatWorkingBeatmap(req.BeatmapFile);
var ruleset = beatmap.BeatmapInfo.Ruleset.CreateInstance();
Mod[] mods = ruleset.ConvertFromLegacyMods((LegacyMods)req.Mods).ToArray();
+1 -1
View File
@@ -85,7 +85,7 @@ namespace osu.Desktop
}
}
using (DesktopGameHost host = Host.GetSuitableDesktopHost(gameName, new HostOptions { BindIPC = true }))
using (DesktopGameHost host = Host.GetSuitableDesktopHost(gameName, new HostOptions { BindIPC = !tournamentClient }))
{
if (!host.IsPrimaryInstance)
{
+1 -1
View File
@@ -26,7 +26,7 @@
<PackageReference Include="Clowd.Squirrel" Version="2.9.42" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="7.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.1.3.18" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />
@@ -7,9 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.4" />
<PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
<PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- using a different name because package name cannot contain 'catch' -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Catch_Tests.Android" android:installLocation="auto">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!catch Test" />
</manifest>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Catch.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Catch-Tests-iOS</string>
<string>sh.ppy.catch-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
@@ -42,4 +42,4 @@
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
</plist>
@@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1]));
AddAssert("default slider velocity", () => lastObject.SliderVelocityBindable.IsDefault);
AddAssert("default slider velocity", () => lastObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addPlacementSteps(times, positions);
addPathCheckStep(times, positions);
AddAssert("slider velocity changed", () => !lastObject.SliderVelocityBindable.IsDefault);
AddAssert("slider velocity changed", () => !lastObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -108,11 +108,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
double[] times = { 100, 300 };
float[] positions = { 200, 300 };
addBlueprintStep(times, positions);
AddAssert("default slider velocity", () => hitObject.SliderVelocityBindable.IsDefault);
AddAssert("default slider velocity", () => hitObject.SliderVelocityMultiplierBindable.IsDefault);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 400);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityBindable.IsDefault);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Catch.Tests
AddSliderStep("start time", 500, 600, 0, x =>
{
drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = x;
drawableFruit.RefreshStateTransforms();
drawableBanana.RefreshStateTransforms();
});
}
@@ -44,6 +46,8 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("Initialize start time", () =>
{
drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = initial_start_time;
drawableFruit.RefreshStateTransforms();
drawableBanana.RefreshStateTransforms();
fruitRotation = drawableFruit.DisplayRotation;
bananaRotation = drawableBanana.DisplayRotation;
@@ -54,6 +58,8 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("change start time", () =>
{
drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = another_start_time;
drawableFruit.RefreshStateTransforms();
drawableBanana.RefreshStateTransforms();
});
AddAssert("fruit rotation is changed", () => drawableFruit.DisplayRotation != fruitRotation);
@@ -64,6 +70,8 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("reset start time", () =>
{
drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = initial_start_time;
drawableFruit.RefreshStateTransforms();
drawableBanana.RefreshStateTransforms();
});
AddAssert("rotation and size restored", () =>
@@ -0,0 +1,157 @@
// 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 NUnit.Framework;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Scoring;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual.Gameplay;
namespace osu.Game.Rulesets.Catch.Tests
{
[TestFixture]
public partial class TestSceneScoring : ScoringTestScene
{
public TestSceneScoring()
: base(supportsNonPerfectJudgements: false)
{
}
private Bindable<double> scoreMultiplier { get; } = new BindableDouble
{
Default = 4,
Value = 4
};
protected override IBeatmap CreateBeatmap(int maxCombo)
{
var beatmap = new CatchBeatmap();
for (int i = 0; i < maxCombo; ++i)
beatmap.HitObjects.Add(new Fruit());
return beatmap;
}
protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new CatchProcessorBasedScoringAlgorithm(beatmap, mode);
[Test]
public void TestBasicScenarios()
{
AddStep("set up score multiplier", () =>
{
scoreMultiplier.BindValueChanged(_ => Rerun());
});
AddStep("set max combo to 100", () => MaxCombo.Value = 100);
AddStep("set perfect score", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
});
AddStep("set score with misses", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
MissLocations.AddRange(new[] { 24d, 49 });
});
AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
}
private const int base_great = 300;
private class ScoreV1 : IScoringAlgorithm
{
private int currentCombo;
public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
public void ApplyHit() => applyHitV1(base_great);
public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
public void ApplyMiss() => applyHitV1(0);
private void applyHitV1(int baseScore)
{
if (baseScore == 0)
{
currentCombo = 0;
return;
}
TotalScore += baseScore;
// combo multiplier
// ReSharper disable once PossibleLossOfFraction
TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * ScoreMultiplier.Value));
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private readonly double comboPortionMax;
private const double combo_base = 4;
private const int combo_cap = 200;
public ScoreV2(int maxCombo)
{
for (int i = 0; i < maxCombo; i++)
ApplyHit();
comboPortionMax = comboPortion;
currentCombo = 0;
comboPortion = 0;
}
public void ApplyHit() => applyHitV2(base_great);
public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
private void applyHitV2(int baseScore)
{
comboPortion += baseScore * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(combo_cap, combo_base));
}
public void ApplyMiss()
{
currentCombo = 0;
}
public long TotalScore
=> (int)Math.Round(1000000 * comboPortion / comboPortionMax); // vast simplification, as we're not doing ticks here.
}
private class CatchProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public CatchProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
: base(beatmap, mode)
{
}
protected override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor();
protected override JudgementResult CreatePerfectJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Great };
protected override JudgementResult CreateNonPerfectJudgementResult() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
protected override JudgementResult CreateMissJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Miss };
}
}
}
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -41,9 +41,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
SliderVelocity = sliderVelocityData?.SliderVelocity ?? 1
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield();
case IHasDuration endTime:
+1
View File
@@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -49,15 +46,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
};
if (ComputeLegacyScoringValues)
{
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -2,33 +2,26 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Catch.Difficulty
{
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
@@ -70,13 +63,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
scoreMultiplier = difficultyPeppyStars;
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -112,28 +111,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty
case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
case BananaShower:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
@@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdateHitObjectFromPath(JuiceStream hitObject)
{
// The SV setting may need to be changed for the current path.
var svBindable = hitObject.SliderVelocityBindable;
var svBindable = hitObject.SliderVelocityMultiplierBindable;
double svToVelocityFactor = hitObject.Velocity / svBindable.Value;
double requiredVelocity = path.ComputeRequiredVelocity();
@@ -25,6 +25,7 @@ using osuTK;
namespace osu.Game.Rulesets.Catch.Edit
{
// we're also a ScrollingHitObjectComposer candidate, but can't be everything can we?
public partial class CatchHitObjectComposer : DistancedHitObjectComposer<CatchHitObject>
{
private const float distance_snap_radius = 50;
@@ -140,7 +141,7 @@ namespace osu.Game.Rulesets.Catch.Edit
return base.OnPressed(e);
}
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) =>
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{
TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
+9 -11
View File
@@ -28,17 +28,17 @@ namespace osu.Game.Rulesets.Catch.Objects
public int RepeatCount { get; set; }
public BindableNumber<double> SliderVelocityBindable { get; } = new BindableDouble(1)
public BindableNumber<double> SliderVelocityMultiplierBindable { get; } = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10
};
public double SliderVelocity
public double SliderVelocityMultiplier
{
get => SliderVelocityBindable.Value;
set => SliderVelocityBindable.Value = value;
get => SliderVelocityMultiplierBindable.Value;
set => SliderVelocityMultiplierBindable.Value = value;
}
[JsonIgnore]
@@ -48,10 +48,10 @@ namespace osu.Game.Rulesets.Catch.Objects
private double tickDistanceFactor;
[JsonIgnore]
public double Velocity => velocityFactor * SliderVelocity;
public double Velocity => velocityFactor * SliderVelocityMultiplier;
[JsonIgnore]
public double TickDistance => tickDistanceFactor * SliderVelocity;
public double TickDistance => tickDistanceFactor * SliderVelocityMultiplier;
/// <summary>
/// The length of one span of this <see cref="JuiceStream"/>.
@@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
int nodeIndex = 0;
SliderEventDescriptor? lastEvent = null;
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken))
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken))
{
// generate tiny droplets since the last point
if (lastEvent != null)
@@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
// this also includes LegacyLastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied.
// this also includes LastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
lastEvent = e;
switch (e.Type)
@@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
public double Distance => Path.Distance;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
public double? LegacyLastTickOffset { get; set; }
}
}
@@ -2,17 +2,21 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 banana_max_size = new Vector2(128);
protected override void LoadComplete()
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-bananas");
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay");
Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size);
SetTexture(texture, overlayTexture);
}
@@ -2,12 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 droplet_max_size = new Vector2(82, 103);
public LegacyDropletPiece()
{
Scale = new Vector2(0.8f);
@@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-drop");
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay");
Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size);
SetTexture(texture, overlayTexture);
}
@@ -2,11 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 fruit_max_size = new Vector2(128);
protected override void LoadComplete()
{
base.LoadComplete();
@@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (visualRepresentation)
{
case FruitVisualRepresentation.Pear:
SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay"));
setTextures("pear");
break;
case FruitVisualRepresentation.Grape:
SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay"));
setTextures("grapes");
break;
case FruitVisualRepresentation.Pineapple:
SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay"));
setTextures("apple");
break;
case FruitVisualRepresentation.Raspberry:
SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay"));
setTextures("orange");
break;
}
void setTextures(string fruitName) => SetTexture(
Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size),
Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size)
);
}
}
}
@@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI
{
public partial class DrawableCatchRuleset : DrawableScrollingRuleset<CatchHitObject>
{
protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Constant;
protected override bool UserScrollSpeedAdjustment => false;
public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
@@ -30,6 +28,7 @@ namespace osu.Game.Rulesets.Catch.UI
{
Direction.Value = ScrollingDirection.Down;
TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate);
VisualisationMethod = ScrollVisualisationMethod.Constant;
}
[BackgroundDependencyLoader]
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Mania.Tests.Android" android:installLocation="auto">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!mania Test" />
</manifest>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Mania.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Mania-Tests-iOS</string>
<string>sh.ppy.mania-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
@@ -42,4 +42,4 @@
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
</plist>
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
PassCondition = () =>
{
var hitObject = Player.ChildrenOfType<DrawableManiaHitObject>().FirstOrDefault();
return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm is ConstantScrollAlgorithm;
return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm.Value is ConstantScrollAlgorithm;
}
});
}
@@ -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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Replays;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
{
public partial class TestSceneManiaModDoubleTime : ModTestScene
{
private const double offset = 18;
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Test]
public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData
{
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value != 1,
Autoplay = false,
Beatmap = new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000 }
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
[Test]
public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData
{
Mod = new ManiaModDoubleTime(),
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1,
Autoplay = false,
Beatmap = new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000 }
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
}
}
@@ -6,7 +6,6 @@ using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Tests.Visual;
using System.Collections.Generic;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Beatmaps;
@@ -24,21 +23,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Assert.False(testBeatmap.HitObjects.OfType<HoldNote>().Any());
}
[Test]
public void TestCorrectNoteValues()
{
var testBeatmap = createRawBeatmap();
var noteValues = new List<double>(testBeatmap.HitObjects.OfType<HoldNote>().Count());
foreach (HoldNote h in testBeatmap.HitObjects.OfType<HoldNote>())
{
noteValues.Add(ManiaModHoldOff.GetNoteDurationInBeatLength(h, testBeatmap));
}
noteValues.Sort();
Assert.AreEqual(noteValues, new List<double> { 0.125, 0.250, 0.500, 1.000, 2.000 });
}
[Test]
public void TestCorrectObjectCount()
{
@@ -47,25 +31,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
var rawBeatmap = createRawBeatmap();
var testBeatmap = createModdedBeatmap();
// Calculate expected number of objects
int expectedObjectCount = 0;
foreach (ManiaHitObject h in rawBeatmap.HitObjects)
{
// Both notes and hold notes account for at least one object
expectedObjectCount++;
if (h.GetType() == typeof(HoldNote))
{
double noteValue = ManiaModHoldOff.GetNoteDurationInBeatLength((HoldNote)h, rawBeatmap);
if (noteValue >= ManiaModHoldOff.END_NOTE_ALLOW_THRESHOLD)
{
// Should generate an end note if it's longer than the minimum note value
expectedObjectCount++;
}
}
}
// Both notes and hold notes account for at least one object
int expectedObjectCount = rawBeatmap.HitObjects.Count;
Assert.That(testBeatmap.HitObjects.Count == expectedObjectCount);
}
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
IBindable<ScrollingDirection> IScrollingInfo.Direction => Direction;
IBindable<double> IScrollingInfo.TimeRange { get; } = new Bindable<double>(5000);
IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ConstantScrollAlgorithm();
IBindable<IScrollAlgorithm> IScrollingInfo.Algorithm { get; } = new Bindable<IScrollAlgorithm>(new ConstantScrollAlgorithm());
}
}
}
@@ -0,0 +1,175 @@
// 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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual.Gameplay;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public partial class TestSceneScoring : ScoringTestScene
{
protected override IBeatmap CreateBeatmap(int maxCombo)
{
var beatmap = new ManiaBeatmap(new StageDefinition(5));
for (int i = 0; i < maxCombo; ++i)
beatmap.HitObjects.Add(new Note());
return beatmap;
}
protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1(MaxCombo.Value);
protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new ManiaProcessorBasedScoringAlgorithm(beatmap, mode);
[Test]
public void TestBasicScenarios()
{
AddStep("set max combo to 100", () => MaxCombo.Value = 100);
AddStep("set perfect score", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
});
AddStep("set score with misses", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
MissLocations.AddRange(new[] { 24d, 49 });
});
AddStep("set score with misses and OKs", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
MissLocations.AddRange(new[] { 24d, 49 });
});
}
private class ScoreV1 : IScoringAlgorithm
{
private int currentCombo;
private double comboAddition = 100;
private double totalScoreDouble;
private readonly double scoreMultiplier;
public ScoreV1(int maxCombo)
{
scoreMultiplier = 500000d / maxCombo;
}
public void ApplyHit() => applyHitV1(320, add => add + 2, 32);
public void ApplyNonPerfect() => applyHitV1(100, add => add - 24, 8);
public void ApplyMiss() => applyHitV1(0, _ => -56, 0);
private void applyHitV1(int scoreIncrease, Func<double, double> comboAdditionFunc, int delta)
{
comboAddition = comboAdditionFunc(comboAddition);
if (currentCombo != 0 && currentCombo % 384 == 0)
comboAddition = 100;
comboAddition = Math.Max(0, Math.Min(comboAddition, 100));
double scoreIncreaseD = Math.Sqrt(comboAddition) * delta * scoreMultiplier / 320;
TotalScore = (long)totalScoreDouble;
scoreIncreaseD += scoreIncrease * scoreMultiplier / 320;
scoreIncrease = (int)scoreIncreaseD;
TotalScore += scoreIncrease;
totalScoreDouble += scoreIncreaseD;
if (scoreIncrease > 0)
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private double currentBaseScore;
private double maxBaseScore;
private int currentHits;
private readonly double comboPortionMax;
private readonly int maxCombo;
private const double combo_base = 4;
public ScoreV2(int maxCombo)
{
this.maxCombo = maxCombo;
for (int i = 0; i < this.maxCombo; i++)
ApplyHit();
comboPortionMax = comboPortion;
currentCombo = 0;
comboPortion = 0;
currentBaseScore = 0;
maxBaseScore = 0;
currentHits = 0;
}
public void ApplyHit() => applyHitV2(305, 300);
public void ApplyNonPerfect() => applyHitV2(100, 100);
private void applyHitV2(int hitValue, int baseHitValue)
{
maxBaseScore += 305;
currentBaseScore += hitValue;
comboPortion += baseHitValue * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(400, combo_base));
currentHits++;
}
public void ApplyMiss()
{
currentHits++;
maxBaseScore += 305;
currentCombo = 0;
}
public long TotalScore
{
get
{
float accuracy = (float)(currentBaseScore / maxBaseScore);
return (int)Math.Round
(
200000 * comboPortion / comboPortionMax +
800000 * Math.Pow(accuracy, 2 + 2 * accuracy) * ((double)currentHits / maxCombo)
);
}
}
}
private class ManiaProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public ManiaProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
: base(beatmap, mode)
{
}
protected override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor();
protected override JudgementResult CreatePerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Perfect };
protected override JudgementResult CreateNonPerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Ok };
protected override JudgementResult CreateMissJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Miss };
}
}
}
@@ -117,18 +117,16 @@ namespace osu.Game.Rulesets.Mania.Tests
private void createBarLine(bool major)
{
foreach (var stage in stages)
var obj = new BarLine
{
var obj = new BarLine
{
StartTime = Time.Current + 2000,
Major = major,
};
StartTime = Time.Current + 2000,
Major = major,
};
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
foreach (var stage in stages)
stage.Add(obj);
}
}
private ScrollingTestContainer createStage(ScrollingDirection direction, ManiaAction action)
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -10,10 +10,11 @@ using System.Linq;
using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
@@ -50,10 +51,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime);
double beatLength;
if (hitObject.LegacyBpmMultiplier.HasValue)
beatLength = timingPoint.BeatLength * hitObject.LegacyBpmMultiplier.Value;
else if (hitObject is IHasSliderVelocity hasSliderVelocity)
beatLength = timingPoint.BeatLength / hasSliderVelocity.SliderVelocity;
if (hitObject is IHasSliderVelocity hasSliderVelocity)
beatLength = LegacyRulesetExtensions.GetPrecisionAdjustedBeatLength(hasSliderVelocity, timingPoint, ManiaRuleset.SHORT_NAME);
else
beatLength = timingPoint.BeatLength;
@@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
@@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
if (ComputeLegacyScoringValues)
{
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -1,28 +1,16 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Mania.Difficulty
{
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore => 0;
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
.Select(m => m.ScoreMultiplier)
.Aggregate(1.0, (c, n) => c * n);
ComboScore = (int)(1000000 * multiplier);
return new LegacyScoreAttributes { ComboScore = 1000000 };
}
}
}
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
{
private const double individual_decay_base = 0.125;
private const double overall_decay_base = 0.30;
private const double release_threshold = 24;
private const double release_threshold = 30;
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1;
@@ -50,10 +50,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
for (int i = 0; i < endTimes.Length; ++i)
{
// The current note is overlapped if a previous note or end is overlapping the current note body
isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1);
isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1);
// We give a slight bonus to everything if something is held meanwhile
if (Precision.DefinitelyBigger(endTimes[i], endTime, 1))
if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
@@ -70,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime)));
holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime)));
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
@@ -2,18 +2,22 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osuTK;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.Edit
{
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset, ISupportConstantAlgorithmToggle
{
public BindableBool ShowSpeedChanges { get; set; } = new BindableBool();
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods)
@@ -21,6 +25,13 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
protected override void LoadComplete()
{
base.LoadComplete();
ShowSpeedChanges.BindValueChanged(showChanges => VisualisationMethod = showChanges.NewValue ? ScrollVisualisationMethod.Sequential : ScrollVisualisationMethod.Constant, true);
}
protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages)
{
Anchor = Anchor.Centre,
@@ -21,7 +21,7 @@ using osuTK;
namespace osu.Game.Rulesets.Mania.Edit
{
public partial class ManiaHitObjectComposer : HitObjectComposer<ManiaHitObject>
public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject>
{
private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid;
@@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(screenSpacePosition);
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
{
drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods);
@@ -0,0 +1,22 @@
// 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.Bindables;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens.Edit.Setup;
namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public partial class ManiaDifficultySection : DifficultySection
{
[BackgroundDependencyLoader]
private void load()
{
CircleSizeSlider.Label = BeatmapsetsStrings.ShowStatsCsMania;
CircleSizeSlider.Description = "The number of columns in the beatmap";
if (CircleSizeSlider.Current is BindableNumber<float> circleSizeFloat)
circleSizeFloat.Precision = 1;
}
}
}
+3
View File
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
@@ -425,6 +426,8 @@ namespace osu.Game.Rulesets.Mania
}
public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection();
public override DifficultySection CreateEditorDifficultySection() => new ManiaDifficultySection();
}
public enum PlayfieldType
@@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania
},
new SettingsCheckbox
{
Keywords = new[] { "color" },
LabelText = RulesetSettingsStrings.TimingBasedColouring,
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
}
@@ -33,5 +33,6 @@ namespace osu.Game.Rulesets.Mania
HitExplosion,
StageBackground,
StageForeground,
BarLine
}
}
@@ -0,0 +1,47 @@
// 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.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
/// <summary>
/// May be attached to rate-adjustment mods to adjust hit windows adjust relative to gameplay rate.
/// </summary>
/// <remarks>
/// Historically, in osu!mania, hit windows are expected to adjust relative to the gameplay rate such that the real-world hit window remains the same.
/// </remarks>
public interface IManiaRateAdjustmentMod : IApplicableToDifficulty, IApplicableToHitObject
{
BindableNumber<double> SpeedChange { get; }
HitWindows HitWindows { get; set; }
void IApplicableToDifficulty.ApplyToDifficulty(BeatmapDifficulty difficulty)
{
HitWindows = new ManiaHitWindows(SpeedChange.Value);
HitWindows.SetDifficulty(difficulty.OverallDifficulty);
}
void IApplicableToHitObject.ApplyToHitObject(HitObject hitObject)
{
switch (hitObject)
{
case Note:
hitObject.HitWindows = HitWindows;
break;
case HoldNote hold:
hold.Head.HitWindows = HitWindows;
hold.Tail.HitWindows = HitWindows;
break;
}
}
}
}
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant;
maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant;
}
}
}
@@ -1,11 +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 osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModDaycore : ModDaycore
public class ManiaModDaycore : ModDaycore, IManiaRateAdjustmentMod
{
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
}
}
@@ -1,11 +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 osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModDoubleTime : ModDoubleTime
public class ManiaModDoubleTime : ModDoubleTime, IManiaRateAdjustmentMod
{
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
}
}
@@ -1,11 +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 osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModHalfTime : ModHalfTime
public class ManiaModHalfTime : ModHalfTime, IManiaRateAdjustmentMod
{
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
}
}
@@ -29,8 +29,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert) };
public const double END_NOTE_ALLOW_THRESHOLD = 0.5;
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
@@ -46,28 +44,9 @@ namespace osu.Game.Rulesets.Mania.Mods
StartTime = h.StartTime,
Samples = h.GetNodeSamples(0)
});
// Don't add an end note if the duration is shorter than the threshold
double noteValue = GetNoteDurationInBeatLength(h, maniaBeatmap); // 1/1, 1/2, 1/4, etc.
if (noteValue >= END_NOTE_ALLOW_THRESHOLD)
{
newObjects.Add(new Note
{
Column = h.Column,
StartTime = h.EndTime,
Samples = h.GetNodeSamples((h.NodeSamples?.Count - 1) ?? 1)
});
}
}
maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType<Note>().Concat(newObjects).OrderBy(h => h.StartTime).ToList();
}
public static double GetNoteDurationInBeatLength(HoldNote holdNote, ManiaBeatmap beatmap)
{
double beatLength = beatmap.ControlPointInfo.TimingPointAt(holdNote.StartTime).BeatLength;
return holdNote.Duration / beatLength;
}
}
}
@@ -2,11 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModNightcore : ModNightcore<ManiaHitObject>
public class ManiaModNightcore : ModNightcore<ManiaHitObject>, IManiaRateAdjustmentMod
{
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
}
}
+10 -1
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 osu.Framework.Bindables;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
@@ -8,7 +9,15 @@ namespace osu.Game.Rulesets.Mania.Objects
{
public class BarLine : ManiaHitObject, IBarLine
{
public bool Major { get; set; }
private HitObjectProperty<bool> major;
public Bindable<bool> MajorBindable => major.Bindable;
public bool Major
{
get => major.Value;
set => major.Value = value;
}
public override Judgement CreateJudgement() => new IgnoreJudgement();
}
@@ -1,9 +1,11 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
@@ -13,45 +15,41 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableBarLine : DrawableManiaHitObject<BarLine>
{
public readonly Bindable<bool> Major = new Bindable<bool>();
public DrawableBarLine()
: this(null!)
{
}
public DrawableBarLine(BarLine barLine)
: base(barLine)
{
RelativeSizeAxes = Axes.X;
Height = barLine.Major ? 1.7f : 1.2f;
}
AddInternal(new Box
[BackgroundDependencyLoader]
private void load()
{
AddInternal(new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.BarLine), _ => new DefaultBarLine())
{
Name = "Bar line",
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Alpha = barLine.Major ? 0.5f : 0.2f
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
if (barLine.Major)
{
Vector2 size = new Vector2(22, 6);
const float line_offset = 4;
Major.BindValueChanged(major => Height = major.NewValue ? 1.7f : 1.2f, true);
}
AddInternal(new Circle
{
Name = "Left line",
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight,
protected override void OnApply()
{
base.OnApply();
Major.BindTo(HitObject.MajorBindable);
}
Size = size,
X = -line_offset,
});
AddInternal(new Circle
{
Name = "Right line",
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft,
Size = size,
X = line_offset,
});
}
protected override void OnFree()
{
base.OnFree();
Major.UnbindFrom(HitObject.MajorBindable);
}
protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150);
@@ -1,12 +1,25 @@
// 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.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
{
public class ManiaHitWindows : HitWindows
{
private readonly double multiplier;
public ManiaHitWindows()
: this(1)
{
}
public ManiaHitWindows(double multiplier)
{
this.multiplier = multiplier;
}
public override bool IsHitResultAllowed(HitResult result)
{
switch (result)
@@ -22,5 +35,12 @@ namespace osu.Game.Rulesets.Mania.Scoring
return false;
}
protected override DifficultyRange[] GetRanges() => base.GetRanges().Select(r =>
new DifficultyRange(
r.Result,
r.Min * multiplier,
r.Average * multiplier,
r.Max * multiplier)).ToArray();
}
}
@@ -0,0 +1,82 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Mania.Skinning.Default
{
public partial class DefaultBarLine : CompositeDrawable
{
private Bindable<bool> major = null!;
private Drawable mainLine = null!;
private Drawable leftAnchor = null!;
private Drawable rightAnchor = null!;
[BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject)
{
RelativeSizeAxes = Axes.Both;
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
AddInternal(mainLine = new Box
{
Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
});
const float major_extension = 10;
AddInternal(leftAnchor = new Box
{
Name = "Left anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight,
Width = major_extension,
RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.Transparent, Colour4.White),
});
AddInternal(rightAnchor = new Box
{
Name = "Right anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft,
Width = major_extension,
RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Transparent),
});
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
}
protected override void LoadComplete()
{
base.LoadComplete();
major.BindValueChanged(updateMajor, true);
}
private void updateMajor(ValueChangedEvent<bool> major)
{
mainLine.Alpha = major.NewValue ? 0.5f : 0.2f;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
}
}
}
@@ -119,6 +119,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
case ManiaSkinComponents.StageForeground:
return new LegacyStageForeground();
case ManiaSkinComponents.BarLine:
return null; // Not yet implemented.
default:
throw new UnsupportedSkinComponentException(lookup);
}
@@ -30,5 +30,7 @@ namespace osu.Game.Rulesets.Mania.Skinning
Lookup = lookup;
ColumnIndex = columnIndex;
}
public override string ToString() => $"[{nameof(ManiaSkinConfigurationLookup)} lookup:{Lookup} col:{ColumnIndex}]";
}
}
@@ -3,7 +3,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@@ -14,7 +13,6 @@ using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
@@ -52,22 +50,6 @@ namespace osu.Game.Rulesets.Mania.UI
protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config;
public ScrollVisualisationMethod ScrollMethod
{
get => scrollMethod;
set
{
if (IsLoaded)
throw new InvalidOperationException($"Can't alter {nameof(ScrollMethod)} after ruleset is already loaded");
scrollMethod = value;
}
}
private ScrollVisualisationMethod scrollMethod = ScrollVisualisationMethod.Sequential;
protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod;
private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>();
private readonly BindableInt configScrollSpeed = new BindableInt();
private double smoothTimeRange;
@@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
@@ -25,6 +26,22 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly List<Stage> stages = new List<Stage>();
public override Quad SkinnableComponentScreenSpaceDrawQuad
{
get
{
RectangleF totalArea = RectangleF.Empty;
for (int i = 0; i < Stages.Count; ++i)
{
var stageArea = Stages[i].ScreenSpaceDrawQuad.AABBFloat;
totalArea = i == 0 ? stageArea : RectangleF.Union(totalArea, stageArea);
}
return totalArea;
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos));
public ManiaPlayfield(List<StageDefinition> stageDefinitions)
+3 -1
View File
@@ -136,6 +136,8 @@ namespace osu.Game.Rulesets.Mania.UI
columnFlow.SetContentForColumn(i, column);
AddNested(column);
}
RegisterPool<BarLine, DrawableBarLine>(50, 200);
}
private ISkinSource currentSkin;
@@ -186,7 +188,7 @@ namespace osu.Game.Rulesets.Mania.UI
public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h);
public void Add(BarLine barLine) => base.Add(new DrawableBarLine(barLine));
public void Add(BarLine barLine) => base.Add(barLine);
internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result)
{
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Osu.Tests.Android" android:installLocation="auto">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!standard Test" />
</manifest>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Osu.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Osu-Tests-iOS</string>
<string>sh.ppy.osu-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
@@ -42,4 +42,4 @@
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
</plist>
@@ -25,6 +25,35 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[Test]
public void TestSelectAfterFadedOut()
{
var slider = new Slider
{
StartTime = 0,
Position = new Vector2(100, 100),
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
new PathControlPoint(new Vector2(100))
}
}
};
AddStep("add slider", () => EditorBeatmap.Add(slider));
moveMouseToObject(() => slider);
AddStep("seek after end", () => EditorClock.Seek(750));
AddStep("left click", () => InputManager.Click(MouseButton.Left));
AddAssert("slider not selected", () => EditorBeatmap.SelectedHitObjects.Count == 0);
AddStep("seek to visible", () => EditorClock.Seek(650));
AddStep("left click", () => InputManager.Click(MouseButton.Left));
AddUntilStep("slider selected", () => EditorBeatmap.SelectedHitObjects.Single() == slider);
}
[Test]
public void TestContextMenuShownCorrectlyForSelectedSlider()
{
@@ -9,6 +9,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
@@ -70,12 +71,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
base.Content.Children = new Drawable[]
{
editorClock = new EditorClock(editorBeatmap),
snapProvider,
new PopoverContainer { Child = snapProvider },
Content
};
}
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
protected override Container<Drawable> Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
[SetUp]
public void Setup() => Schedule(() =>
@@ -0,0 +1,95 @@
// 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.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Tests.Beatmaps;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestScenePreciseRotation : TestSceneOsuEditor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset);
[Test]
public void TestHotkeyHandling()
{
AddStep("select single circle", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType<HitCircle>().First()));
AddStep("press rotate hotkey", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.R);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("no popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.Zero);
AddStep("select first three objects", () =>
{
EditorBeatmap.SelectedHitObjects.Clear();
EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects.Take(3));
});
AddStep("press rotate hotkey", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.R);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.EqualTo(1));
AddStep("press rotate hotkey", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.R);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("no popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.Zero);
}
[Test]
public void TestRotateCorrectness()
{
AddStep("replace objects", () =>
{
EditorBeatmap.Clear();
EditorBeatmap.AddRange(new HitObject[]
{
new HitCircle { Position = new Vector2(100) },
new HitCircle { Position = new Vector2(200) },
});
});
AddStep("select both circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("press rotate hotkey", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.R);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("popover present", getPopover, () => Is.Not.Null);
AddStep("rotate by 180deg", () => getPopover().ChildrenOfType<TextBox>().Single().Current.Value = "180");
AddAssert("first object rotated 180deg around playfield centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(100)));
AddAssert("second object rotated 180deg around playfield centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(1).TriggerClick());
AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position, () => Is.EqualTo(new Vector2(100, 100)));
PreciseRotationPopover? getPopover() => this.ChildrenOfType<PreciseRotationPopover>().SingleOrDefault();
}
}
}
@@ -0,0 +1,110 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Tests.Beatmaps;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderReversal : TestSceneOsuEditor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false);
private readonly PathControlPoint[][] paths =
{
createPathSegment(
PathType.PerfectCurve,
new Vector2(200, -50),
new Vector2(250, 0)
),
createPathSegment(
PathType.Linear,
new Vector2(100, 0),
new Vector2(100, 100)
)
};
private static PathControlPoint[] createPathSegment(PathType type, params Vector2[] positions)
{
return positions.Select(p => new PathControlPoint
{
Position = p
}).Prepend(new PathControlPoint
{
Type = type
}).ToArray();
}
private Slider selectedSlider => (Slider)EditorBeatmap.SelectedHitObjects[0];
[TestCase(0, 250)]
[TestCase(0, 200)]
[TestCase(1, 120)]
[TestCase(1, 80)]
public void TestSliderReversal(int pathIndex, double length)
{
var controlPoints = paths[pathIndex];
Vector2 oldStartPos = default;
Vector2 oldEndPos = default;
double oldDistance = default;
var oldControlPointTypes = controlPoints.Select(p => p.Type);
AddStep("Add slider", () =>
{
var slider = new Slider
{
Position = new Vector2(OsuPlayfield.BASE_SIZE.X / 2, OsuPlayfield.BASE_SIZE.Y / 2),
Path = new SliderPath(controlPoints)
{
ExpectedDistance = { Value = length }
}
};
EditorBeatmap.Add(slider);
oldStartPos = slider.Position;
oldEndPos = slider.EndPosition;
oldDistance = slider.Path.Distance;
});
AddStep("Select slider", () =>
{
var slider = (Slider)EditorBeatmap.HitObjects[0];
EditorBeatmap.SelectedHitObjects.Add(slider);
});
AddStep("Reverse slider", () =>
{
InputManager.PressKey(Key.LControl);
InputManager.Key(Key.G);
InputManager.ReleaseKey(Key.LControl);
});
AddAssert("Slider has correct length", () =>
Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance));
AddAssert("Slider has correct start position", () =>
Vector2.Distance(selectedSlider.Position, oldEndPos) < 1);
AddAssert("Slider has correct end position", () =>
Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1);
AddAssert("Control points have correct types", () =>
{
var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray();
return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes);
});
}
}
}
@@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider = new Slider
{
Position = new Vector2(0, 50),
LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point
Path = new SliderPath(new[]
{
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
@@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
new PathControlPoint(new Vector2(0, 6.25f))
}),
RepeatCount = 1,
SliderVelocity = 10
SliderVelocityMultiplier = 10
}
}
},
@@ -19,6 +19,8 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase("basic")]
[TestCase("colinear-perfect-curve")]
[TestCase("slider-ticks")]
[TestCase("slider-ticks-edge-case")]
[TestCase("slider-paths-edge-case")]
[TestCase("repeat-slider")]
[TestCase("uneven-repeat-slider")]
[TestCase("old-stacking")]
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Tests
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (auto && !userTriggered && timeOffset > hitOffset && CheckHittable?.Invoke(this, Time.Current) != false)
if (auto && !userTriggered && timeOffset > hitOffset && CheckHittable?.Invoke(this, Time.Current, HitResult.Great) == ClickAction.Hit)
{
// force success
ApplyResult(r => r.Type = HitResult.Great);
@@ -13,6 +13,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
@@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private float? alphaAtMiss;
[Test]
public void TestHitCircleClassicMod()
public void TestHitCircleClassicModMiss()
{
AddStep("Create hit circle", () =>
{
@@ -61,8 +62,27 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Transparent when missed", () => alphaAtMiss == 0);
}
/// <summary>
/// No early fade is expected to be applied if the hit circle has been hit.
/// </summary>
[Test]
public void TestHitCircleNoMod()
public void TestHitCircleClassicModHit()
{
TestDrawableHitCircle circle = null!;
AddStep("Create hit circle", () =>
{
SelectedMods.Value = new Mod[] { new OsuModClassic() };
circle = createCircle(true);
});
AddUntilStep("Wait until circle is hit", () => circle.Result?.Type == HitResult.Great);
AddUntilStep("Wait for miss window", () => Clock.CurrentTime, () => Is.GreaterThanOrEqualTo(circle.HitObject.StartTime + circle.HitObject.HitWindows.WindowFor(HitResult.Miss)));
AddAssert("Check circle is still visible", () => circle.Alpha, () => Is.GreaterThan(0));
}
[Test]
public void TestHitCircleNoModMiss()
{
AddStep("Create hit circle", () =>
{
@@ -74,6 +94,16 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Opaque when missed", () => alphaAtMiss == 1);
}
[Test]
public void TestHitCircleNoModHit()
{
AddStep("Create hit circle", () =>
{
SelectedMods.Value = Array.Empty<Mod>();
createCircle(true);
});
}
[Test]
public void TestSliderClassicMod()
{
@@ -100,27 +130,33 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Head circle opaque when missed", () => alphaAtMiss == 1);
}
private void createCircle()
private TestDrawableHitCircle createCircle(bool shouldHit = false)
{
alphaAtMiss = null;
DrawableHitCircle drawableHitCircle = new DrawableHitCircle(new HitCircle
TestDrawableHitCircle drawableHitCircle = new TestDrawableHitCircle(new HitCircle
{
StartTime = Time.Current + 500,
Position = new Vector2(250)
});
Position = new Vector2(250),
}, shouldHit);
drawableHitCircle.Scale = new Vector2(2f);
LoadComponent(drawableHitCircle);
foreach (var mod in SelectedMods.Value.OfType<IApplicableToDrawableHitObject>())
mod.ApplyToDrawableHitObject(drawableHitCircle);
drawableHitCircle.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
drawableHitCircle.OnNewResult += (_, _) =>
drawableHitCircle.OnNewResult += (_, result) =>
{
alphaAtMiss = drawableHitCircle.Alpha;
if (!result.IsHit)
alphaAtMiss = drawableHitCircle.Alpha;
};
Child = drawableHitCircle;
return drawableHitCircle;
}
private void createSlider()
@@ -138,6 +174,8 @@ namespace osu.Game.Rulesets.Osu.Tests
})
});
drawableSlider.Scale = new Vector2(2f);
drawableSlider.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
drawableSlider.OnLoadComplete += _ =>
@@ -145,12 +183,36 @@ namespace osu.Game.Rulesets.Osu.Tests
foreach (var mod in SelectedMods.Value.OfType<IApplicableToDrawableHitObject>())
mod.ApplyToDrawableHitObject(drawableSlider.HeadCircle);
drawableSlider.HeadCircle.OnNewResult += (_, _) =>
drawableSlider.HeadCircle.OnNewResult += (_, result) =>
{
alphaAtMiss = drawableSlider.HeadCircle.Alpha;
if (!result.IsHit)
alphaAtMiss = drawableSlider.HeadCircle.Alpha;
};
};
Child = drawableSlider;
}
protected partial class TestDrawableHitCircle : DrawableHitCircle
{
private readonly bool shouldHit;
public TestDrawableHitCircle(HitCircle h, bool shouldHit)
: base(h)
{
this.shouldHit = shouldHit;
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (shouldHit && !userTriggered && timeOffset >= 0)
{
// force success
ApplyResult(r => r.Type = HitResult.Great);
}
else
base.CheckForResult(userTriggered, timeOffset);
}
}
}
}
@@ -0,0 +1,890 @@
// 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.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Formats;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneLegacyHitPolicy : RateAdjustedBeatmapTestScene
{
private readonly OsuHitWindows referenceHitWindows;
/// <summary>
/// This is provided as a convenience for testing note lock behaviour against osu!stable.
/// Setting this field to a non-null path will cause beatmap files and replays used in all test cases
/// to be exported to disk so that they can be cross-checked against stable.
/// </summary>
private readonly string? exportLocation = null;
public TestSceneLegacyHitPolicy()
{
referenceHitWindows = new OsuHitWindows();
referenceHitWindows.SetDifficulty(0);
}
/// <summary>
/// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleBeforeFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
// note lock prevented the object from being hit, so the judgement offset should be very late.
addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh));
addClickActionAssert(0, ClickAction.Shake);
}
/// <summary>
/// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAtFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
// note lock prevented the object from being hit, so the judgement offset should be very late.
addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh));
addClickActionAssert(0, ClickAction.Shake);
}
/// <summary>
/// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAfterFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
// note lock prevented the object from being hit, so the judgement offset should be very late.
addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh));
addClickActionAssert(0, ClickAction.Shake);
}
/// <summary>
/// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged.
/// </summary>
[Test]
public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 190, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle - 90, Position = positionSecondCircle, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Meh);
addJudgementAssert(hitObjects[1], HitResult.Meh);
addJudgementOffsetAssert(hitObjects[0], -190); // time_first_circle - 190
addJudgementOffsetAssert(hitObjects[1], -190); // time_second_circle - first_circle_time - 90
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
/// <summary>
/// Tests clicking a future circle after the first circle's start time, while the first circle HAS been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAfterFirstCircleTimeWithFirstCircleJudged()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 190, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Meh);
addJudgementAssert(hitObjects[1], HitResult.Ok);
addJudgementOffsetAssert(hitObjects[0], -190); // time_first_circle - 190
addJudgementOffsetAssert(hitObjects[1], -100); // time_second_circle - first_circle_time
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
/// <summary>
/// Tests clicking a future circle after a slider's start time, but hitting the slider head and all slider ticks.
/// </summary>
[Test]
public void TestHitCircleBeforeSliderHead()
{
const double time_slider = 1500;
const double time_circle = 1510;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new Slider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(50, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit);
addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit);
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
/// <summary>
/// Tests clicking hitting future slider ticks before a circle.
/// </summary>
[Test]
public void TestHitSliderTicksBeforeCircle()
{
const double time_slider = 1500;
const double time_circle = 1510;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(30);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new Slider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(50, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_circle + referenceHitWindows.WindowFor(HitResult.Meh) - 100, Position = positionCircle, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_circle + referenceHitWindows.WindowFor(HitResult.Meh) - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Ok);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit);
addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit);
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
/// <summary>
/// Tests clicking a future circle before a spinner.
/// </summary>
[Test]
public void TestHitCircleBeforeSpinner()
{
const double time_spinner = 1500;
const double time_circle = 1600;
Vector2 positionCircle = Vector2.Zero;
var hitObjects = new List<OsuHitObject>
{
new TestSpinner
{
StartTime = time_spinner,
Position = new Vector2(256, 192),
EndTime = time_spinner + 1000,
},
new HitCircle
{
StartTime = time_circle,
Position = positionCircle
},
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Meh);
addClickActionAssert(0, ClickAction.Hit);
}
[Test]
public void TestHitSliderHeadBeforeHitCircle()
{
const double time_circle = 1000;
const double time_slider = 1200;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new Slider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addClickActionAssert(0, ClickAction.Shake);
addClickActionAssert(1, ClickAction.Hit);
addClickActionAssert(2, ClickAction.Hit);
}
[Test]
public void TestOverlappingSliders()
{
const double time_first_slider = 1000;
const double time_second_slider = 1200;
Vector2 positionFirstSlider = new Vector2(100, 50);
Vector2 positionSecondSlider = new Vector2(100, 80);
var midpoint = (positionFirstSlider + positionSecondSlider) / 2;
var hitObjects = new List<OsuHitObject>
{
new Slider
{
StartTime = time_first_slider,
Position = positionFirstSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
},
new Slider
{
StartTime = time_second_slider,
Position = positionSecondSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton, OsuAction.RightButton } },
new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint },
new OsuReplayFrame { Time = time_second_slider, Position = positionSecondSlider + new Vector2(0, 10), Actions = { OsuAction.LeftButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Ok);
addJudgementAssert(hitObjects[1], HitResult.Great);
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
[Test]
public void TestStacksDoNotShake()
{
const double time_stack_start = 1000;
Vector2 position = new Vector2(80);
var hitObjects = Enumerable.Range(0, 20).Select(i => new HitCircle
{
StartTime = time_stack_start + i * 100,
Position = position
}).Cast<OsuHitObject>().ToList();
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_stack_start - 450, Position = new Vector2(55), Actions = { OsuAction.LeftButton } },
});
addClickActionAssert(0, ClickAction.Ignore);
}
[Test]
public void TestAutopilotReducesHittableRange()
{
const double time_circle = 1500;
Vector2 positionCircle = Vector2.Zero;
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_circle,
Position = positionCircle
},
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_circle - 250, Position = positionCircle, Actions = { OsuAction.LeftButton } }
}, new Mod[] { new OsuModAutopilot() });
addJudgementAssert(hitObjects[0], HitResult.Miss);
// note lock prevented the object from being hit, so the judgement offset should be very late.
addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh));
addClickActionAssert(0, ClickAction.Shake);
}
[Test]
public void TestInputDoesNotFallThroughOverlappingSliders()
{
const double time_first_slider = 1000;
const double time_second_slider = 1250;
Vector2 positionFirstSlider = new Vector2(100, 50);
Vector2 positionSecondSlider = new Vector2(100, 80);
var midpoint = (positionFirstSlider + positionSecondSlider) / 2;
var hitObjects = new List<OsuHitObject>
{
new Slider
{
StartTime = time_first_slider,
Position = positionFirstSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
},
new Slider
{
StartTime = time_second_slider,
Position = positionSecondSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint },
});
addJudgementAssert(hitObjects[0], HitResult.Ok);
addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0);
addJudgementAssert(hitObjects[1], HitResult.Miss);
// the slider head of the first slider prevents the second slider's head from being hit, so the judgement offset should be very late.
// this is not strictly done by the hit policy implementation itself (see `OsuModClassic.blockInputToObjectsUnderSliderHead()`),
// but we're testing this here anyways to just keep everything related to input handling and note lock in one place.
addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, referenceHitWindows.WindowFor(HitResult.Meh));
addClickActionAssert(0, ClickAction.Hit);
}
[Test]
public void TestOverlappingSlidersDontBlockEachOtherWhenFullyJudged()
{
const double time_first_slider = 1000;
const double time_second_slider = 1600;
Vector2 positionFirstSlider = new Vector2(100, 50);
Vector2 positionSecondSlider = new Vector2(100, 80);
var midpoint = (positionFirstSlider + positionSecondSlider) / 2;
var hitObjects = new List<OsuHitObject>
{
new Slider
{
StartTime = time_first_slider,
Position = positionFirstSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
},
new Slider
{
StartTime = time_second_slider,
Position = positionSecondSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint },
// this frame doesn't do anything on lazer, but is REQUIRED for correct playback on stable,
// because stable during replay playback only updates game state _when it encounters a replay frame_
new OsuReplayFrame { Time = 1250, Position = midpoint },
new OsuReplayFrame { Time = time_second_slider + 50, Position = midpoint, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_second_slider + 75, Position = midpoint },
});
addJudgementAssert(hitObjects[0], HitResult.Ok);
addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0);
addJudgementAssert(hitObjects[1], HitResult.Ok);
addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, 50);
addClickActionAssert(0, ClickAction.Hit);
addClickActionAssert(1, ClickAction.Hit);
}
[Test]
public void TestOverlappingHitCirclesDontBlockEachOtherWhenBothVisible()
{
const double time_first_circle = 1000;
const double time_second_circle = 1200;
Vector2 positionFirstCircle = new Vector2(100);
Vector2 positionSecondCircle = new Vector2(120);
var midpoint = (positionFirstCircle + positionSecondCircle) / 2;
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle,
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle,
},
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle, Position = midpoint, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle + 25, Position = midpoint },
new OsuReplayFrame { Time = time_first_circle + 50, Position = midpoint, Actions = { OsuAction.RightButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementOffsetAssert(hitObjects[0], 0);
addJudgementAssert(hitObjects[1], HitResult.Meh);
addJudgementOffsetAssert(hitObjects[1], -150);
}
[Test]
public void TestOverlappingHitCirclesDontBlockEachOtherWhenFullyFadedOut()
{
const double time_first_circle = 1000;
const double time_second_circle = 1200;
const double time_third_circle = 1400;
Vector2 positionFirstCircle = new Vector2(100);
Vector2 positionSecondCircle = new Vector2(200);
var hitObjects = new List<OsuHitObject>
{
new HitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle,
},
new HitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle,
},
new HitCircle
{
StartTime = time_third_circle,
Position = positionFirstCircle,
},
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle + 50, Position = positionFirstCircle },
new OsuReplayFrame { Time = time_second_circle - 50, Position = positionSecondCircle },
new OsuReplayFrame { Time = time_second_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_second_circle + 50, Position = positionSecondCircle },
new OsuReplayFrame { Time = time_third_circle - 50, Position = positionFirstCircle },
new OsuReplayFrame { Time = time_third_circle, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_third_circle + 50, Position = positionFirstCircle },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementOffsetAssert(hitObjects[0], 0);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementOffsetAssert(hitObjects[1], 0);
addJudgementAssert(hitObjects[2], HitResult.Great);
addJudgementOffsetAssert(hitObjects[2], 0);
}
private void addJudgementAssert(OsuHitObject hitObject, HitResult result)
{
AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}",
() => judgementResults.Single(r => r.HitObject == hitObject).Type, () => Is.EqualTo(result));
}
private void addJudgementAssert(string name, Func<OsuHitObject?> hitObject, HitResult result)
{
AddAssert($"{name} judgement is {result}",
() => judgementResults.Single(r => r.HitObject == hitObject()).Type, () => Is.EqualTo(result));
}
private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset)
{
AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}",
() => judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, () => Is.EqualTo(offset).Within(50));
}
private void addJudgementOffsetAssert(string name, Func<OsuHitObject?> hitObject, double offset)
{
AddAssert($"{name} @ judged at {offset}",
() => judgementResults.Single(r => r.HitObject == hitObject()).TimeOffset, () => Is.EqualTo(offset).Within(50));
}
private void addClickActionAssert(int inputIndex, ClickAction action)
=> AddAssert($"input #{inputIndex} resulted in {action}", () => testPolicy.ClickActions[inputIndex], () => Is.EqualTo(action));
private ScoreAccessibleReplayPlayer currentPlayer = null!;
private List<JudgementResult> judgementResults = null!;
private TestLegacyHitPolicy testPolicy = null!;
private void performTest(List<OsuHitObject> hitObjects, List<ReplayFrame> frames, IEnumerable<Mod>? extraMods = null, [CallerMemberName] string testCaseName = "")
{
List<Mod> mods = null!;
IBeatmap playableBeatmap = null!;
Score score = null!;
AddStep("set up mods", () =>
{
mods = new List<Mod> { new OsuModClassic() };
if (extraMods != null)
mods.AddRange(extraMods);
});
AddStep("create beatmap", () =>
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
Metadata =
{
Title = testCaseName
},
HitObjects = hitObjects,
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = 0,
SliderTickRate = 3
},
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder
},
ControlPointInfo = cpi
});
playableBeatmap = Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo);
});
AddStep("create score", () =>
{
score = new Score
{
Replay = new Replay
{
Frames = new List<ReplayFrame>
{
// required for correct playback in stable
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, new Vector2(256, -500))
}.Concat(frames).ToList()
},
ScoreInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapInfo = playableBeatmap.BeatmapInfo,
Mods = mods.ToArray()
}
};
});
if (exportLocation != null)
{
AddStep("export beatmap", () =>
{
var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null);
using (var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osu"), FileMode.Create))
{
var memoryStream = new MemoryStream();
using (var writer = new StreamWriter(memoryStream, Encoding.UTF8, leaveOpen: true))
beatmapEncoder.Encode(writer);
memoryStream.Seek(0, SeekOrigin.Begin);
memoryStream.CopyTo(stream);
memoryStream.Seek(0, SeekOrigin.Begin);
playableBeatmap.BeatmapInfo.MD5Hash = memoryStream.ComputeMD5Hash();
}
});
AddStep("export score", () =>
{
using var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osr"), FileMode.Create);
var encoder = new LegacyScoreEncoder(score, playableBeatmap);
encoder.Encode(stream);
});
}
AddStep("load player", () =>
{
SelectedMods.Value = mods.ToArray();
var p = new ScoreAccessibleReplayPlayer(score);
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddStep("Substitute hit policy", () =>
{
var playfield = currentPlayer.ChildrenOfType<OsuPlayfield>().Single();
var currentPolicy = playfield.HitPolicy;
playfield.HitPolicy = testPolicy = new TestLegacyHitPolicy(currentPolicy);
});
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private class TestSpinner : Spinner
{
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
SpinsRequired = 1;
}
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
private class TestLegacyHitPolicy : LegacyHitPolicy
{
private readonly IHitPolicy currentPolicy;
public TestLegacyHitPolicy(IHitPolicy currentPolicy)
{
this.currentPolicy = currentPolicy;
}
public List<ClickAction> ClickActions { get; } = new List<ClickAction>();
public override ClickAction CheckHittable(DrawableHitObject hitObject, double time, HitResult result)
{
var action = currentPolicy.CheckHittable(hitObject, time, result);
ClickActions.Add(action);
return action;
}
}
}
}
@@ -1,493 +0,0 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene
{
private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss
private const double late_miss_window = 500; // time after +500 is considered a miss
/// <summary>
/// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleBeforeFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new TestHitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
addJudgementOffsetAssert(hitObjects[0], late_miss_window);
}
/// <summary>
/// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAtFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new TestHitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
addJudgementOffsetAssert(hitObjects[0], late_miss_window);
}
/// <summary>
/// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAfterFirstCircleTime()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new TestHitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Miss);
addJudgementOffsetAssert(hitObjects[0], late_miss_window);
}
/// <summary>
/// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged.
/// </summary>
[Test]
public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new TestHitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200
addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100
}
/// <summary>
/// Tests clicking a future circle after the first circle's start time, while the first circle HAS been judged.
/// </summary>
[Test]
public void TestClickSecondCircleAfterFirstCircleTimeWithFirstCircleJudged()
{
const double time_first_circle = 1500;
const double time_second_circle = 1600;
Vector2 positionFirstCircle = Vector2.Zero;
Vector2 positionSecondCircle = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_first_circle,
Position = positionFirstCircle
},
new TestHitCircle
{
StartTime = time_second_circle,
Position = positionSecondCircle
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200
addJudgementOffsetAssert(hitObjects[1], -100); // time_second_circle - first_circle_time
}
/// <summary>
/// Tests clicking a future circle after a slider's start time, but hitting all slider ticks.
/// </summary>
[Test]
public void TestMissSliderHeadAndHitAllSliderTicks()
{
const double time_slider = 1500;
const double time_circle = 1510;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new TestSlider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } }
});
addJudgementAssert(hitObjects[0], HitResult.Miss);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit);
addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit);
}
/// <summary>
/// Tests clicking hitting future slider ticks before a circle.
/// </summary>
[Test]
public void TestHitSliderTicksBeforeCircle()
{
const double time_slider = 1500;
const double time_circle = 1510;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(30);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new TestSlider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_circle + late_miss_window - 100, Position = positionCircle, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_circle + late_miss_window - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit);
addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit);
}
/// <summary>
/// Tests clicking a future circle before a spinner.
/// </summary>
[Test]
public void TestHitCircleBeforeSpinner()
{
const double time_spinner = 1500;
const double time_circle = 1800;
Vector2 positionCircle = Vector2.Zero;
var hitObjects = new List<OsuHitObject>
{
new TestSpinner
{
StartTime = time_spinner,
Position = new Vector2(256, 192),
EndTime = time_spinner + 1000,
},
new TestHitCircle
{
StartTime = time_circle,
Position = positionCircle
},
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
}
[Test]
public void TestHitSliderHeadBeforeHitCircle()
{
const double time_circle = 1000;
const double time_slider = 1200;
Vector2 positionCircle = Vector2.Zero;
Vector2 positionSlider = new Vector2(80);
var hitObjects = new List<OsuHitObject>
{
new TestHitCircle
{
StartTime = time_circle,
Position = positionCircle
},
new TestSlider
{
StartTime = time_slider,
Position = positionSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } },
});
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
}
private void addJudgementAssert(OsuHitObject hitObject, HitResult result)
{
AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}",
() => judgementResults.Single(r => r.HitObject == hitObject).Type, () => Is.EqualTo(result));
}
private void addJudgementAssert(string name, Func<OsuHitObject> hitObject, HitResult result)
{
AddAssert($"{name} judgement is {result}",
() => judgementResults.Single(r => r.HitObject == hitObject()).Type == result);
}
private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset)
{
AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}",
() => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100));
}
private ScoreAccessibleReplayPlayer currentPlayer;
private List<JudgementResult> judgementResults;
private void performTest(List<OsuHitObject> hitObjects, List<ReplayFrame> frames)
{
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects = hitObjects,
Difficulty = new BeatmapDifficulty { SliderTickRate = 3 },
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo
},
});
SelectedMods.Value = new[] { new OsuModClassic() };
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private class TestHitCircle : HitCircle
{
protected override HitWindows CreateHitWindows() => new TestHitWindows();
}
private class TestSlider : Slider
{
public TestSlider()
{
SliderVelocity = 0.1f;
DefaultsApplied += _ =>
{
HeadCircle.HitWindows = new TestHitWindows();
TailCircle.HitWindows = new TestHitWindows();
HeadCircle.HitWindows.SetDifficulty(0);
TailCircle.HitWindows.SetDifficulty(0);
};
}
}
private class TestSpinner : Spinner
{
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
SpinsRequired = 1;
}
}
private class TestHitWindows : HitWindows
{
private static readonly DifficultyRange[] ranges =
{
new DifficultyRange(HitResult.Great, 500, 500, 500),
new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window),
};
public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss;
protected override DifficultyRange[] GetRanges() => ranges;
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}
@@ -0,0 +1,176 @@
// 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 NUnit.Framework;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual.Gameplay;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public partial class TestSceneScoring : ScoringTestScene
{
private Bindable<double> scoreMultiplier { get; } = new BindableDouble
{
Default = 4,
Value = 4
};
protected override IBeatmap CreateBeatmap(int maxCombo)
{
var beatmap = new OsuBeatmap();
for (int i = 0; i < maxCombo; i++)
beatmap.HitObjects.Add(new HitCircle());
return beatmap;
}
protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new OsuProcessorBasedScoringAlgorithm(beatmap, mode);
[Test]
public void TestBasicScenarios()
{
AddStep("set up score multiplier", () =>
{
scoreMultiplier.BindValueChanged(_ => Rerun());
});
AddStep("set max combo to 100", () => MaxCombo.Value = 100);
AddStep("set perfect score", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
});
AddStep("set score with misses", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
MissLocations.AddRange(new[] { 24d, 49 });
});
AddStep("set score with misses and OKs", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
MissLocations.AddRange(new[] { 24d, 49 });
});
AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
}
private const int base_great = 300;
private const int base_ok = 100;
private class ScoreV1 : IScoringAlgorithm
{
private int currentCombo;
public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
public void ApplyHit() => applyHitV1(base_great);
public void ApplyNonPerfect() => applyHitV1(base_ok);
public void ApplyMiss() => applyHitV1(0);
private void applyHitV1(int baseScore)
{
if (baseScore == 0)
{
currentCombo = 0;
return;
}
TotalScore += baseScore;
// combo multiplier
// ReSharper disable once PossibleLossOfFraction
TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * ScoreMultiplier.Value));
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private double currentBaseScore;
private double maxBaseScore;
private int currentHits;
private readonly double comboPortionMax;
private readonly int maxCombo;
public ScoreV2(int maxCombo)
{
this.maxCombo = maxCombo;
for (int i = 0; i < this.maxCombo; i++)
ApplyHit();
comboPortionMax = comboPortion;
currentCombo = 0;
comboPortion = 0;
currentBaseScore = 0;
maxBaseScore = 0;
currentHits = 0;
}
public void ApplyHit() => applyHitV2(base_great);
public void ApplyNonPerfect() => applyHitV2(base_ok);
private void applyHitV2(int baseScore)
{
maxBaseScore += base_great;
currentBaseScore += baseScore;
comboPortion += baseScore * (1 + ++currentCombo / 10.0);
currentHits++;
}
public void ApplyMiss()
{
currentHits++;
maxBaseScore += base_great;
currentCombo = 0;
}
public long TotalScore
{
get
{
double accuracy = currentBaseScore / maxBaseScore;
return (int)Math.Round
(
700000 * comboPortion / comboPortionMax +
300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo)
);
}
}
}
private class OsuProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public OsuProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
: base(beatmap, mode)
{
}
protected override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor();
protected override JudgementResult CreatePerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great };
protected override JudgementResult CreateNonPerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok };
protected override JudgementResult CreateMissJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss };
}
}
}
+38 -20
View File
@@ -3,6 +3,7 @@
#nullable disable
using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Game.Audio;
@@ -19,7 +20,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Judgements;
@@ -35,16 +35,24 @@ namespace osu.Game.Rulesets.Osu.Tests
{
private int depthIndex;
private readonly BindableBool snakingIn = new BindableBool();
private readonly BindableBool snakingOut = new BindableBool();
private readonly BindableBool snakingIn = new BindableBool(true);
private readonly BindableBool snakingOut = new BindableBool(true);
[SetUpSteps]
public void SetUpSteps()
private float progressToHit;
protected override void LoadComplete()
{
AddToggleStep("toggle snaking", v =>
base.LoadComplete();
AddToggleStep("disable snaking", v =>
{
snakingIn.Value = v;
snakingOut.Value = v;
snakingIn.Value = !v;
snakingOut.Value = !v;
});
AddSliderStep("hit at", 0f, 1f, 0f, v =>
{
progressToHit = v;
});
}
@@ -56,6 +64,18 @@ namespace osu.Game.Rulesets.Osu.Tests
config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut);
}
protected override void Update()
{
base.Update();
foreach (var slider in this.ChildrenOfType<DrawableSlider>())
{
double completionProgress = Math.Clamp((Time.Current - slider.HitObject.StartTime) / slider.HitObject.Duration, 0, 1);
if (completionProgress > progressToHit && !slider.IsHit)
slider.HeadCircle.HitArea.Hit();
}
}
[Test]
public void TestVariousSliders()
{
@@ -206,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Tests
StackHeight = 10
};
return createDrawable(slider, 2, 2);
return createDrawable(slider, 2);
}
private Drawable testSimpleMedium(int repeats = 0) => createSlider(5, repeats: repeats);
@@ -229,6 +249,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
var slider = new Slider
{
SliderVelocityMultiplier = speedMultiplier,
StartTime = Time.Current + time_offset,
Position = new Vector2(0, -(distance / 2)),
Path = new SliderPath(PathType.PerfectCurve, new[]
@@ -240,7 +261,7 @@ namespace osu.Game.Rulesets.Osu.Tests
StackHeight = stackHeight
};
return createDrawable(slider, circleSize, speedMultiplier);
return createDrawable(slider, circleSize);
}
private Drawable testPerfect(int repeats = 0)
@@ -258,7 +279,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
return createDrawable(slider, 2, 3);
return createDrawable(slider, 2);
}
private Drawable testLinear(int repeats = 0) => createLinear(repeats);
@@ -281,7 +302,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
return createDrawable(slider, 2, 3);
return createDrawable(slider, 2);
}
private Drawable testBezier(int repeats = 0) => createBezier(repeats);
@@ -303,7 +324,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
return createDrawable(slider, 2, 3);
return createDrawable(slider, 2);
}
private Drawable testLinearOverlapping(int repeats = 0) => createOverlapping(repeats);
@@ -326,7 +347,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
return createDrawable(slider, 2, 3);
return createDrawable(slider, 2);
}
private Drawable testCatmull(int repeats = 0) => createCatmull(repeats);
@@ -352,15 +373,12 @@ namespace osu.Game.Rulesets.Osu.Tests
NodeSamples = repeatSamples
};
return createDrawable(slider, 3, 1);
return createDrawable(slider, 3);
}
private Drawable createDrawable(Slider slider, float circleSize, double speedMultiplier)
private Drawable createDrawable(Slider slider, float circleSize)
{
var cpi = new LegacyControlPointInfo();
cpi.Add(0, new DifficultyControlPoint { SliderVelocity = speedMultiplier });
slider.ApplyDefaults(cpi, new BeatmapDifficulty
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty
{
CircleSize = circleSize,
SliderTickRate = 3
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocity = velocity,
SliderVelocityMultiplier = velocity,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
@@ -349,7 +349,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocity = 0.1f,
SliderVelocityMultiplier = 0.1f,
Path = new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
@@ -1,8 +1,6 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public partial class TestSceneSliderSnaking : TestSceneOsuPlayer
{
[Resolved]
private AudioManager audioManager { get; set; }
private AudioManager audioManager { get; set; } = null!;
protected override bool Autoplay => autoplay;
private bool autoplay;
@@ -41,12 +39,12 @@ namespace osu.Game.Rulesets.Osu.Tests
private readonly BindableBool snakingIn = new BindableBool();
private readonly BindableBool snakingOut = new BindableBool();
private IBeatmap beatmap;
private IBeatmap beatmap = null!;
private const double duration_of_span = 3605;
private const double fade_in_modifier = -1200;
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null)
=> new ClockBackedTestWorkingBeatmap(this.beatmap = beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
[BackgroundDependencyLoader]
@@ -57,15 +55,8 @@ namespace osu.Game.Rulesets.Osu.Tests
config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut);
}
private Slider slider;
private DrawableSlider drawableSlider;
[SetUp]
public void Setup() => Schedule(() =>
{
slider = null;
drawableSlider = null;
});
private Slider slider = null!;
private DrawableSlider? drawableSlider;
protected override bool HasCustomSteps => true;
@@ -135,9 +126,9 @@ namespace osu.Game.Rulesets.Osu.Tests
}
[Test]
public void TestRepeatArrowDoesNotMoveWhenHit()
public void TestRepeatArrowDoesNotMove([Values] bool useAutoplay)
{
AddStep("enable autoplay", () => autoplay = true);
AddStep($"set autoplay to {useAutoplay}", () => autoplay = useAutoplay);
setSnaking(true);
CreateTest();
// repeat might have a chance to update its position depending on where in the frame its hit,
@@ -145,21 +136,12 @@ namespace osu.Game.Rulesets.Osu.Tests
addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionAlmostSame);
}
[Test]
public void TestRepeatArrowMovesWhenNotHit()
{
AddStep("disable autoplay", () => autoplay = false);
setSnaking(true);
CreateTest();
addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionDecreased);
}
private void retrieveSlider(int index)
{
AddStep("retrieve slider at index", () => slider = (Slider)beatmap.HitObjects[index]);
addSeekStep(() => slider.StartTime);
AddUntilStep("retrieve drawable slider", () =>
(drawableSlider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null);
(drawableSlider = (DrawableSlider?)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null);
}
private void addEnsureSnakingInSteps(Func<double> startTime) => addCheckPositionChangeSteps(startTime, getSliderEnd, positionIncreased);
@@ -179,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private Func<double> timeAtRepeat(Func<double> startTime, int repeatIndex) => () => startTime() + 100 + duration_of_span * repeatIndex;
private Func<Vector2> positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? getSliderStart : getSliderEnd;
private List<Vector2> getSliderCurve() => ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve;
private List<Vector2> getSliderCurve() => ((PlaySliderBody)drawableSlider!.Body.Drawable).CurrentCurve;
private Vector2 getSliderStart() => getSliderCurve().First();
private Vector2 getSliderEnd() => getSliderCurve().Last();
@@ -43,7 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests
AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8);
AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8);
PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
PausableSkinnableSound getSpinningSample() =>
drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
}
[TestCase(false)]
@@ -64,6 +65,39 @@ namespace osu.Game.Rulesets.Osu.Tests
AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1);
}
[TestCase(0, 4, 6)]
[TestCase(5, 7, 10)]
[TestCase(10, 11, 8)]
public void TestSpinnerSpinRequirements(int od, int normalTicks, int bonusTicks)
{
Spinner spinner = null;
AddStep("add spinner", () => SetContents(_ =>
{
spinner = new Spinner
{
StartTime = Time.Current,
EndTime = Time.Current + 3000,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL)
}
};
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
return drawableSpinner = new TestDrawableSpinner(spinner, true)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
Scale = new Vector2(0.75f)
};
}));
AddAssert("number of normal ticks matches", () => spinner.SpinsRequired, () => Is.EqualTo(normalTicks));
AddAssert("number of bonus ticks matches", () => spinner.MaximumBonusSpins, () => Is.EqualTo(bonusTicks));
}
private Drawable testSingle(float circleSize, bool auto = false, double length = 3000)
{
const double delay = 2000;
@@ -0,0 +1,147 @@
// 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.Extensions.TypeExtensions;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneSpinnerJudgement : RateAdjustedBeatmapTestScene
{
private const double time_spinner_start = 2000;
private const double time_spinner_end = 4000;
private List<JudgementResult> judgementResults = new List<JudgementResult>();
private ScoreAccessibleReplayPlayer currentPlayer = null!;
[Test]
public void TestHitNothing()
{
performTest(new List<ReplayFrame>());
AddAssert("all min judgements", () => judgementResults.All(result => result.Type == result.Judgement.MinResult));
}
[TestCase(1)]
[TestCase(2)]
[TestCase(5)]
public void TestNumberOfSpins(int spins)
{
performTest(generateReplay(spins));
for (int i = 0; i < spins; ++i)
assertResult<SpinnerTick>(i, HitResult.SmallBonus);
assertResult<SpinnerTick>(spins, HitResult.IgnoreMiss);
}
[Test]
public void TestHitEverything()
{
performTest(generateReplay(20));
AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult));
}
private static List<ReplayFrame> generateReplay(int spins)
{
var replayFrames = new List<ReplayFrame>();
const int frames_per_spin = 30;
for (int i = 0; i < spins * frames_per_spin; ++i)
{
float totalProgress = i / (float)(spins * frames_per_spin);
float spinProgress = (i % frames_per_spin) / (float)frames_per_spin;
double time = time_spinner_start + (time_spinner_end - time_spinner_start) * totalProgress;
float posX = MathF.Cos(2 * MathF.PI * spinProgress);
float posY = MathF.Sin(2 * MathF.PI * spinProgress);
Vector2 finalPos = OsuPlayfield.BASE_SIZE / 2 + new Vector2(posX, posY) * 50;
replayFrames.Add(new OsuReplayFrame(time, finalPos, OsuAction.LeftButton));
}
return replayFrames;
}
private void performTest(List<ReplayFrame> frames)
{
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Spinner
{
StartTime = time_spinner_start,
EndTime = time_spinner_end,
Position = OsuPlayfield.BASE_SIZE / 2
}
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty(),
Ruleset = new OsuRuleset().RulesetInfo
},
});
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private void assertResult<T>(int index, HitResult expectedResult)
{
AddAssert($"{typeof(T).ReadableName()} ({index}) judged as {expectedResult}",
() => judgementResults.Where(j => j.HitObject is T).OrderBy(j => j.HitObject.StartTime).ElementAt(index).Type,
() => Is.EqualTo(expectedResult));
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}
@@ -336,6 +336,52 @@ namespace osu.Game.Rulesets.Osu.Tests
addJudgementAssert(hitObjects[1], HitResult.IgnoreHit);
}
[Test]
public void TestInputFallsThroughJudgedSliders()
{
const double time_first_slider = 1000;
const double time_second_slider = 1250;
Vector2 positionFirstSlider = new Vector2(100, 50);
Vector2 positionSecondSlider = new Vector2(100, 80);
var midpoint = (positionFirstSlider + positionSecondSlider) / 2;
var hitObjects = new List<OsuHitObject>
{
new TestSlider
{
StartTime = time_first_slider,
Position = positionFirstSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
},
new TestSlider
{
StartTime = time_second_slider,
Position = positionSecondSlider,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(25, 0),
})
}
};
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint },
});
addJudgementAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, HitResult.Great);
addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0);
addJudgementAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Great);
addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, -200);
}
private void addJudgementAssert(OsuHitObject hitObject, HitResult result)
{
AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}",
@@ -354,6 +400,12 @@ namespace osu.Game.Rulesets.Osu.Tests
() => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100));
}
private void addJudgementOffsetAssert(string name, Func<OsuHitObject> hitObject, double offset)
{
AddAssert($"{name} @ judged at {offset}",
() => judgementResults.Single(r => r.HitObject == hitObject()).TimeOffset, () => Is.EqualTo(offset).Within(50));
}
private ScoreAccessibleReplayPlayer currentPlayer;
private List<JudgementResult> judgementResults;
@@ -399,7 +451,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
public TestSlider()
{
SliderVelocity = 0.1f;
SliderVelocityMultiplier = 0.1f;
DefaultsApplied += _ =>
{
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -44,12 +44,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
Position = positionData?.Position ?? Vector2.Zero,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset,
// prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
// this results in more (or less) ticks being generated in <v8 maps for the same time duration.
TickDistanceMultiplier = beatmap.BeatmapInfo.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(original.StartTime).SliderVelocity : 1,
GenerateTicks = generateTicksData?.GenerateTicks ?? true,
SliderVelocity = sliderVelocityData?.SliderVelocity ?? 1,
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1,
}.Yield();
case IHasDuration endTimeData:
@@ -26,12 +26,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -109,15 +106,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpinnerCount = spinnerCount,
};
if (ComputeLegacyScoringValues)
{
OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -2,37 +2,27 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Osu.Difficulty
{
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double scoreMultiplier;
private IBeatmap playableBeatmap = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
this.playableBeatmap = playableBeatmap;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
@@ -73,13 +63,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
scoreMultiplier = difficultyPeppyStars;
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -122,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
case Slider:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
scoreIncrease = 300;
increaseCombo = false;
@@ -133,22 +129,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
// As we're primarily concerned with computing the maximum theoretical final score,
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
const double minimum_rotations_per_second = 3;
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
for (int i = 0; i <= totalHalfSpinsPossible; i++)
{
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
simulateHit(new SpinnerBonusTick());
simulateHit(new SpinnerBonusTick(), ref attributes);
else if (i > 1 && i % 2 == 0)
simulateHit(new SpinnerTick());
simulateHit(new SpinnerTick(), ref attributes);
}
scoreIncrease = 300;
@@ -159,16 +160,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
InternalChild = content = new Container
{
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
CornerRadius = Size.X / 2;
CornerExponent = 2;
@@ -22,7 +22,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
protected override bool AlwaysShowWhenSelected => true;
protected override bool ShouldBeAlive => base.ShouldBeAlive
|| (DrawableObject is not DrawableSpinner && ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime && editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION);
|| (DrawableObject is not DrawableSpinner && ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime
&& editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION);
public override bool IsSelectable =>
// Bypass fade out extension from hit markers for selection purposes.
// This is to match stable, where even when the afterimage hit markers are still visible, objects are not selectable.
base.ShouldBeAlive;
protected OsuSelectionBlueprint(T hitObject)
: base(hitObject)
@@ -85,9 +85,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
BeginPlacement();
double? nearestSliderVelocity = (editorBeatmap.HitObjects
.LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocity;
.LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocityMultiplier;
HitObject.SliderVelocity = nearestSliderVelocity ?? 1;
HitObject.SliderVelocityMultiplier = nearestSliderVelocity ?? 1;
HitObject.Position = ToLocalSpace(result.ScreenSpacePosition);
// Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation.
@@ -315,7 +315,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
StartTime = HitObject.StartTime,
Position = HitObject.Position + splitControlPoints[0].Position,
NewCombo = HitObject.NewCombo,
LegacyLastTickOffset = HitObject.LegacyLastTickOffset,
Samples = HitObject.Samples.Select(s => s.With()).ToList(),
RepeatCount = HitObject.RepeatCount,
NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(),
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new DrawableOsuEditorRuleset(ruleset, beatmap, mods);
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
@@ -62,12 +62,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void load()
{
// Give a bit of breathing room around the playfield content.
PlayfieldContentContainer.Padding = new MarginPadding
{
Vertical = 10,
Left = TOOLBOX_CONTRACTED_SIZE_LEFT + 10,
Right = TOOLBOX_CONTRACTED_SIZE_RIGHT + 10,
};
PlayfieldContentContainer.Padding = new MarginPadding(10);
LayerBelowRuleset.AddRange(new Drawable[]
{
@@ -90,6 +85,11 @@ namespace osu.Game.Rulesets.Osu.Edit
// we may be entering the screen with a selection already active
updateDistanceSnapGrid();
RightToolbox.Add(new TransformToolboxGroup
{
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler
});
}
protected override ComposeBlueprintContainer CreateBlueprintContainer()
@@ -17,6 +17,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Utils;
using osuTK;
using osuTK.Input;
@@ -27,11 +28,6 @@ namespace osu.Game.Rulesets.Osu.Edit
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider? snapProvider { get; set; }
/// <summary>
/// During a transform, the initial origin is stored so it can be used throughout the operation.
/// </summary>
private Vector2? referenceOrigin;
/// <summary>
/// During a transform, the initial path types of a single selected slider are stored so they
/// can be maintained throughout the operation.
@@ -42,9 +38,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
base.OnSelectionChanged();
Quad quad = selectedMovableObjects.Length > 0 ? getSurroundingQuad(selectedMovableObjects) : new Quad();
Quad quad = selectedMovableObjects.Length > 0 ? GeometryUtils.GetSurroundingQuad(selectedMovableObjects) : new Quad();
SelectionBox.CanRotate = quad.Width > 0 || quad.Height > 0;
SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0;
SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0;
SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider);
@@ -53,7 +48,6 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override void OnOperationEnded()
{
base.OnOperationEnded();
referenceOrigin = null;
referencePathTypes = null;
}
@@ -109,13 +103,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : getSurroundingQuad(hitObjects);
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects);
bool didFlip = false;
foreach (var h in hitObjects)
{
var flippedPosition = GetFlippedPosition(direction, flipQuad, h.Position);
var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position);
if (!Precision.AlmostEquals(flippedPosition, h.Position))
{
@@ -169,34 +163,13 @@ namespace osu.Game.Rulesets.Osu.Edit
if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y;
}
public override bool HandleRotation(float delta)
{
var hitObjects = selectedMovableObjects;
Quad quad = getSurroundingQuad(hitObjects);
referenceOrigin ??= quad.Centre;
foreach (var h in hitObjects)
{
h.Position = RotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta);
if (h is IHasPath path)
{
foreach (PathControlPoint cp in path.Path.ControlPoints)
cp.Position = RotatePointAroundOrigin(cp.Position, Vector2.Zero, delta);
}
}
// this isn't always the case but let's be lenient for now.
return true;
}
public override SelectionRotationHandler CreateRotationHandler() => new OsuSelectionRotationHandler();
private void scaleSlider(Slider slider, Vector2 scale)
{
referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type).ToList();
Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position));
Quad sliderQuad = GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position));
// Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size;
@@ -222,7 +195,7 @@ namespace osu.Game.Rulesets.Osu.Edit
slider.SnapTo(snapProvider);
//if sliderhead or sliderend end up outside playfield, revert scaling.
Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider });
Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
if (xInBounds && yInBounds && slider.Path.HasValidLength)
@@ -238,10 +211,10 @@ namespace osu.Game.Rulesets.Osu.Edit
private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
{
scale = getClampedScale(hitObjects, reference, scale);
Quad selectionQuad = getSurroundingQuad(hitObjects);
Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
foreach (var h in hitObjects)
h.Position = GetScaledPosition(reference, scale, selectionQuad, h.Position);
h.Position = GeometryUtils.GetScaledPosition(reference, scale, selectionQuad, h.Position);
}
private (bool X, bool Y) isQuadInBounds(Quad quad)
@@ -256,7 +229,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
Quad quad = getSurroundingQuad(hitObjects);
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 delta = Vector2.Zero;
@@ -286,7 +259,7 @@ namespace osu.Game.Rulesets.Osu.Edit
float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0;
float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0;
Quad selectionQuad = getSurroundingQuad(hitObjects);
Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
Quad scaledQuad = new Quad(selectionQuad.TopLeft.X + xOffset, selectionQuad.TopLeft.Y + yOffset, selectionQuad.Width + scale.X, selectionQuad.Height + scale.Y);
@@ -311,26 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit
return scale;
}
/// <summary>
/// Returns a gamefield-space quad surrounding the provided hit objects.
/// </summary>
/// <param name="hitObjects">The hit objects to calculate a quad for.</param>
private Quad getSurroundingQuad(OsuHitObject[] hitObjects) =>
GetSurroundingQuad(hitObjects.SelectMany(h =>
{
if (h is IHasPath path)
{
return new[]
{
h.Position,
// can't use EndPosition for reverse slider cases.
h.Position + path.Path.PositionAt(1)
};
}
return new[] { h.Position };
}));
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>

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