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

Compare commits

...

2002 Commits

943 changed files with 41949 additions and 12484 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
generator:
name: Run
runs-on: self-hosted
timeout-minutes: 720
timeout-minutes: 1440
outputs:
target: ${{ steps.run.outputs.target }}
+13 -3
View File
@@ -82,8 +82,18 @@ jobs:
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
- name: Test
run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0
shell: pwsh
run: >
dotnet test
osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
--logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
--
NUnit.ConsoleOut=0
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
@@ -136,4 +146,4 @@ jobs:
run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
- name: Build
run: dotnet build -c Debug osu.iOS
run: dotnet build -c Debug osu.iOS.slnf
+87
View File
@@ -0,0 +1,87 @@
name: Pack and nuget
on:
push:
tags:
- '*'
jobs:
notify_pending_production_deploy:
runs-on: ubuntu-latest
steps:
- name: Submit pending deployment notification
run: |
export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME"
export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID"
export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME:
[View Workflow Run]($URL)"
export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID"
BODY="$(jq --null-input '{
"embeds": [
{
"title": env.TITLE,
"color": 15098112,
"description": env.DESCRIPTION,
"url": env.URL,
"author": {
"name": env.GITHUB_ACTOR,
"icon_url": env.ACTOR_ICON
}
}
]
}')"
curl \
-H "Content-Type: application/json" \
-d "$BODY" \
"${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}"
pack:
name: Pack
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set artifacts directory
id: artifactsPath
run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts"
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Pack
run: |
# Replace project references in templates with package reference, because they're included as source files.
dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
# Pack
dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: osu
path: |
${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg
${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg
- name: Publish packages to nuget.org
run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
+7
View File
@@ -18,3 +18,10 @@ M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize(
M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead.
M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead.
M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead.
M:osuTK.MathHelper.Clamp(System.Int32,System.Int32,System.Int32)~System.Int32;Use Math.Clamp() instead.
M:osuTK.MathHelper.Clamp(System.Single,System.Single,System.Single)~System.Single;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead.
M:osuTK.MathHelper.Clamp(System.Double,System.Double,System.Double)~System.Double;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead.
M:TagLib.File.Create(System.String);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
M:TagLib.File.Create(TagLib.File.IFileAbstraction);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
M:TagLib.File.Create(System.String,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
M:TagLib.File.Create(TagLib.File.IFileAbstraction,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
+4
View File
@@ -3,6 +3,10 @@
<PropertyGroup Label="C#">
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<!-- Stabilises hot reload, see: https://platform.uno/docs/articles/studio/Hot%20Reload/hot-reload-overview.html?tabs=vswin%2Cwindows%2Cskia-desktop%2Ccommon-issues -->
<GenerateAssemblyInfo Condition="'$(Configuration)'=='Debug'">false</GenerateAssemblyInfo>
<!-- Required due to the above -->
<NoWarn Condition="'$(Configuration)'=='Debug'">$(NoWarn);CA1416</NoWarn>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>$(MSBuildThisFileDirectory)app.manifest</ApplicationManifest>
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
using osuTK;
@@ -17,5 +18,8 @@ namespace osu.Game.Rulesets.EmptyFreeform.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is EmptyFreeformReplayFrame freeformFrame && Time == freeformFrame.Time && Position == freeformFrame.Position && Actions.SequenceEqual(freeformFrame.Actions);
}
}
@@ -9,5 +9,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
public class PippidonReplayFrame : ReplayFrame
{
public Vector2 Position;
public override bool IsEquivalentTo(ReplayFrame other)
=> other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Position == pippidonFrame.Position;
}
}
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.EmptyScrolling.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.EmptyScrolling.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is EmptyScrollingReplayFrame scrollingFrame && Time == scrollingFrame.Time && Actions.SequenceEqual(scrollingFrame.Actions);
}
}
@@ -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 System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -9,7 +10,6 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Pippidon.Objects;
using osu.Game.Rulesets.Pippidon.UI;
using osuTK;
namespace osu.Game.Rulesets.Pippidon.Beatmaps
{
@@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps
};
}
private int getLane(HitObject hitObject) => (int)MathHelper.Clamp(
private int getLane(HitObject hitObject) => (int)Math.Clamp(
(getUsablePosition(hitObject) - minPosition) / (maxPosition - minPosition) * PippidonPlayfield.LANE_COUNT, 0, PippidonPlayfield.LANE_COUNT - 1);
private float getUsablePosition(HitObject h) => (h as IHasYPosition)?.Y ?? ((IHasXPosition)h).X;
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.Pippidon.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Actions.SequenceEqual(pippidonFrame.Actions);
}
}
-32
View File
@@ -1,32 +0,0 @@
clone_depth: 1
version: '{branch}-{build}'
image: Visual Studio 2022
cache:
- '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
dotnet_csproj:
patch: true
file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects
version: '0.0.{build}'
before_build:
- cmd: dotnet --info # Useful when version mismatch between CI and local
- cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects
- cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects
- cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
build:
project: osu.sln
parallel: true
verbosity: minimal
publish_nuget: true
after_build:
- ps: .\InspectCode.ps1
test:
assemblies:
except:
- '**\*Android*'
- '**\*iOS*'
- 'build\**\*'
-86
View File
@@ -1,86 +0,0 @@
clone_depth: 1
version: '{build}'
image: Visual Studio 2022
test: off
skip_non_tags: true
configuration: Release
environment:
matrix:
- job_name: osu-game
- job_name: osu-ruleset
job_depends_on: osu-game
- job_name: taiko-ruleset
job_depends_on: osu-game
- job_name: catch-ruleset
job_depends_on: osu-game
- job_name: mania-ruleset
job_depends_on: osu-game
- job_name: templates
job_depends_on: osu-game
nuget:
project_feed: true
for:
-
matrix:
only:
- job_name: osu-game
build_script:
- cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: osu-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: taiko-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: catch-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: mania-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: templates
build_script:
- cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
artifacts:
- path: '**\*.nupkg'
deploy:
- provider: Environment
name: nuget
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.220.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.604.1" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+1 -1
View File
@@ -21,7 +21,7 @@ namespace osu.Android
[Cached]
private readonly OsuGameActivity gameActivity;
protected override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth);
public override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth);
public OsuGameAndroid(OsuGameActivity activity)
: base(null)
+11
View File
@@ -17,6 +17,7 @@ using osu.Framework.Logging;
using osu.Game.Updater;
using osu.Desktop.Windows;
using osu.Framework.Allocation;
using osu.Game.Configuration;
using osu.Game.IO;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
@@ -33,6 +34,8 @@ namespace osu.Desktop
[Cached(typeof(IHighPerformanceSessionManager))]
private readonly HighPerformanceSessionManager highPerformanceSessionManager = new HighPerformanceSessionManager();
public bool IsFirstRun { get; init; }
public OsuGameDesktop(string[]? args = null)
: base(args)
{
@@ -104,6 +107,14 @@ namespace osu.Desktop
protected override UpdateManager CreateUpdateManager()
{
// If this is the first time we've run the game, ie it is being installed,
// reset the user's release stream to "lazer".
//
// This ensures that if a user is trying to recover from a failed startup on an unstable release stream,
// the game doesn't immediately try and update them back to the release stream after starting up.
if (IsFirstRun)
LocalConfig.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer);
if (IsPackageManaged)
return new NoActionUpdateManager();
+10 -1
View File
@@ -28,6 +28,8 @@ namespace osu.Desktop
private static LegacyTcpIpcProvider? legacyIpc;
private static bool isFirstRun;
[STAThread]
public static void Main(string[] args)
{
@@ -135,7 +137,12 @@ namespace osu.Desktop
if (tournamentClient)
host.Run(new TournamentGame());
else
host.Run(new OsuGameDesktop(args));
{
host.Run(new OsuGameDesktop(args)
{
IsFirstRun = isFirstRun
});
}
}
}
@@ -177,6 +184,8 @@ namespace osu.Desktop
var app = VelopackApp.Build();
app.WithFirstRun(_ => isFirstRun = true);
if (OperatingSystem.IsWindows())
configureWindows(app);
+30 -9
View File
@@ -4,8 +4,10 @@
using System;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Game;
using osu.Game.Configuration;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Screens.Play;
@@ -16,8 +18,8 @@ namespace osu.Desktop.Updater
{
public partial class VelopackUpdateManager : Game.Updater.UpdateManager
{
private readonly UpdateManager updateManager;
private INotificationOverlay notificationOverlay = null!;
[Resolved]
private INotificationOverlay notificationOverlay { get; set; } = null!;
[Resolved]
private OsuGameBase game { get; set; } = null!;
@@ -25,22 +27,32 @@ namespace osu.Desktop.Updater
[Resolved]
private ILocalUserPlayInfo? localUserInfo { get; set; }
[Resolved]
private OsuConfigManager osuConfigManager { get; set; } = null!;
private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying;
private readonly Bindable<ReleaseStream> releaseStream = new Bindable<ReleaseStream>();
private UpdateManager? updateManager;
private UpdateInfo? pendingUpdate;
public VelopackUpdateManager()
protected override void LoadComplete()
{
updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, false), new UpdateOptions
// Used by the base implementation.
osuConfigManager.BindWith(OsuSetting.ReleaseStream, releaseStream);
releaseStream.BindValueChanged(_ => onReleaseStreamChanged(), true);
base.LoadComplete();
}
private void onReleaseStreamChanged()
{
updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, releaseStream.Value == ReleaseStream.Tachyon), new UpdateOptions
{
AllowVersionDowngrade = true,
});
}
[BackgroundDependencyLoader]
private void load(INotificationOverlay notifications)
{
notificationOverlay = notifications;
Schedule(() => Task.Run(CheckForUpdateAsync));
}
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
@@ -76,6 +88,12 @@ namespace osu.Desktop.Updater
return true;
}
if (updateManager == null)
{
scheduleRecheck = true;
return false;
}
pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false);
// No update is available. We'll check again later.
@@ -141,6 +159,9 @@ namespace osu.Desktop.Updater
private async Task restartToApplyUpdate()
{
if (updateManager == null)
return;
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
}
@@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public abstract partial class CatchPlacementBlueprintTestScene : PlacementBlueprintTestScene
{
protected sealed override Ruleset CreateRuleset() => new CatchRuleset();
protected const double TIME_SNAP = 100;
protected DrawableCatchHitObject LastObject;
@@ -0,0 +1,75 @@
// 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.Allocation;
using osu.Framework.Audio;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Replays;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Storyboards;
using osu.Game.Tests.Visual;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Tests
{
public partial class TestSceneReplayRecording : PlayerTestScene
{
protected override Ruleset CreatePlayerRuleset() => new CatchRuleset();
[Resolved]
private AudioManager audioManager { get; set; } = null!;
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap
{
HitObjects =
{
new Fruit { StartTime = 0, },
new Fruit { StartTime = 5000, },
new Fruit { StartTime = 10000, },
new Fruit { StartTime = 15000, }
}
};
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) =>
new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
[Test]
public void TestRecording()
{
seekTo(0);
AddStep("start moving left", () => InputManager.PressKey(Key.Left));
seekTo(5000);
AddStep("end moving left", () => InputManager.ReleaseKey(Key.Left));
AddAssert("catcher max left", () => this.ChildrenOfType<Catcher>().Single().X, () => Is.EqualTo(0));
AddAssert("movement to left recorded to replay", () => Player.Score.Replay.Frames.OfType<CatchReplayFrame>().Any(f => f.Actions.SequenceEqual([CatchAction.MoveLeft])));
AddAssert("replay reached left edge", () => Player.Score.Replay.Frames.OfType<CatchReplayFrame>().Any(f => Precision.AlmostEquals(f.Position, 0)));
AddStep("start dashing right", () =>
{
InputManager.PressKey(Key.LShift);
InputManager.PressKey(Key.Right);
});
seekTo(10000);
AddStep("end dashing right", () =>
{
InputManager.ReleaseKey(Key.LShift);
InputManager.ReleaseKey(Key.Right);
});
AddAssert("catcher max right", () => this.ChildrenOfType<Catcher>().Single().X, () => Is.EqualTo(CatchPlayfield.WIDTH));
AddAssert("dash to right recorded to replay", () => Player.Score.Replay.Frames.OfType<CatchReplayFrame>().Any(f => f.Actions.SequenceEqual([CatchAction.Dash, CatchAction.MoveRight])));
AddAssert("replay reached right edge", () => Player.Score.Replay.Frames.OfType<CatchReplayFrame>().Any(f => Precision.AlmostEquals(f.Position, CatchPlayfield.WIDTH)));
}
private void seekTo(double time)
{
AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time));
AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500));
}
}
}
@@ -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 System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
@@ -16,26 +17,30 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
int fruits = HitObjects.Count(s => s is Fruit);
int juiceStreams = HitObjects.Count(s => s is JuiceStream);
int bananaShowers = HitObjects.Count(s => s is BananaShower);
int sum = Math.Max(1, fruits + juiceStreams);
return new[]
{
new BeatmapStatistic
{
Name = @"Fruit Count",
Name = @"Fruits",
Content = fruits.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles),
BarDisplayLength = fruits / (float)sum,
},
new BeatmapStatistic
{
Name = @"Juice Stream Count",
Name = @"Juice Streams",
Content = juiceStreams.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders),
BarDisplayLength = juiceStreams / (float)sum,
},
new BeatmapStatistic
{
Name = @"Banana Shower Count",
Name = @"Banana Showers",
Content = bananaShowers.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners),
BarDisplayLength = Math.Min(bananaShowers / 10f, 1),
}
};
}
@@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
// 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(obj.StartTime).SliderVelocity : 1,
TickDistanceMultiplier = beatmap.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(obj.StartTime).SliderVelocity : 1,
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield();
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
@@ -10,15 +9,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyAttributes : DifficultyAttributes
{
/// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
foreach (var v in base.ToDatabaseAttributes())
@@ -26,7 +16,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Todo: osu!catch should not output star rating in the 'aim' attribute.
yield return (ATTRIB_ID_AIM, StarRating);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -34,7 +23,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
base.FromDatabaseAttributes(values, onlineInfo);
StarRating = values[ATTRIB_ID_AIM];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
@@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
private float halfCatcherWidth;
public override int Version => 20220701;
public override int Version => 20250306;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@@ -35,14 +36,10 @@ namespace osu.Game.Rulesets.Catch.Difficulty
if (beatmap.HitObjects.Count == 0)
return new CatchDifficultyAttributes { Mods = mods };
// this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier,
StarRating = Math.Sqrt(skills.OfType<Movement>().Single().DifficultyValue()) * difficulty_multiplier,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.GetMaxCombo(),
};
@@ -3,6 +3,9 @@
using System;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
@@ -50,7 +53,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
if (catchAttributes.MaxCombo > 0)
value *= Math.Min(Math.Pow(score.MaxCombo, 0.8) / Math.Pow(catchAttributes.MaxCombo, 0.8), 1.0);
double approachRate = catchAttributes.ApproachRate;
var difficulty = score.BeatmapInfo!.Difficulty.Clone();
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
var track = new TrackVirtual(10000);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
double clockRate = track.Rate;
// this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double approachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0;
double approachRateFactor = 1.0;
if (approachRate > 9.0)
approachRateFactor += 0.1 * (approachRate - 9.0); // 10% for each AR above 9
@@ -26,7 +26,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
private float? lastPlayerPosition;
private float lastDistanceMoved;
private float lastExactDistanceMoved;
private double lastStrainTime;
private bool isInBuzzSection;
/// <summary>
/// The speed multiplier applied to the player's catcher.
@@ -59,6 +61,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
float distanceMoved = playerPosition - lastPlayerPosition.Value;
// For the exact position we consider that the catcher is in the correct position for both objects
float exactDistanceMoved = catchCurrent.NormalizedPosition - lastPlayerPosition.Value;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510);
@@ -92,12 +97,30 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
playerPosition = catchCurrent.NormalizedPosition;
}
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius)
if (Math.Abs(exactDistanceMoved) <= HalfCatcherWidth * 2 && exactDistanceMoved == -lastExactDistanceMoved && catchCurrent.StrainTime == lastStrainTime)
{
if (isInBuzzSection)
distanceAddition = 0;
else
isInBuzzSection = true;
}
else
{
isInBuzzSection = false;
}
lastPlayerPosition = playerPosition;
lastDistanceMoved = distanceMoved;
lastStrainTime = catchCurrent.StrainTime;
lastExactDistanceMoved = exactDistanceMoved;
return distanceAddition / weightedStrainTime;
}
@@ -143,7 +143,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
base.UpdateHitObjectFromPath(hitObject);
if (hitObject.Path.ControlPoints.Count <= 1 || !hitObject.Path.HasValidLength)
if (hitObject.Path.ControlPoints.Count <= 1 || !hitObject.Path.HasValidLengthForPlacement)
EditorBeatmap?.Remove(hitObject);
}
}
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Catch.Edit.Checks;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Catch.Edit
@@ -13,7 +14,11 @@ namespace osu.Game.Rulesets.Catch.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
// Compose
new CheckBananaShowerGap(),
new CheckConcurrentObjects(),
// Settings
new CheckCatchAbnormalDifficultySettings(),
};
@@ -75,6 +75,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Setup
{
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
KeyboardStep = 0.1f,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
@@ -89,6 +90,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Setup
{
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
KeyboardStep = 1,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
@@ -1,10 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Mods;
@@ -35,21 +37,39 @@ namespace osu.Game.Rulesets.Catch.Mods
[SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")]
public BindableBool HardRockOffsets { get; } = new BindableBool();
public override string SettingDescription
public override string ExtendedIconInformation
{
get
{
string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}";
string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}";
string spicyPatterns = HardRockOffsets.IsDefault ? string.Empty : "Spicy patterns";
if (UserAdjustedSettingsCount != 1)
return string.Empty;
return string.Join(", ", new[]
{
circleSize,
base.SettingDescription,
approachRate,
spicyPatterns,
}.Where(s => !string.IsNullOrEmpty(s)));
if (!CircleSize.IsDefault) return format("CS", CircleSize);
if (!ApproachRate.IsDefault) return format("AR", ApproachRate);
if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty);
if (!DrainRate.IsDefault) return format("HP", DrainRate);
return string.Empty;
string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}";
}
}
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
if (!CircleSize.IsDefault)
yield return ("Circle size", $"{CircleSize.Value:N1}");
foreach (var setting in base.SettingDescription)
yield return setting;
if (!ApproachRate.IsDefault)
yield return ("Approach rate", $"{ApproachRate.Value:N1}");
if (!HardRockOffsets.IsDefault)
yield return ("Spicy patterns", "On");
}
}
+1 -1
View File
@@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModEasy : ModEasyWithExtraLives
{
public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!";
public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and extra lives!";
}
}
@@ -2,6 +2,7 @@
// 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.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -64,5 +65,12 @@ namespace osu.Game.Rulesets.Catch.Replays
return new LegacyReplayFrame(Time, Position, null, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is CatchReplayFrame catchFrame
&& Time == catchFrame.Time
&& Position == catchFrame.Position
&& Dashing == catchFrame.Dashing
&& Actions.SequenceEqual(catchFrame.Actions);
}
}
@@ -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 System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
@@ -110,7 +111,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Argon
double duration = ObjectState.HitObject.StartTime - ObjectState.DisplayStartTime;
fadeContent.Alpha = MathHelper.Clamp(
fadeContent.Alpha = Math.Clamp(
Interpolation.ValueAt(
Time.Current, 1f, 0f,
ObjectState.DisplayStartTime + duration * lens_flare_start,
@@ -49,6 +49,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
var leaderboard = container.OfType<DrawableGameplayLeaderboard>().FirstOrDefault();
if (keyCounter != null)
{
@@ -64,12 +65,20 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = new Vector2(10, -10);
}
if (leaderboard != null)
{
leaderboard.Anchor = Anchor.CentreLeft;
leaderboard.Origin = Anchor.CentreLeft;
leaderboard.X = 10;
}
})
{
Children = new Drawable[]
{
new LegacyKeyCounterDisplay(),
new SpectatorList(),
new DrawableGameplayLeaderboard(),
}
};
}
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.UI;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Catch.UI
protected override Container<Drawable> Content => content;
private readonly Container content;
private readonly Container scaleContainer;
public CatchPlayfieldAdjustmentContainer()
{
const float base_game_width = 1024f;
@@ -26,30 +30,49 @@ namespace osu.Game.Rulesets.Catch.UI
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
InternalChild = new Container
InternalChild = scaleContainer = new Container
{
// This container limits vertical visibility of the playfield to ensure fairness between wide and tall resolutions (i.e. tall resolutions should not see more fruits).
// Note that the container still extends across the screen horizontally, so that hit explosions at the sides of the playfield do not get cut off.
Name = "Visible area",
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Height = base_game_height + extra_bottom_space,
Y = extra_bottom_space / 2,
Masking = true,
RelativeSizeAxes = Axes.Both,
Child = new Container
{
Name = "Playable area",
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
Y = base_game_height * ((1 - playfield_size_adjust) / 4 * 3),
Size = new Vector2(base_game_width, base_game_height) * playfield_size_adjust,
Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both }
},
// This container limits vertical visibility of the playfield to ensure fairness between wide and tall resolutions (i.e. tall resolutions should not see more fruits).
// Note that the container still extends across the screen horizontally, so that hit explosions at the sides of the playfield do not get cut off.
Name = "Visible area",
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Height = base_game_height + extra_bottom_space,
Y = extra_bottom_space / 2,
Masking = true,
Child = new Container
{
Name = "Playable area",
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
Y = base_game_height * ((1 - playfield_size_adjust) / 4 * 3),
Size = new Vector2(base_game_width, base_game_height) * playfield_size_adjust,
Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both }
},
}
};
}
[BackgroundDependencyLoader]
private void load(OsuGame? osuGame)
{
if (osuGame != null)
{
// on mobile platforms where the base aspect ratio is wider, the catch playfield
// needs to be scaled down to remain playable.
const float base_aspect_ratio = 1024f / 768f;
float aspectRatio = osuGame.ScalingContainerTargetDrawSize.X / osuGame.ScalingContainerTargetDrawSize.Y;
scaleContainer.Scale = new Vector2(Math.Min(1, base_aspect_ratio / aspectRatio));
}
}
/// <summary>
/// A <see cref="Container"/> which scales its content relative to a target width.
/// </summary>
@@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Mania.Edit.Checks;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Rulesets.Mania.Tests.Editor.Checks
{
[TestFixture]
public class CheckManiaConcurrentObjectsTest
{
private CheckConcurrentObjects check = null!;
[SetUp]
public void Setup()
{
check = new CheckManiaConcurrentObjects();
}
[Test]
public void TestHoldNotesSeparateOnSameColumn()
{
assertOk(new List<HitObject>
{
createHoldNote(startTime: 100, endTime: 400.75d, column: 1),
createHoldNote(startTime: 500, endTime: 900.75d, column: 1)
});
}
[Test]
public void TestHoldNotesConcurrentOnDifferentColumns()
{
assertOk(new List<HitObject>
{
createHoldNote(startTime: 100, endTime: 400.75d, column: 1),
createHoldNote(startTime: 300, endTime: 700.75d, column: 2)
});
}
[Test]
public void TestHoldNotesConcurrentOnSameColumn()
{
assertConcurrentSame(new List<HitObject>
{
createHoldNote(startTime: 100, endTime: 400.75d, column: 1),
createHoldNote(startTime: 300, endTime: 700.75d, column: 1)
});
}
private void assertOk(List<HitObject> hitobjects)
{
Assert.That(check.Run(getContext(hitobjects)), Is.Empty);
}
private void assertConcurrentSame(List<HitObject> hitobjects, int count = 1)
{
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
}
private BeatmapVerifierContext getContext(List<HitObject> hitobjects)
{
var beatmap = new Beatmap<HitObject> { HitObjects = hitobjects };
return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
}
private HoldNote createHoldNote(double startTime, double endTime, int column)
{
return new HoldNote
{
StartTime = startTime,
EndTime = endTime,
Column = column
};
}
}
}
@@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public abstract partial class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene
{
protected sealed override Ruleset CreateRuleset() => new ManiaRuleset();
private readonly Column column;
[Cached(typeof(IReadOnlyList<Mod>))]
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
{
keyCount.Current.Value = 8;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<ReloadEditorDialog>);
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("refuse", () => InputManager.Key(Key.Number2));
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
{
keyCount.Current.Value = 8;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().Single().CurrentDialog, Is.InstanceOf<ReloadEditorDialog>);
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().Single().CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("acquiesce", () => InputManager.Key(Key.Number1));
AddUntilStep("beatmap became 8K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(8));
}
@@ -0,0 +1,187 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public class ManiaFilterCriteriaTest
{
[TestCase]
public void TestKeysEqualSingleValue()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "1");
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria
{
Mods = [new ManiaModKey1()]
}));
}
[TestCase]
public void TestKeysEqualMultipleValues()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "1,3,5,7");
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria
{
Mods = [new ManiaModKey1()]
}));
}
[TestCase]
public void TestKeysNotEqualSingleValue()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "1");
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria
{
Mods = [new ManiaModKey1()]
}));
}
[TestCase]
public void TestKeysNotEqualMultipleValues()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "1,3,5,7");
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria
{
Mods = [new ManiaModKey1()]
}));
}
[TestCase]
public void TestKeysGreaterOrEqualThan()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.GreaterOrEqual, "4");
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 5 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria
{
Mods = [new ManiaModKey7()]
}));
}
[TestCase]
public void TestFilterIntersection()
{
var criteria = new ManiaFilterCriteria();
criteria.TryParseCustomKeywordCriteria("keys", Operator.Greater, "4");
criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "7");
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 4 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 5 }),
new FilterCriteria()));
Assert.False(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 7 }),
new FilterCriteria()));
Assert.True(criteria.Matches(
new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 9 }),
new FilterCriteria()));
}
[TestCase]
public void TestInvalidFilters()
{
var criteria = new ManiaFilterCriteria();
Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "some text"));
Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "4,some text"));
Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.GreaterOrEqual, "4,5,6"));
}
}
}
@@ -31,9 +31,12 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
public void TestHoldNote(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new HoldNote { StartTime = 1000, EndTime = 3000 }), shouldMiss);
[Test]
public void TestGreatHit() => CreateModTest(new ModTestData
public void TestPerfectHits([Values] bool requirePerfectHits) => CreateModTest(new ModTestData
{
Mod = new ManiaModPerfect(),
Mod = new ManiaModPerfect
{
RequirePerfectHits = { Value = requirePerfectHits }
},
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false),
Autoplay = false,
CreateBeatmap = () => new Beatmap
@@ -47,6 +50,32 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000, ManiaAction.Key1),
new ManiaReplayFrame(2000)
}
});
[Test]
public void TestGreatHit([Values] bool requirePerfectHits) => CreateModTest(new ModTestData
{
Mod = new ManiaModPerfect
{
RequirePerfectHits = { Value = requirePerfectHits }
},
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(requirePerfectHits),
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Note
{
StartTime = 1000,
}
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1020, ManiaAction.Key1),
new ManiaReplayFrame(2000)
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
foreach (var holdNote in CreatedDrawables.SelectMany(d => d.ChildrenOfType<DrawableHoldNote>()))
{
((Bindable<bool>)holdNote.IsHitting).Value = v;
((Bindable<bool>)holdNote.IsHolding).Value = v;
}
});
}
@@ -0,0 +1,758 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene
{
protected override Ruleset CreateRuleset() => new ManiaRuleset();
protected override string? ExportLocation => null;
private static readonly object[][] score_v2_test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// Note that mania hitwindows are heavily idiosyncratic,
// and if you *think* a number here is wrong, probably double check.
// Known issues / complexities:
// - There is a disparate set of hitwindow ranges for: score V1 non-converts, score V1 converts, and score V2 (regardless of convert)
// - It is NEVER POSSIBLE to get a MEH result when late; exceeding the OK hit windows will result in a MISS.
// Additionally, the OK hit window when late is EXCLUSIVE / OPEN rather than INCLUSIVE / CLOSED.
// Relevant stable source: https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameplayElements/HitObjectManagerMania.cs#L737-L751
// - There is also a seemingly mania-specific issue wherein key inputs registered before time instant 0 get truncated to time 0,
// which is why the beatmaps used below make sure not to cross that boundary (the note starts at t=300ms).
// This is not an issue in osu! or taiko.
// The source of this behaviour has not been investigated in detail.
// OD = 5 test cases.
// PERFECT hit window is [ -19ms, 19ms]
// GREAT hit window is [ -49ms, 49ms]
// GOOD hit window is [ -82ms, 82ms]
// OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -18d, HitResult.Perfect },
new object[] { 5f, -19d, HitResult.Perfect },
new object[] { 5f, -20d, HitResult.Great },
new object[] { 5f, -21d, HitResult.Great },
new object[] { 5f, -48d, HitResult.Great },
new object[] { 5f, -49d, HitResult.Great },
new object[] { 5f, -50d, HitResult.Good },
new object[] { 5f, -51d, HitResult.Good },
new object[] { 5f, -81d, HitResult.Good },
new object[] { 5f, -82d, HitResult.Good },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -111d, HitResult.Ok },
new object[] { 5f, -112d, HitResult.Ok },
new object[] { 5f, -113d, HitResult.Meh },
new object[] { 5f, -114d, HitResult.Meh },
new object[] { 5f, -135d, HitResult.Meh },
new object[] { 5f, -136d, HitResult.Meh },
new object[] { 5f, -137d, HitResult.Miss },
new object[] { 5f, -138d, HitResult.Miss },
new object[] { 5f, 111d, HitResult.Ok },
new object[] { 5f, 112d, HitResult.Miss },
new object[] { 5f, 113d, HitResult.Miss },
new object[] { 5f, 114d, HitResult.Miss },
new object[] { 5f, 135d, HitResult.Miss },
new object[] { 5f, 136d, HitResult.Miss },
new object[] { 5f, 137d, HitResult.Miss },
new object[] { 5f, 138d, HitResult.Miss },
// OD = 9.3 test cases.
// PERFECT hit window is [ -14ms, 14ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -69ms, 69ms]
// OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 13d, HitResult.Perfect },
new object[] { 9.3f, 14d, HitResult.Perfect },
new object[] { 9.3f, 15d, HitResult.Great },
new object[] { 9.3f, 16d, HitResult.Great },
new object[] { 9.3f, 35d, HitResult.Great },
new object[] { 9.3f, 36d, HitResult.Great },
new object[] { 9.3f, 37d, HitResult.Good },
new object[] { 9.3f, 38d, HitResult.Good },
new object[] { 9.3f, 68d, HitResult.Good },
new object[] { 9.3f, 69d, HitResult.Good },
new object[] { 9.3f, 70d, HitResult.Ok },
new object[] { 9.3f, 71d, HitResult.Ok },
new object[] { 9.3f, 98d, HitResult.Ok },
new object[] { 9.3f, 99d, HitResult.Miss },
new object[] { 9.3f, 100d, HitResult.Miss },
new object[] { 9.3f, 101d, HitResult.Miss },
new object[] { 9.3f, 122d, HitResult.Miss },
new object[] { 9.3f, 123d, HitResult.Miss },
new object[] { 9.3f, 124d, HitResult.Miss },
new object[] { 9.3f, 125d, HitResult.Miss },
new object[] { 9.3f, -98d, HitResult.Ok },
new object[] { 9.3f, -99d, HitResult.Ok },
new object[] { 9.3f, -100d, HitResult.Meh },
new object[] { 9.3f, -101d, HitResult.Meh },
new object[] { 9.3f, -122d, HitResult.Meh },
new object[] { 9.3f, -123d, HitResult.Meh },
new object[] { 9.3f, -124d, HitResult.Miss },
new object[] { 9.3f, -125d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_test_cases =
{
// OD = 5 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -49ms, 49ms]
// GOOD hit window is [ -82ms, 82ms]
// OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -15d, HitResult.Perfect },
new object[] { 5f, -16d, HitResult.Perfect },
new object[] { 5f, -17d, HitResult.Great },
new object[] { 5f, -18d, HitResult.Great },
new object[] { 5f, -48d, HitResult.Great },
new object[] { 5f, -49d, HitResult.Great },
new object[] { 5f, -50d, HitResult.Good },
new object[] { 5f, -51d, HitResult.Good },
new object[] { 5f, -81d, HitResult.Good },
new object[] { 5f, -82d, HitResult.Good },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -111d, HitResult.Ok },
new object[] { 5f, -112d, HitResult.Ok },
new object[] { 5f, -113d, HitResult.Meh },
new object[] { 5f, -114d, HitResult.Meh },
new object[] { 5f, -135d, HitResult.Meh },
new object[] { 5f, -136d, HitResult.Meh },
new object[] { 5f, -137d, HitResult.Miss },
new object[] { 5f, -138d, HitResult.Miss },
new object[] { 5f, 111d, HitResult.Ok },
new object[] { 5f, 112d, HitResult.Miss },
new object[] { 5f, 113d, HitResult.Miss },
new object[] { 5f, 114d, HitResult.Miss },
new object[] { 5f, 135d, HitResult.Miss },
new object[] { 5f, 136d, HitResult.Miss },
new object[] { 5f, 137d, HitResult.Miss },
new object[] { 5f, 138d, HitResult.Miss },
// OD = 9.3 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -69ms, 69ms]
// OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 15d, HitResult.Perfect },
new object[] { 9.3f, 16d, HitResult.Perfect },
new object[] { 9.3f, 17d, HitResult.Great },
new object[] { 9.3f, 18d, HitResult.Great },
new object[] { 9.3f, 35d, HitResult.Great },
new object[] { 9.3f, 36d, HitResult.Great },
new object[] { 9.3f, 37d, HitResult.Good },
new object[] { 9.3f, 38d, HitResult.Good },
new object[] { 9.3f, 68d, HitResult.Good },
new object[] { 9.3f, 69d, HitResult.Good },
new object[] { 9.3f, 70d, HitResult.Ok },
new object[] { 9.3f, 71d, HitResult.Ok },
new object[] { 9.3f, 98d, HitResult.Ok },
new object[] { 9.3f, 99d, HitResult.Miss },
new object[] { 9.3f, 100d, HitResult.Miss },
new object[] { 9.3f, 101d, HitResult.Miss },
new object[] { 9.3f, 122d, HitResult.Miss },
new object[] { 9.3f, 123d, HitResult.Miss },
new object[] { 9.3f, 124d, HitResult.Miss },
new object[] { 9.3f, 125d, HitResult.Miss },
new object[] { 9.3f, -98d, HitResult.Ok },
new object[] { 9.3f, -99d, HitResult.Ok },
new object[] { 9.3f, -100d, HitResult.Meh },
new object[] { 9.3f, -101d, HitResult.Meh },
new object[] { 9.3f, -122d, HitResult.Meh },
new object[] { 9.3f, -123d, HitResult.Meh },
new object[] { 9.3f, -124d, HitResult.Miss },
new object[] { 9.3f, -125d, HitResult.Miss },
// OD = 3.1 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -54ms, 54ms]
// GOOD hit window is [ -87ms, 87ms]
// OK hit window is [-117ms, 117ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-141ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 3.1f, 15d, HitResult.Perfect },
new object[] { 3.1f, 16d, HitResult.Perfect },
new object[] { 3.1f, 17d, HitResult.Great },
new object[] { 3.1f, 18d, HitResult.Great },
new object[] { 3.1f, 53d, HitResult.Great },
new object[] { 3.1f, 54d, HitResult.Great },
new object[] { 3.1f, 55d, HitResult.Good },
new object[] { 3.1f, 56d, HitResult.Good },
new object[] { 3.1f, 86d, HitResult.Good },
new object[] { 3.1f, 87d, HitResult.Good },
new object[] { 3.1f, 88d, HitResult.Ok },
new object[] { 3.1f, 89d, HitResult.Ok },
new object[] { 3.1f, 116d, HitResult.Ok },
new object[] { 3.1f, 117d, HitResult.Miss },
new object[] { 3.1f, 118d, HitResult.Miss },
new object[] { 3.1f, 119d, HitResult.Miss },
new object[] { 3.1f, 140d, HitResult.Miss },
new object[] { 3.1f, 141d, HitResult.Miss },
new object[] { 3.1f, 142d, HitResult.Miss },
new object[] { 3.1f, 143d, HitResult.Miss },
new object[] { 3.1f, -116d, HitResult.Ok },
new object[] { 3.1f, -117d, HitResult.Ok },
new object[] { 3.1f, -118d, HitResult.Meh },
new object[] { 3.1f, -119d, HitResult.Meh },
new object[] { 3.1f, -140d, HitResult.Meh },
new object[] { 3.1f, -141d, HitResult.Meh },
new object[] { 3.1f, -142d, HitResult.Miss },
new object[] { 3.1f, -143d, HitResult.Miss },
};
private static readonly object[][] score_v1_convert_test_cases =
{
// OD = 5 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -34ms, 34ms]
// GOOD hit window is [ -67ms, 67ms]
// OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -15d, HitResult.Perfect },
new object[] { 5f, -16d, HitResult.Perfect },
new object[] { 5f, -17d, HitResult.Great },
new object[] { 5f, -18d, HitResult.Great },
new object[] { 5f, -33d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Good },
new object[] { 5f, -36d, HitResult.Good },
new object[] { 5f, -66d, HitResult.Good },
new object[] { 5f, -67d, HitResult.Good },
new object[] { 5f, -68d, HitResult.Ok },
new object[] { 5f, -69d, HitResult.Ok },
new object[] { 5f, -96d, HitResult.Ok },
new object[] { 5f, -97d, HitResult.Ok },
new object[] { 5f, -98d, HitResult.Meh },
new object[] { 5f, -99d, HitResult.Meh },
new object[] { 5f, -120d, HitResult.Meh },
new object[] { 5f, -121d, HitResult.Meh },
new object[] { 5f, -122d, HitResult.Miss },
new object[] { 5f, -123d, HitResult.Miss },
new object[] { 5f, 96d, HitResult.Ok },
new object[] { 5f, 97d, HitResult.Miss },
new object[] { 5f, 98d, HitResult.Miss },
new object[] { 5f, 99d, HitResult.Miss },
new object[] { 5f, 120d, HitResult.Miss },
new object[] { 5f, 121d, HitResult.Miss },
new object[] { 5f, 122d, HitResult.Miss },
new object[] { 5f, 123d, HitResult.Miss },
// OD = 3.1 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -47ms, 47ms]
// GOOD hit window is [ -77ms, 77ms]
// OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 3.1f, 15d, HitResult.Perfect },
new object[] { 3.1f, 16d, HitResult.Perfect },
new object[] { 3.1f, 17d, HitResult.Great },
new object[] { 3.1f, 18d, HitResult.Great },
new object[] { 3.1f, 46d, HitResult.Great },
new object[] { 3.1f, 47d, HitResult.Great },
new object[] { 3.1f, 48d, HitResult.Good },
new object[] { 3.1f, 49d, HitResult.Good },
new object[] { 3.1f, 76d, HitResult.Good },
new object[] { 3.1f, 77d, HitResult.Good },
new object[] { 3.1f, 78d, HitResult.Ok },
new object[] { 3.1f, 79d, HitResult.Ok },
new object[] { 3.1f, 96d, HitResult.Ok },
new object[] { 3.1f, 97d, HitResult.Miss },
new object[] { 3.1f, 98d, HitResult.Miss },
new object[] { 3.1f, 99d, HitResult.Miss },
new object[] { 3.1f, 120d, HitResult.Miss },
new object[] { 3.1f, 121d, HitResult.Miss },
new object[] { 3.1f, 122d, HitResult.Miss },
new object[] { 3.1f, 123d, HitResult.Miss },
new object[] { 3.1f, -96d, HitResult.Ok },
new object[] { 3.1f, -97d, HitResult.Ok },
new object[] { 3.1f, -98d, HitResult.Meh },
new object[] { 3.1f, -99d, HitResult.Meh },
new object[] { 3.1f, -120d, HitResult.Meh },
new object[] { 3.1f, -121d, HitResult.Meh },
new object[] { 3.1f, -122d, HitResult.Miss },
new object[] { 3.1f, -123d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_hard_rock_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 7.
// PERFECT hit window is [-11ms, 11ms]
// GREAT hit window is [-35ms, 35ms]
// GOOD hit window is [-58ms, 58ms]
// OK hit window is [-80ms, 80ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-97ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -10d, HitResult.Perfect },
new object[] { 5f, -11d, HitResult.Perfect },
new object[] { 5f, -12d, HitResult.Great },
new object[] { 5f, -13d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Great },
new object[] { 5f, -36d, HitResult.Good },
new object[] { 5f, -37d, HitResult.Good },
new object[] { 5f, -57d, HitResult.Good },
new object[] { 5f, -58d, HitResult.Good },
new object[] { 5f, -59d, HitResult.Ok },
new object[] { 5f, -60d, HitResult.Ok },
new object[] { 5f, -79d, HitResult.Ok },
new object[] { 5f, -80d, HitResult.Ok },
new object[] { 5f, -81d, HitResult.Meh },
new object[] { 5f, -82d, HitResult.Meh },
new object[] { 5f, -96d, HitResult.Meh },
new object[] { 5f, -97d, HitResult.Meh },
new object[] { 5f, -98d, HitResult.Miss },
new object[] { 5f, -99d, HitResult.Miss },
new object[] { 5f, 79d, HitResult.Ok },
new object[] { 5f, 80d, HitResult.Miss },
new object[] { 5f, 81d, HitResult.Miss },
new object[] { 5f, 82d, HitResult.Miss },
new object[] { 5f, 96d, HitResult.Miss },
new object[] { 5f, 97d, HitResult.Miss },
new object[] { 5f, 98d, HitResult.Miss },
new object[] { 5f, 99d, HitResult.Miss },
// OD = 9.3 test cases.
// This leads to "effective" OD of 13.02.
// Note that contrary to other rulesets this does NOT cap out to OD 10!
// PERFECT hit window is [-11ms, 11ms]
// GREAT hit window is [-25ms, 25ms]
// GOOD hit window is [-49ms, 49ms]
// OK hit window is [-70ms, 70ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-87ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 10d, HitResult.Perfect },
new object[] { 9.3f, 11d, HitResult.Perfect },
new object[] { 9.3f, 12d, HitResult.Great },
new object[] { 9.3f, 13d, HitResult.Great },
new object[] { 9.3f, 24d, HitResult.Great },
new object[] { 9.3f, 25d, HitResult.Great },
new object[] { 9.3f, 26d, HitResult.Good },
new object[] { 9.3f, 27d, HitResult.Good },
new object[] { 9.3f, 48d, HitResult.Good },
new object[] { 9.3f, 49d, HitResult.Good },
new object[] { 9.3f, 50d, HitResult.Ok },
new object[] { 9.3f, 51d, HitResult.Ok },
new object[] { 9.3f, 69d, HitResult.Ok },
new object[] { 9.3f, 70d, HitResult.Miss },
new object[] { 9.3f, 71d, HitResult.Miss },
new object[] { 9.3f, 72d, HitResult.Miss },
new object[] { 9.3f, 86d, HitResult.Miss },
new object[] { 9.3f, 87d, HitResult.Miss },
new object[] { 9.3f, 88d, HitResult.Miss },
new object[] { 9.3f, 89d, HitResult.Miss },
new object[] { 9.3f, -69d, HitResult.Ok },
new object[] { 9.3f, -70d, HitResult.Ok },
new object[] { 9.3f, -71d, HitResult.Meh },
new object[] { 9.3f, -72d, HitResult.Meh },
new object[] { 9.3f, -86d, HitResult.Meh },
new object[] { 9.3f, -87d, HitResult.Meh },
new object[] { 9.3f, -88d, HitResult.Miss },
new object[] { 9.3f, -89d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_easy_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -22ms, 22ms]
// GREAT hit window is [ -68ms, 68ms]
// GOOD hit window is [-114ms, 114ms]
// OK hit window is [-156ms, 156ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-190ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -21d, HitResult.Perfect },
new object[] { 5f, -22d, HitResult.Perfect },
new object[] { 5f, -23d, HitResult.Great },
new object[] { 5f, -24d, HitResult.Great },
new object[] { 5f, -67d, HitResult.Great },
new object[] { 5f, -68d, HitResult.Great },
new object[] { 5f, -69d, HitResult.Good },
new object[] { 5f, -70d, HitResult.Good },
new object[] { 5f, -113d, HitResult.Good },
new object[] { 5f, -114d, HitResult.Good },
new object[] { 5f, -115d, HitResult.Ok },
new object[] { 5f, -116d, HitResult.Ok },
new object[] { 5f, -155d, HitResult.Ok },
new object[] { 5f, -156d, HitResult.Ok },
new object[] { 5f, -157d, HitResult.Meh },
new object[] { 5f, -158d, HitResult.Meh },
new object[] { 5f, -189d, HitResult.Meh },
new object[] { 5f, -190d, HitResult.Meh },
new object[] { 5f, -191d, HitResult.Miss },
new object[] { 5f, -192d, HitResult.Miss },
new object[] { 5f, 155d, HitResult.Ok },
new object[] { 5f, 156d, HitResult.Miss },
new object[] { 5f, 157d, HitResult.Miss },
new object[] { 5f, 158d, HitResult.Miss },
new object[] { 5f, 189d, HitResult.Miss },
new object[] { 5f, 190d, HitResult.Miss },
new object[] { 5f, 191d, HitResult.Miss },
new object[] { 5f, 192d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_double_time_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -24ms, 24ms]
// GREAT hit window is [ -73ms, 73ms]
// GOOD hit window is [-123ms, 123ms]
// OK hit window is [-168ms, 168ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-204ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -23d, HitResult.Perfect },
new object[] { 5f, -24d, HitResult.Perfect },
new object[] { 5f, -25d, HitResult.Great },
new object[] { 5f, -26d, HitResult.Great },
new object[] { 5f, -72d, HitResult.Great },
new object[] { 5f, -73d, HitResult.Great },
new object[] { 5f, -74d, HitResult.Good },
new object[] { 5f, -75d, HitResult.Good },
new object[] { 5f, -122d, HitResult.Good },
new object[] { 5f, -123d, HitResult.Good },
new object[] { 5f, -124d, HitResult.Ok },
new object[] { 5f, -125d, HitResult.Ok },
new object[] { 5f, -167d, HitResult.Ok },
new object[] { 5f, -168d, HitResult.Ok },
new object[] { 5f, -169d, HitResult.Meh },
new object[] { 5f, -170d, HitResult.Meh },
new object[] { 5f, -203d, HitResult.Meh },
new object[] { 5f, -204d, HitResult.Meh },
new object[] { 5f, -205d, HitResult.Miss },
new object[] { 5f, -206d, HitResult.Miss },
new object[] { 5f, 167d, HitResult.Ok },
new object[] { 5f, 168d, HitResult.Miss },
new object[] { 5f, 169d, HitResult.Miss },
new object[] { 5f, 170d, HitResult.Miss },
new object[] { 5f, 203d, HitResult.Miss },
new object[] { 5f, 204d, HitResult.Miss },
new object[] { 5f, 205d, HitResult.Miss },
new object[] { 5f, 206d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_half_time_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -12ms, 12ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -61ms, 61ms]
// OK hit window is [ -84ms, 84ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-102ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -11d, HitResult.Perfect },
new object[] { 5f, -12d, HitResult.Perfect },
new object[] { 5f, -13d, HitResult.Great },
new object[] { 5f, -14d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Great },
new object[] { 5f, -36d, HitResult.Great },
new object[] { 5f, -37d, HitResult.Good },
new object[] { 5f, -38d, HitResult.Good },
new object[] { 5f, -60d, HitResult.Good },
new object[] { 5f, -61d, HitResult.Good },
new object[] { 5f, -62d, HitResult.Ok },
new object[] { 5f, -63d, HitResult.Ok },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -85d, HitResult.Meh },
new object[] { 5f, -86d, HitResult.Meh },
new object[] { 5f, -101d, HitResult.Meh },
new object[] { 5f, -102d, HitResult.Meh },
new object[] { 5f, -103d, HitResult.Miss },
new object[] { 5f, -104d, HitResult.Miss },
new object[] { 5f, 83d, HitResult.Ok },
new object[] { 5f, 84d, HitResult.Miss },
new object[] { 5f, 85d, HitResult.Miss },
new object[] { 5f, 86d, HitResult.Miss },
new object[] { 5f, 101d, HitResult.Miss },
new object[] { 5f, 102d, HitResult.Miss },
new object[] { 5f, 103d, HitResult.Miss },
new object[] { 5f, 104d, HitResult.Miss },
};
private const double note_time = 300;
[TestCaseSource(nameof(score_v2_test_cases))]
public void TestHitWindowTreatmentWithScoreV2(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ModScoreV2()]
}
};
RunTest($@"SV2 single note @ OD{overallDifficulty}", beatmap, $@"SV2 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_test_cases))]
public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"SV1 single note @ OD{overallDifficulty}", beatmap, $@"SV1 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_convert_test_cases))]
public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModKey1()],
}
};
RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_hard_rock_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndHardRockNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModHardRock()],
}
};
RunTest($@"SV1+HR single note @ OD{overallDifficulty}", beatmap, $@"SV1+HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_easy_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndEasyNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModEasy()],
}
};
RunTest($@"SV1+EZ single note @ OD{overallDifficulty}", beatmap, $@"SV1+EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_double_time_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndDoubleTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModDoubleTime()],
}
};
RunTest($@"SV1+DT single note @ OD{overallDifficulty}", beatmap, $@"SV1+DT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_half_time_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndHalfTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModHalfTime()],
}
};
RunTest($@"SV1+HT single note @ OD{overallDifficulty}", beatmap, $@"SV1+HT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static ManiaBeatmap createNonConvertBeatmap(float overallDifficulty)
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new ManiaBeatmap(new StageDefinition(1))
{
HitObjects =
{
new Note
{
StartTime = note_time,
Column = 0,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
CircleSize = 1,
},
BeatmapInfo =
{
Ruleset = new ManiaRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
return beatmap;
}
private static Beatmap createConvertBeatmap(float overallDifficulty)
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new Beatmap
{
HitObjects =
{
new FakeCircle
{
StartTime = note_time,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
},
BeatmapInfo =
{
Ruleset = new RulesetInfo { OnlineID = 0 }
},
ControlPointInfo = cpi,
};
return beatmap;
}
private class FakeCircle : HitObject, IHasPosition
{
public float X
{
get => Position.X;
set => Position = new Vector2(value, Position.Y);
}
public float Y
{
get => Position.Y;
set => Position = new Vector2(Position.X, value);
}
public Vector2 Position { get; set; }
}
}
}
@@ -3,10 +3,13 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests
{
@@ -14,6 +17,16 @@ namespace osu.Game.Rulesets.Mania.Tests
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[SetUp]
public void SetUp() => Schedule(() =>
{
InputManager.EndTouch(new Touch(TouchSource.Touch1, Vector2.Zero));
InputManager.EndTouch(new Touch(TouchSource.Touch2, Vector2.Zero));
toggleTouchControls(false);
});
#region Without touch controls
[Test]
public void TestTouchInput()
{
@@ -63,6 +76,140 @@ namespace osu.Game.Rulesets.Mania.Tests
() => Does.Not.Contain(getColumn(0).Action.Value));
}
[Test]
public void TestBetweenTwoColumns()
{
AddStep("touch after column 0", () =>
{
var column = getColumn(0);
InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(column.LayoutSize.X + 0.5f, column.LayoutSize.Y / 2))));
});
AddAssert("column 0 pressed",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getColumn(0).Action.Value));
AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("column 0 released",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getColumn(0).Action.Value));
AddStep("touch before column 1", () =>
{
var column = getColumn(1);
InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(-0.5f, column.LayoutSize.Y / 2))));
});
AddAssert("column 1 pressed",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getColumn(1).Action.Value));
AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("column 1 released",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getColumn(1).Action.Value));
}
#endregion
#region With touch controls
[Test]
public void TestTouchAreaNotInitiallyVisible()
{
AddStep("enable touch controls", () => toggleTouchControls(true));
AddAssert("touch area not visible", () => getTouchOverlay()?.State.Value == Visibility.Hidden);
}
[Test]
public void TestPressReceptors()
{
AddStep("enable touch controls", () => toggleTouchControls(true));
AddAssert("touch area not visible", () => getTouchOverlay()?.State.Value == Visibility.Hidden);
for (int i = 0; i < 4; i++)
{
int index = i;
AddStep($"touch receptor {index}", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, getReceptor(index).ScreenSpaceDrawQuad.Centre)));
AddAssert("action sent",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getReceptor(index).Action.Value));
AddStep($"release receptor {index}", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(index).ScreenSpaceDrawQuad.Centre)));
AddAssert("touch area visible", () => getTouchOverlay()?.State.Value == Visibility.Visible);
}
}
[Test]
public void TestColumnsNotTouchableWithTouchControls()
{
AddStep("enable touch controls", () => toggleTouchControls(true));
AddStep("touch receptor 0", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("action sent",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getReceptor(0).Action.Value));
AddStep("release receptor 0", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("touch area visible", () => getTouchOverlay()?.State.Value == Visibility.Visible);
AddStep("touch column 0", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre + new Vector2(0f, -50f))));
AddAssert("action not sent",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getColumn(0).Action.Value));
AddStep("release column 0", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre + new Vector2(0f, -50f))));
AddAssert("action not sent",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getColumn(0).Action.Value));
}
[Test]
public void TestTouchControlBetweenTwoColumns()
{
AddStep("enable touch controls", () => toggleTouchControls(true));
AddStep("touch after receptor 0", () =>
{
var column = getReceptor(0);
InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(column.LayoutSize.X + 1f, column.LayoutSize.Y / 2))));
});
AddAssert("column 0 pressed",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getReceptor(0).Action.Value));
AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("column 0 released",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getReceptor(0).Action.Value));
AddStep("touch before receptor 1", () =>
{
var column = getReceptor(1);
InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(-1f, column.LayoutSize.Y / 2))));
});
AddAssert("column 1 pressed",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Contain(getReceptor(1).Action.Value));
AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre)));
AddAssert("column 1 released",
() => this.ChildrenOfType<ManiaInputManager>().SelectMany(m => m.KeyBindingContainer.PressedActions),
() => Does.Not.Contain(getReceptor(1).Action.Value));
}
#endregion
private void toggleTouchControls(bool enabled)
{
var maniaConfig = (ManiaRulesetConfigManager)RulesetConfigs.GetConfigFor(CreatePlayerRuleset())!;
maniaConfig.SetValue(ManiaRulesetSetting.MobileLayout, enabled ? ManiaMobileLayout.LandscapeWithOverlay : ManiaMobileLayout.Portrait);
}
private ManiaTouchInputArea? getTouchOverlay() => this.ChildrenOfType<ManiaTouchInputArea>().SingleOrDefault();
private ManiaTouchInputArea.ColumnInputReceptor getReceptor(int index) => this.ChildrenOfType<ManiaTouchInputArea.ColumnInputReceptor>().ElementAt(index);
private Column getColumn(int index) => this.ChildrenOfType<Column>().ElementAt(index);
}
}
@@ -0,0 +1,61 @@
// 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.Allocation;
using osu.Framework.Audio;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Storyboards;
using osu.Game.Tests.Visual;
using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Tests
{
public partial class TestSceneReplayRecording : PlayerTestScene
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Resolved]
private AudioManager audioManager { get; set; } = null!;
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new ManiaBeatmap(new StageDefinition(1))
{
HitObjects =
{
new Note { StartTime = 0, },
new Note { StartTime = 5000, },
new Note { StartTime = 10000, },
new Note { StartTime = 15000, }
},
Difficulty = { CircleSize = 1 },
BeatmapInfo =
{
Ruleset = ruleset,
}
};
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) =>
new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
[Test]
public void TestRecording()
{
seekTo(0);
AddStep("press space", () => InputManager.PressKey(Key.Space));
seekTo(15);
AddStep("release space", () => InputManager.ReleaseKey(Key.Space));
AddUntilStep("button press recorded to replay", () => Player.Score.Replay.Frames.OfType<ManiaReplayFrame>().Any(f => f.Actions.SequenceEqual([ManiaAction.Key1])));
}
private void seekTo(double time)
{
AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time));
AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500));
}
}
}
@@ -0,0 +1,125 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests
{
public partial class TestSceneReplayRewinding : RateAdjustedBeatmapTestScene
{
private ReplayPlayer currentPlayer = null!;
[Test]
public void TestRewindingToMiddleOfHoldNote()
{
Score score = null!;
var beatmap = new ManiaBeatmap(new StageDefinition(4))
{
HitObjects =
{
new HoldNote
{
StartTime = 500,
EndTime = 1500,
Column = 2
}
}
};
AddStep(@"create replay", () => score = new Score
{
Replay = new Replay
{
Frames =
{
new ManiaReplayFrame(500, ManiaAction.Key3),
new ManiaReplayFrame(1500),
}
},
ScoreInfo = new ScoreInfo()
});
AddStep(@"set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(beatmap));
AddStep(@"set ruleset", () => Ruleset.Value = beatmap.BeatmapInfo.Ruleset);
AddStep(@"push player", () => LoadScreen(currentPlayer = new ReplayPlayer(score)));
AddUntilStep(@"wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep(@"wait for hold to be judged", () => currentPlayer.ChildrenOfType<IFrameStableClock>().Single().CurrentTime, () => Is.GreaterThan(1600));
AddStep(@"seek to middle of hold note", () => currentPlayer.Seek(1000));
AddUntilStep(@"wait for gameplay to complete", () => currentPlayer.GameplayState.HasCompleted);
AddAssert(@"no misses registered", () => currentPlayer.GameplayState.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Miss), () => Is.Zero);
AddStep(@"exit player", () => currentPlayer.Exit());
}
[Test]
public void TestCorrectComboAccountingForConcurrentObjects()
{
Score score = null!;
var beatmap = new ManiaBeatmap(new StageDefinition(4))
{
HitObjects =
{
new Note
{
StartTime = 500,
Column = 0,
},
new Note
{
StartTime = 500,
Column = 2,
},
new HoldNote
{
StartTime = 1000,
EndTime = 1500,
Column = 1,
}
}
};
AddStep(@"create replay", () => score = new Score
{
Replay = new Replay
{
Frames =
{
new ManiaReplayFrame(500, ManiaAction.Key1, ManiaAction.Key3),
new ManiaReplayFrame(520),
new ManiaReplayFrame(1000, ManiaAction.Key2),
new ManiaReplayFrame(1500),
}
},
ScoreInfo = new ScoreInfo()
});
AddStep(@"set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(beatmap));
AddStep(@"set ruleset", () => Ruleset.Value = beatmap.BeatmapInfo.Ruleset);
AddStep(@"push player", () => LoadScreen(currentPlayer = new ReplayPlayer(score)));
AddUntilStep(@"wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep(@"wait for objects to be judged", () => currentPlayer.ChildrenOfType<IFrameStableClock>().Single().CurrentTime, () => Is.GreaterThan(1600));
AddStep(@"stop gameplay", () => currentPlayer.ChildrenOfType<GameplayClockContainer>().Single().Stop());
AddStep(@"seek to start", () => currentPlayer.Seek(0));
AddAssert(@"combo is 0", () => currentPlayer.GameplayState.ScoreProcessor.Combo.Value, () => Is.Zero);
AddStep(@"exit player", () => currentPlayer.Exit());
}
}
}
@@ -0,0 +1,149 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneReplayStability : ReplayStabilityTestScene
{
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// PERFECT hit window is [ -19.4ms, 19.4ms]
// GREAT hit window is [ -49.0ms, 49.0ms]
// GOOD hit window is [ -82.0ms, 82.0ms]
// OK hit window is [-112.0ms, 112.0ms]
// MEH hit window is [-136.0ms, 136.0ms]
// MISS hit window is [-173.0ms, 173.0ms]
new object[] { 5f, -19d, HitResult.Perfect },
new object[] { 5f, -19.2d, HitResult.Perfect },
new object[] { 5f, -19.38d, HitResult.Perfect },
// new object[] { 5f, -19.4d, HitResult.Perfect }, <- in theory this should work, in practice it does not (fails even before encode & rounding due to floating point precision issues)
new object[] { 5f, -19.44d, HitResult.Great },
new object[] { 5f, -19.7d, HitResult.Great },
new object[] { 5f, -20d, HitResult.Great },
new object[] { 5f, -48d, HitResult.Great },
new object[] { 5f, -48.4d, HitResult.Great },
new object[] { 5f, -48.7d, HitResult.Great },
new object[] { 5f, -49d, HitResult.Great },
new object[] { 5f, -49.2d, HitResult.Good },
new object[] { 5f, -49.7d, HitResult.Good },
new object[] { 5f, -50d, HitResult.Good },
new object[] { 5f, -81d, HitResult.Good },
new object[] { 5f, -81.2d, HitResult.Good },
new object[] { 5f, -81.7d, HitResult.Good },
new object[] { 5f, -82d, HitResult.Good },
new object[] { 5f, -82.2d, HitResult.Ok },
new object[] { 5f, -82.7d, HitResult.Ok },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -111d, HitResult.Ok },
new object[] { 5f, -111.2d, HitResult.Ok },
new object[] { 5f, -111.7d, HitResult.Ok },
new object[] { 5f, -112d, HitResult.Ok },
new object[] { 5f, -112.2d, HitResult.Meh },
new object[] { 5f, -112.7d, HitResult.Meh },
new object[] { 5f, -113d, HitResult.Meh },
new object[] { 5f, -135d, HitResult.Meh },
new object[] { 5f, -135.2d, HitResult.Meh },
new object[] { 5f, -135.8d, HitResult.Meh },
new object[] { 5f, -136d, HitResult.Meh },
new object[] { 5f, -136.2d, HitResult.Miss },
new object[] { 5f, -136.7d, HitResult.Miss },
new object[] { 5f, -137d, HitResult.Miss },
// OD = 9.3 test cases.
// PERFECT hit window is [ -14.67ms, 14.67ms]
// GREAT hit window is [ -36.10ms, 36.10ms]
// GOOD hit window is [ -69.10ms, 69.10ms]
// OK hit window is [ -99.10ms, 99.10ms]
// MEH hit window is [-123.10ms, 123.10ms]
// MISS hit window is [-160.10ms, 160.10ms]
new object[] { 9.3f, 14d, HitResult.Perfect },
new object[] { 9.3f, 14.2d, HitResult.Perfect },
new object[] { 9.3f, 14.6d, HitResult.Perfect },
// new object[] { 9.3f, 14.67d, HitResult.Perfect }, <- in theory this should work, in practice it does not (fails even before encode & rounding due to floating point precision issues)
new object[] { 9.3f, 14.7d, HitResult.Great },
new object[] { 9.3f, 15d, HitResult.Great },
new object[] { 9.3f, 35d, HitResult.Great },
new object[] { 9.3f, 35.3d, HitResult.Great },
new object[] { 9.3f, 35.8d, HitResult.Great },
new object[] { 9.3f, 36.05d, HitResult.Great },
new object[] { 9.3f, 36.3d, HitResult.Good },
new object[] { 9.3f, 36.7d, HitResult.Good },
new object[] { 9.3f, 37d, HitResult.Good },
new object[] { 9.3f, 68d, HitResult.Good },
new object[] { 9.3f, 68.4d, HitResult.Good },
new object[] { 9.3f, 68.9d, HitResult.Good },
new object[] { 9.3f, 69.07d, HitResult.Good },
new object[] { 9.3f, 69.25d, HitResult.Ok },
new object[] { 9.3f, 69.85d, HitResult.Ok },
new object[] { 9.3f, 70d, HitResult.Ok },
new object[] { 9.3f, 98d, HitResult.Ok },
new object[] { 9.3f, 98.3d, HitResult.Ok },
new object[] { 9.3f, 98.6d, HitResult.Ok },
new object[] { 9.3f, 99d, HitResult.Ok },
new object[] { 9.3f, 99.3d, HitResult.Meh },
new object[] { 9.3f, 99.7d, HitResult.Meh },
new object[] { 9.3f, 100d, HitResult.Meh },
new object[] { 9.3f, 122d, HitResult.Meh },
new object[] { 9.3f, 122.34d, HitResult.Meh },
new object[] { 9.3f, 122.57d, HitResult.Meh },
new object[] { 9.3f, 123.04d, HitResult.Meh },
new object[] { 9.3f, 123.45d, HitResult.Miss },
new object[] { 9.3f, 123.95d, HitResult.Miss },
new object[] { 9.3f, 124d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double note_time = 100;
var beatmap = new ManiaBeatmap(new StageDefinition(1))
{
HitObjects =
{
new Note
{
StartTime = note_time,
Column = 0,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
CircleSize = 1,
},
BeatmapInfo =
{
Ruleset = new ManiaRuleset().RulesetInfo,
},
};
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
RunTest(beatmap, replay, [expectedResult]);
}
}
}
@@ -36,20 +36,23 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
{
int notes = HitObjects.Count(s => s is Note);
int holdNotes = HitObjects.Count(s => s is HoldNote);
int sum = Math.Max(1, notes + holdNotes);
return new[]
{
new BeatmapStatistic
{
Name = @"Note Count",
Name = @"Notes",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles),
Content = notes.ToString(),
BarDisplayLength = notes / (float)sum,
},
new BeatmapStatistic
{
Name = @"Hold Note Count",
Name = @"Hold Notes",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders),
Content = holdNotes.ToString(),
BarDisplayLength = holdNotes / (float)sum,
},
};
}
@@ -1,7 +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.
using System;
using osu.Framework.Configuration.Tracking;
using osu.Game.Configuration;
using osu.Game.Localisation;
@@ -24,17 +23,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
SetDefault(ManiaRulesetSetting.ScrollSpeed, 8.0, 1.0, 40.0, 0.1);
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
#pragma warning disable CS0618
// Although obsolete, this is still required to populate the bindable from the database in case migration is required.
SetDefault<double?>(ManiaRulesetSetting.ScrollTime, null);
if (Get<double?>(ManiaRulesetSetting.ScrollTime) is double scrollTime)
{
SetValue(ManiaRulesetSetting.ScrollSpeed, Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime));
SetValue<double?>(ManiaRulesetSetting.ScrollTime, null);
}
#pragma warning restore CS0618
SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait);
}
public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
@@ -51,10 +40,9 @@ namespace osu.Game.Rulesets.Mania.Configuration
public enum ManiaRulesetSetting
{
[Obsolete("Use ScrollSpeed instead.")] // Can be removed 2023-11-30
ScrollTime,
ScrollSpeed,
ScrollDirection,
TimingBasedNoteColouring
TimingBasedNoteColouring,
MobileLayout,
}
}
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
@@ -10,22 +9,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaDifficultyAttributes : DifficultyAttributes
{
/// <summary>
/// The hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods do not affect the hit window at all in osu-stable.
/// </remarks>
[JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; }
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -33,7 +22,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
base.FromDatabaseAttributes(values, onlineInfo);
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}
}
}
@@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
private const double difficulty_multiplier = 0.018;
private readonly bool isForCurrentRuleset;
private readonly double originalOverallDifficulty;
public override int Version => 20241007;
@@ -35,7 +34,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
: base(ruleset, beatmap)
{
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -48,11 +46,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills[0].DifficultyValue() * difficulty_multiplier,
StarRating = skills.OfType<Strain>().Single().DifficultyValue() * difficulty_multiplier,
Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window.
// This is done the way it is to introduce fractional differences in order to match osu-stable for the time being.
GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate),
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
@@ -124,29 +119,5 @@ namespace osu.Game.Rulesets.Mania.Difficulty
}).ToArray();
}
}
private double getHitWindow300(Mod[] mods)
{
if (isForCurrentRuleset)
{
double od = Math.Min(10.0, Math.Max(0, 10.0 - originalOverallDifficulty));
return applyModAdjustments(34 + 3 * od, mods);
}
if (Math.Round(originalOverallDifficulty) > 4)
return applyModAdjustments(34, mods);
return applyModAdjustments(47, mods);
static double applyModAdjustments(double value, Mod[] mods)
{
if (mods.Any(m => m is ManiaModHardRock))
value /= 1.4;
else if (mods.Any(m => m is ManiaModEasy))
value *= 1.4;
return value;
}
}
}
}
@@ -0,0 +1,43 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Mania.Edit.Checks
{
public class CheckManiaConcurrentObjects : CheckConcurrentObjects
{
public override IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var hitObjects = context.Beatmap.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
var hitobject = hitObjects[i];
for (int j = i + 1; j < hitObjects.Count; ++j)
{
var nextHitobject = hitObjects[j];
// Mania hitobjects are only considered concurrent if they also share the same column.
if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column)
continue;
// Two hitobjects cannot be concurrent without also being concurrent with all objects in between.
// So if the next object is not concurrent, then we know no future objects will be either.
if (!AreConcurrent(hitobject, nextHitobject))
break;
if (hitobject.GetType() == nextHitobject.GetType())
yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject);
else
yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject);
}
}
}
}
}
@@ -13,6 +13,9 @@ namespace osu.Game.Rulesets.Mania.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
// Compose
new CheckManiaConcurrentObjects(),
// Settings
new CheckKeyCount(),
new CheckManiaAbnormalDifficultySettings(),
@@ -89,6 +89,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
KeyboardStep = 0.1f,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
@@ -103,6 +104,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
KeyboardStep = 1,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
@@ -134,7 +136,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
updatingKeyCount = true;
editor.Reload().ContinueWith(t =>
editor.SaveAndReload().ContinueWith(t =>
{
if (!t.GetResultSafely())
{
@@ -0,0 +1,48 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HoldNoteJudgementResult : JudgementResult
{
private Stack<(double time, bool holding)> holdingState { get; } = new Stack<(double, bool)>();
public HoldNoteJudgementResult(HoldNote hitObject, Judgement judgement)
: base(hitObject, judgement)
{
holdingState.Push((double.NegativeInfinity, false));
}
private (double time, bool holding) getLastReport(double currentTime)
{
while (holdingState.Peek().time > currentTime)
holdingState.Pop();
return holdingState.Peek();
}
public bool IsHolding(double currentTime) => getLastReport(currentTime).holding;
public bool DroppedHoldAfter(double time)
{
foreach (var state in holdingState)
{
if (state.time >= time && !state.holding)
return true;
}
return false;
}
public void ReportHoldState(double currentTime, bool holding)
{
var lastReport = getLastReport(currentTime);
if (holding != lastReport.holding)
holdingState.Push((currentTime, holding));
}
}
}
+58 -5
View File
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
@@ -17,20 +18,72 @@ namespace osu.Game.Rulesets.Mania
{
public class ManiaFilterCriteria : IRulesetFilterCriteria
{
private FilterCriteria.OptionalRange<float> keys;
private readonly HashSet<int> includedKeyCounts = Enumerable.Range(1, LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT).ToHashSet();
public bool Matches(BeatmapInfo beatmapInfo, FilterCriteria criteria)
{
return !keys.HasFilter || keys.IsInRange(ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), criteria.Mods));
int keyCount = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), criteria.Mods);
return includedKeyCounts.Contains(keyCount);
}
public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
public bool TryParseCustomKeywordCriteria(string key, Operator op, string strValues)
{
switch (key)
{
case "key":
case "keys":
return FilterQueryParser.TryUpdateCriteriaRange(ref keys, op, value);
{
var keyCounts = new HashSet<int>();
foreach (string strValue in strValues.Split(','))
{
if (!int.TryParse(strValue, out int keyCount))
return false;
keyCounts.Add(keyCount);
}
int? singleKeyCount = keyCounts.Count == 1 ? keyCounts.Single() : null;
switch (op)
{
case Operator.Equal:
includedKeyCounts.IntersectWith(keyCounts);
return true;
case Operator.NotEqual:
includedKeyCounts.ExceptWith(keyCounts);
return true;
case Operator.Less:
if (singleKeyCount == null) return false;
includedKeyCounts.RemoveWhere(k => k >= singleKeyCount.Value);
return true;
case Operator.LessOrEqual:
if (singleKeyCount == null) return false;
includedKeyCounts.RemoveWhere(k => k > singleKeyCount.Value);
return true;
case Operator.Greater:
if (singleKeyCount == null) return false;
includedKeyCounts.RemoveWhere(k => k <= singleKeyCount.Value);
return true;
case Operator.GreaterOrEqual:
if (singleKeyCount == null) return false;
includedKeyCounts.RemoveWhere(k => k < singleKeyCount.Value);
return true;
default:
return false;
}
}
}
return false;
@@ -38,7 +91,7 @@ namespace osu.Game.Rulesets.Mania
public bool FilterMayChangeFromMods(ValueChangedEvent<IReadOnlyList<Mod>> mods)
{
if (keys.HasFilter)
if (includedKeyCounts.Count != LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT)
{
// Interpreting as the Mod type is required for equality comparison.
HashSet<Mod> oldSet = mods.OldValue.OfType<ManiaKeyMod>().AsEnumerable<Mod>().ToHashSet();
@@ -0,0 +1,20 @@
// 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.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Rulesets.Mania
{
public enum ManiaMobileLayout
{
[LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.PortraitExpandedColumns))]
Portrait,
[LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.LandscapeExpandedColumns))]
Landscape,
[LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.LandscapeTouchOverlay))]
LandscapeWithOverlay,
}
}
@@ -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;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
@@ -44,8 +45,17 @@ namespace osu.Game.Rulesets.Mania
Keywords = new[] { "color" },
LabelText = RulesetSettingsStrings.TimingBasedColouring,
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
}
},
};
if (RuntimeInfo.IsMobile)
{
Add(new SettingsEnumDropdown<ManiaMobileLayout>
{
LabelText = RulesetSettingsStrings.MobileLayout,
Current = config.GetBindable<ManiaMobileLayout>(ManiaRulesetSetting.MobileLayout),
});
}
}
private partial class ManiaScrollSlider : RoundedSliderBar<double>
@@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Mania.Mods
public override bool Ranked => false;
public override bool ValidForFreestyleAsRequiredMod => false;
[SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")]
public override BindableNumber<float> Coverage { get; } = new BindableFloat(0.5f)
{
+1 -1
View File
@@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModEasy : ModEasyWithExtraLives
{
public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!";
public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and extra lives!";
}
}
@@ -14,6 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "FI";
public override LocalisableString Description => @"Keys appear out of nowhere!";
public override double ScoreMultiplier => 1;
public override bool ValidForFreestyleAsRequiredMod => false;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -42,8 +42,7 @@ namespace osu.Game.Rulesets.Mania.Mods
var locations = column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0)),
(startTime: h.EndTime, samples: h.GetNodeSamples(1))
(startTime: h.StartTime, samples: h.GetNodeSamples(0))
}))
.OrderBy(h => h.startTime).ToList();
@@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Mania.Mods
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
// apply perfect once the tail is reached
if (HoldNote.HoldStartTime != null && timeOffset >= 0)
if (HoldNote.IsHolding.Value && timeOffset >= 0)
ApplyResult(GetCappedResult(HitResult.Perfect));
else
base.CheckForResult(userTriggered, timeOffset);
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
@@ -9,13 +11,16 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModPerfect : ModPerfect
{
[SettingSource("Require perfect hits")]
public BindableBool RequirePerfectHits { get; } = new BindableBool();
protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result)
{
if (!isRelevantResult(result.Judgement.MinResult) && !isRelevantResult(result.Judgement.MaxResult) && !isRelevantResult(result.Type))
return false;
// Mania allows imperfect "Great" hits without failing.
if (result.Judgement.MaxResult == HitResult.Perfect)
if (result.Judgement.MaxResult == HitResult.Perfect && !RequirePerfectHits.Value)
return result.Type < HitResult.Great;
return result.Type != result.Judgement.MaxResult;
@@ -26,9 +26,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
: base(barLine)
{
RelativeSizeAxes = Axes.X;
Height = 1;
}
[BackgroundDependencyLoader]
[BackgroundDependencyLoader(true)]
private void load()
{
AddInternal(new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.BarLine), _ => new DefaultBarLine())
@@ -36,8 +37,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
Major.BindValueChanged(major => Height = major.NewValue ? 1.7f : 1.2f, true);
}
protected override void OnApply()
@@ -11,6 +11,8 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
@@ -29,9 +31,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public override bool DisplayResult => false;
public IBindable<bool> IsHitting => isHitting;
public IBindable<bool> IsHolding => isHolding;
private readonly Bindable<bool> isHitting = new Bindable<bool>();
private readonly Bindable<bool> isHolding = new Bindable<bool>();
public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child;
@@ -55,16 +57,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private SkinnableDrawable bodyPiece;
/// <summary>
/// Time at which the user started holding this hold note. Null if the user is not holding this hold note.
/// </summary>
public double? HoldStartTime { get; private set; }
/// <summary>
/// Used to decide whether to visually clamp the hold note to the judgement line.
/// </summary>
private double? releaseTime;
public DrawableHoldNote()
: this(null)
{
@@ -126,7 +118,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
base.LoadComplete();
isHitting.BindValueChanged(updateSlidingSample, true);
isHolding.BindValueChanged(updateSlidingSample, true);
}
protected override void OnApply()
@@ -134,8 +126,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
base.OnApply();
sizingContainer.Size = Vector2.One;
HoldStartTime = null;
releaseTime = null;
}
protected override void AddNestedHitObject(DrawableHitObject hitObject)
@@ -214,11 +204,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
base.Update();
if (Time.Current < releaseTime)
releaseTime = null;
if (Time.Current < HoldStartTime)
endHold();
isHolding.Value = Result.IsHolding(Time.Current);
// Pad the full size container so its contents (i.e. the masking container) reach under the tail.
// This is required for the tail to not be masked away, since it lies outside the bounds of the hold note.
@@ -249,7 +235,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
//
// As per stable, this should not apply for early hits, waiting until the object starts to touch the
// judgement area first.
if (Head.IsHit && releaseTime == null && DrawHeight > 0)
if (Head.IsHit && !Result.DroppedHoldAfter(HitObject.StartTime) && DrawHeight > 0)
{
// How far past the hit target this hold note is.
float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y;
@@ -260,6 +246,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
sizingContainer.Height = 1;
}
protected override JudgementResult CreateResult(Judgement judgement) => new HoldNoteJudgementResult(HitObject, judgement);
public new HoldNoteJudgementResult Result => (HoldNoteJudgementResult)base.Result;
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (Tail.AllJudged)
@@ -274,7 +264,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Body.TriggerResult(Tail.IsHit);
// Important that this is always called when a result is applied.
endHold();
Result.ReportHoldState(Time.Current, false);
}
}
@@ -283,7 +273,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
base.MissForcefully();
// Important that this is always called when a result is applied.
endHold();
Result.ReportHoldState(Time.Current, false);
}
public bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
@@ -317,8 +307,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss))
return;
HoldStartTime = Time.Current;
isHitting.Value = true;
Result.ReportHoldState(Time.Current, true);
}
public void OnReleased(KeyBindingReleaseEvent<ManiaAction> e)
@@ -337,22 +326,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
// the user has released too early (before the tail).
//
// In such a case, we want to record this against the DrawableHoldNoteBody.
if (HoldStartTime != null)
if (isHolding.Value)
{
Tail.UpdateResult();
Body.TriggerResult(Tail.IsHit);
endHold();
releaseTime = Time.Current;
Result.ReportHoldState(Time.Current, false);
}
}
private void endHold()
{
HoldStartTime = null;
isHitting.Value = false;
}
protected override void LoadSamples()
{
// Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.
@@ -2,6 +2,7 @@
// 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.Replays.Legacy;
using osu.Game.Rulesets.Replays;
@@ -47,5 +48,8 @@ namespace osu.Game.Rulesets.Mania.Replays
return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is ManiaReplayFrame maniaFrame && Time == maniaFrame.Time && Actions.SequenceEqual(maniaFrame.Actions);
}
}
@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
AccentColour.BindTo(holdNote.AccentColour);
hittingLayer.AccentColour.BindTo(holdNote.AccentColour);
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNote.IsHitting);
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNote.IsHolding);
}
AccentColour.BindValueChanged(colour =>
@@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour);
hittingLayer.IsHitting.UnbindBindings();
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting);
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHolding);
}
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
@@ -53,7 +53,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
}
}
private void onDirectionChanged() => Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position;
private void onDirectionChanged()
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position;
}
protected override SpriteText CreateJudgementText() =>
new OsuSpriteText
@@ -40,9 +40,13 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container =>
{
var leaderboard = container.OfType<DrawableGameplayLeaderboard>().FirstOrDefault();
var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
if (leaderboard != null)
leaderboard.Position = new Vector2(36, 115);
if (combo != null)
{
combo.ShowLabel.Value = false;
@@ -55,6 +59,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
spectatorList.Position = new Vector2(36, -66);
})
{
new DrawableGameplayLeaderboard(),
new ArgonManiaComboCounter(),
new SpectatorList
{
@@ -131,8 +136,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
switch (maniaLookup.Lookup)
{
case LegacyManiaSkinConfigurationLookups.ColumnSpacing:
return SkinUtils.As<TValue>(new Bindable<float>(2));
case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing:
case LegacyManiaSkinConfigurationLookups.RightColumnSpacing:
return SkinUtils.As<TValue>(new Bindable<float>(1));
case LegacyManiaSkinConfigurationLookups.StagePaddingBottom:
case LegacyManiaSkinConfigurationLookups.StagePaddingTop:
@@ -146,7 +152,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
return SkinUtils.As<TValue>(new Bindable<float>(width));
case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour:
var colour = getColourForLayout(columnIndex, stage);
return SkinUtils.As<TValue>(new Bindable<Color4>(colour));
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
[BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject)
{
RelativeSizeAxes = Axes.Both;
RelativeSizeAxes = Axes.X;
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
@@ -75,6 +75,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
private void updateMajor(ValueChangedEvent<bool> major)
{
Height = major.NewValue ? 1.7f : 1.2f;
mainLine.Alpha = major.NewValue ? 0.5f : 0.2f;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
}
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
var holdNote = (DrawableHoldNote)drawableObject;
AccentColour.BindTo(drawableObject.AccentColour);
IsHitting.BindTo(holdNote.IsHitting);
IsHitting.BindTo(holdNote.IsHolding);
}
AccentColour.BindValueChanged(onAccentChanged, true);
@@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyBarLine : CompositeDrawable
{
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
float skinHeight = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.BarLineHeight)?.Value ?? 1;
RelativeSizeAxes = Axes.X;
Height = 1.2f * skinHeight;
Colour = skin.GetManiaSkinConfig<Color4>(LegacyManiaSkinConfigurationLookups.BarLineColour)?.Value ?? Color4.White;
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
AddInternal(new Box
{
Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
});
}
}
}
@@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
var wrapMode = bodyStyle == LegacyNoteBodyStyle.Stretch ? WrapMode.ClampToEdge : WrapMode.Repeat;
direction.BindTo(scrollingInfo.Direction);
isHitting.BindTo(holdNote.IsHitting);
isHitting.BindTo(holdNote.IsHolding);
bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d =>
{
@@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
this.result = result;
this.animation = animation;
Anchor = Anchor.BottomCentre;
Origin = Anchor.Centre;
AutoSizeAxes = Axes.Both;
@@ -53,10 +52,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
float hitPosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0;
float scorePosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value ?? 0;
float absoluteHitPosition = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition;
float finalPosition = scorePosition - absoluteHitPosition;
float hitPositionFromTop = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition;
Y = direction.Value == ScrollingDirection.Up ? -finalPosition : finalPosition;
if (scorePosition > hitPositionFromTop / 2f)
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Y = direction.Value == ScrollingDirection.Up ? hitPositionFromTop - scorePosition : scorePosition - hitPositionFromTop;
}
else
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.BottomCentre : Anchor.TopCentre;
Y = direction.Value == ScrollingDirection.Up ? -scorePosition : scorePosition;
}
}
public void PlayAnimation()
@@ -98,6 +98,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
var leaderboard = container.OfType<DrawableGameplayLeaderboard>().FirstOrDefault();
if (combo != null)
{
@@ -112,10 +113,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = new Vector2(10, -10);
}
if (leaderboard != null)
{
leaderboard.Anchor = Anchor.CentreLeft;
leaderboard.Origin = Anchor.CentreLeft;
leaderboard.X = 10;
}
})
{
new LegacyManiaComboCounter(),
new SpectatorList(),
new DrawableGameplayLeaderboard(),
};
}
@@ -163,7 +172,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
return new LegacyStageForeground();
case ManiaSkinComponents.BarLine:
return null; // Not yet implemented.
return new LegacyBarLine();
default:
throw new UnsupportedSkinComponentException(lookup);
+27 -3
View File
@@ -12,6 +12,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Skinning;
@@ -57,6 +58,11 @@ namespace osu.Game.Rulesets.Mania.UI
public readonly Bindable<Color4> AccentColour = new Bindable<Color4>(Color4.Black);
private IBindable<ManiaMobileLayout> mobilePlayStyle = null!;
private float leftColumnSpacing;
private float rightColumnSpacing;
public Column(int index, bool isSpecial)
{
Index = index;
@@ -77,7 +83,7 @@ namespace osu.Game.Rulesets.Mania.UI
private ISkinSource skin { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(GameHost host)
private void load(GameHost host, ManiaRulesetConfigManager? rulesetConfig)
{
SkinnableDrawable keyArea;
@@ -115,11 +121,22 @@ namespace osu.Game.Rulesets.Mania.UI
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
RegisterPool<HoldNoteBody, DrawableHoldNoteBody>(10, 50);
if (rulesetConfig != null)
mobilePlayStyle = rulesetConfig.GetBindable<ManiaMobileLayout>(ManiaRulesetSetting.MobileLayout);
}
private void onSourceChanged()
{
AccentColour.Value = skin.GetManiaSkinConfig<Color4>(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black;
leftColumnSpacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, Index))
?.Value ?? Stage.COLUMN_SPACING;
rightColumnSpacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, Index))
?.Value ?? Stage.COLUMN_SPACING;
}
protected override void LoadComplete()
@@ -181,8 +198,11 @@ namespace osu.Game.Rulesets.Mania.UI
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
// This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border
=> DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos));
{
// Extend input coverage to the gaps close to this column.
var spacingInflation = new MarginPadding { Left = leftColumnSpacing, Right = rightColumnSpacing };
return DrawRectangle.Inflate(spacingInflation).Contains(ToLocalSpace(screenSpacePos));
}
#region Touch Input
@@ -193,6 +213,10 @@ namespace osu.Game.Rulesets.Mania.UI
protected override bool OnTouchDown(TouchDownEvent e)
{
// if touch overlay is visible, disallow columns from handling touch directly.
if (mobilePlayStyle.Value == ManiaMobileLayout.LandscapeWithOverlay)
return false;
maniaInputManager?.KeyBindingContainer.TriggerPressed(Action.Value);
touchActivationCount++;
return true;
+76 -30
View File
@@ -1,14 +1,19 @@
// 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 osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Layout;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Skinning;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Mania.UI
{
@@ -34,6 +39,8 @@ namespace osu.Game.Rulesets.Mania.UI
set => base.Masking = value;
}
private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize);
public ColumnFlow(StageDefinition stageDefinition)
{
this.stageDefinition = stageDefinition;
@@ -52,42 +59,32 @@ namespace osu.Game.Rulesets.Mania.UI
for (int i = 0; i < stageDefinition.Columns; i++)
columns.Add(new Container<TContent> { RelativeSizeAxes = Axes.Y });
AddLayout(layout);
}
private ISkinSource currentSkin;
[Resolved]
private ISkinSource skin { get; set; } = null!;
private readonly Bindable<ManiaMobileLayout> mobileLayout = new Bindable<ManiaMobileLayout>();
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
private void load(ManiaRulesetConfigManager? rulesetConfig)
{
currentSkin = skin;
rulesetConfig?.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout);
skin.SourceChanged += onSkinChanged;
onSkinChanged();
mobileLayout.BindValueChanged(_ => invalidateLayout());
skin.SourceChanged += invalidateLayout;
}
private void onSkinChanged()
protected override void Update()
{
for (int i = 0; i < stageDefinition.Columns; i++)
base.Update();
if (!layout.IsValid)
{
if (i > 0)
{
float spacing = currentSkin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, i - 1))
?.Value ?? Stage.COLUMN_SPACING;
columns[i].Margin = new MarginPadding { Left = spacing };
}
float? width = currentSkin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i))
?.Value;
bool isSpecialColumn = stageDefinition.IsSpecialColumn(i);
// only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration)
width ??= isSpecialColumn ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH;
columns[i].Width = width.Value;
updateColumnSize();
layout.Validate();
}
}
@@ -101,12 +98,61 @@ namespace osu.Game.Rulesets.Mania.UI
Content[column] = columns[column].Child = content;
}
private void invalidateLayout() => layout.Invalidate();
private void updateColumnSize()
{
float mobileAdjust = 1f;
if (RuntimeInfo.IsMobile && mobileLayout.Value == ManiaMobileLayout.Landscape)
{
// GridContainer+CellContainer containing this stage (gets split up for dual stages).
Vector2? containingCell = this.FindClosestParent<Stage>()?.Parent?.DrawSize;
// Will be null in tests.
if (containingCell != null && containingCell.Value.X >= containingCell.Value.Y)
{
float aspectRatio = containingCell.Value.X / containingCell.Value.Y;
// 2.83 is a mostly arbitrary scale-up (170 / 60, based on original implementation for argon)
mobileAdjust = 2.83f * Math.Min(1, 7f / stageDefinition.Columns);
// 1.92 is a "reference" mobile screen aspect ratio for phones.
// We should scale it back for cases like tablets which aren't so extreme.
mobileAdjust *= aspectRatio / 1.92f;
}
}
for (int i = 0; i < stageDefinition.Columns; i++)
{
float leftSpacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, i))
?.Value ?? Stage.COLUMN_SPACING;
float rightSpacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, i))
?.Value ?? Stage.COLUMN_SPACING;
columns[i].Margin = new MarginPadding { Left = leftSpacing, Right = rightSpacing };
float? width = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i))
?.Value;
bool isSpecialColumn = stageDefinition.IsSpecialColumn(i);
// only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration)
width ??= isSpecialColumn ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH;
columns[i].Width = width.Value * mobileAdjust;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (currentSkin != null)
currentSkin.SourceChanged -= onSkinChanged;
if (skin.IsNotNull())
skin.SourceChanged -= invalidateLayout;
}
}
}
@@ -29,7 +29,11 @@ namespace osu.Game.Rulesets.Mania.UI
direction.BindValueChanged(_ => onDirectionChanged(), true);
}
private void onDirectionChanged() => Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position;
private void onDirectionChanged()
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position;
}
protected override void LoadComplete()
{
@@ -3,30 +3,23 @@
#nullable disable
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.UI
{
public partial class DrawableManiaJudgement : DrawableJudgement
{
private IBindable<ScrollingDirection> direction;
[BackgroundDependencyLoader]
private void load(IScrollingInfo scrollingInfo)
public DrawableManiaJudgement()
{
direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => onDirectionChanged(), true);
}
private void onDirectionChanged()
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Origin = Anchor.Centre;
// Extend the dimensions of this drawable to the entire parenting container.
// This allows skin implementations (i.e. LegacyManiaJudgementPiece) to freely choose the anchor based on skin settings.
Anchor = Anchor.TopLeft;
Origin = Anchor.TopLeft;
RelativeSizeAxes = Axes.Both;
Size = new Vector2(1f);
}
protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result);
@@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Mania.UI
public IEnumerable<BarLine> BarLines;
public override bool RequiresPortraitOrientation => Beatmap.Stages.Count == 1;
public override bool RequiresPortraitOrientation => Beatmap.Stages.Count == 1 && mobileLayout.Value == ManiaMobileLayout.Portrait;
protected override bool RelativeScaleBeatLengths => true;
@@ -58,9 +58,11 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>();
private readonly BindableDouble configScrollSpeed = new BindableDouble();
private readonly Bindable<ManiaMobileLayout> mobileLayout = new Bindable<ManiaMobileLayout>();
public double TargetTimeRange { get; protected set; }
private double currentTimeRange;
protected double TargetTimeRange;
// Stores the current speed adjustment active in gameplay.
private readonly Track speedAdjustmentTrack = new TrackVirtual(0);
@@ -108,9 +110,37 @@ namespace osu.Game.Rulesets.Mania.UI
configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true);
Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed);
configScrollSpeed.BindValueChanged(speed => TargetTimeRange = ComputeScrollTime(speed.NewValue));
configScrollSpeed.BindValueChanged(speed =>
{
if (!AllowScrollSpeedAdjustment)
return;
TargetTimeRange = ComputeScrollTime(speed.NewValue);
});
TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value);
Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout);
mobileLayout.BindValueChanged(_ => updateMobileLayout(), true);
}
private ManiaTouchInputArea? touchInputArea;
private void updateMobileLayout()
{
switch (mobileLayout.Value)
{
case ManiaMobileLayout.LandscapeWithOverlay:
KeyBindingInputManager.Add(touchInputArea = new ManiaTouchInputArea(this));
break;
default:
if (touchInputArea != null)
KeyBindingInputManager.Remove(touchInputArea, true);
touchInputArea = null;
break;
}
}
protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount;
@@ -0,0 +1,209 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osuTK;
namespace osu.Game.Rulesets.Mania.UI
{
/// <summary>
/// An overlay that captures and displays osu!mania mouse and touch input.
/// </summary>
public partial class ManiaTouchInputArea : VisibilityContainer
{
private readonly DrawableManiaRuleset drawableRuleset;
// visibility state affects our child. we always want to handle input.
public override bool PropagatePositionalInputSubTree => true;
public override bool PropagateNonPositionalInputSubTree => true;
[SettingSource("Spacing", "The spacing between receptors.")]
public BindableFloat Spacing { get; } = new BindableFloat(10)
{
Precision = 1,
MinValue = 0,
MaxValue = 100,
};
[SettingSource("Opacity", "The receptor opacity.")]
public BindableFloat Opacity { get; } = new BindableFloat(1)
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 1
};
private GridContainer gridContainer = null!;
public ManiaTouchInputArea(DrawableManiaRuleset drawableRuleset)
{
this.drawableRuleset = drawableRuleset;
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
RelativeSizeAxes = Axes.Both;
Height = 0.5f;
}
[BackgroundDependencyLoader]
private void load()
{
List<Drawable> receptorGridContent = new List<Drawable>();
List<Dimension> receptorGridDimensions = new List<Dimension>();
bool first = true;
foreach (var stage in drawableRuleset.Playfield.Stages)
{
foreach (var column in stage.Columns)
{
if (!first)
{
receptorGridContent.Add(new Gutter { Spacing = { BindTarget = Spacing } });
receptorGridDimensions.Add(new Dimension(GridSizeMode.AutoSize));
}
receptorGridContent.Add(new ColumnInputReceptor
{
Action = { BindTarget = column.Action },
Spacing = { BindTarget = Spacing },
});
receptorGridDimensions.Add(new Dimension());
first = false;
}
}
InternalChild = gridContainer = new GridContainer
{
RelativeSizeAxes = Axes.Both,
AlwaysPresent = true,
Content = new[] { receptorGridContent.ToArray() },
ColumnDimensions = receptorGridDimensions.ToArray()
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Opacity.BindValueChanged(o => Alpha = o.NewValue, true);
}
protected override bool OnKeyDown(KeyDownEvent e)
{
// Hide whenever the keyboard is used.
Hide();
return false;
}
protected override bool OnTouchDown(TouchDownEvent e)
{
Show();
return true;
}
protected override void PopIn()
{
gridContainer.FadeIn(500, Easing.OutQuint);
}
protected override void PopOut()
{
gridContainer.FadeOut(300);
}
public partial class ColumnInputReceptor : CompositeDrawable
{
public readonly IBindable<ManiaAction> Action = new Bindable<ManiaAction>();
public readonly IBindable<float> Spacing = new BindableFloat();
private readonly Box highlightOverlay;
[Resolved]
private ManiaInputManager? inputManager { get; set; }
private bool isPressed;
public ColumnInputReceptor()
{
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0.15f,
},
highlightOverlay = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Blending = BlendingParameters.Additive,
}
}
}
};
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
// Extend input coverage to the gaps close to this receptor.
=> DrawRectangle.Inflate(new Vector2(Spacing.Value / 2, 0)).Contains(ToLocalSpace(screenSpacePos));
protected override bool OnTouchDown(TouchDownEvent e)
{
updateButton(true);
return false; // handled by parent container to show overlay.
}
protected override void OnTouchUp(TouchUpEvent e)
{
updateButton(false);
}
private void updateButton(bool press)
{
if (press == isPressed)
return;
isPressed = press;
if (press)
{
inputManager?.KeyBindingContainer.TriggerPressed(Action.Value);
highlightOverlay.FadeTo(0.1f, 80, Easing.OutQuint);
}
else
{
inputManager?.KeyBindingContainer.TriggerReleased(Action.Value);
highlightOverlay.FadeTo(0, 400, Easing.OutQuint);
}
}
}
private partial class Gutter : Drawable
{
public readonly IBindable<float> Spacing = new Bindable<float>();
public Gutter()
{
Spacing.BindValueChanged(s => Size = new Vector2(s.NewValue));
}
}
}
}
@@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene
{
protected sealed override Ruleset CreateRuleset() => new OsuRuleset();
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject);
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
}
@@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene
{
protected sealed override Ruleset CreateRuleset() => new OsuRuleset();
[SetUp]
public void Setup() => Schedule(() =>
{
@@ -13,6 +13,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene
{
protected sealed override Ruleset CreateRuleset() => new OsuRuleset();
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject);
protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
@@ -49,5 +49,59 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 2
});
[Test]
public void TestRewind()
{
bool seekedBack = false;
bool missRecorded = false;
CreateModTest(new ModTestData
{
Mod = new OsuModStrictTracking(),
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 1000,
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
new PathControlPoint(new Vector2(0, 100))
}
}
}
}
},
ReplayFrames = new List<ReplayFrame>
{
new OsuReplayFrame(0, new Vector2(100, 0)),
new OsuReplayFrame(1000, new Vector2(100, 0)),
new OsuReplayFrame(1050, new Vector2()),
new OsuReplayFrame(1100, new Vector2(), OsuAction.LeftButton),
new OsuReplayFrame(1750, new Vector2(0, 100), OsuAction.LeftButton),
new OsuReplayFrame(1751, new Vector2(0, 100)),
},
PassCondition = () => seekedBack && !missRecorded,
});
AddStep("subscribe to new judgements", () => Player.ScoreProcessor.NewJudgement += j =>
{
if (!j.IsHit)
missRecorded = true;
});
AddUntilStep("wait for gameplay completion", () => Player.GameplayState.HasCompleted);
AddAssert("no misses", () => missRecorded, () => Is.False);
AddStep("seek back", () =>
{
Player.GameplayClockContainer.Stop();
Player.Seek(1040);
seekedBack = true;
});
}
}
}
@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(6.7331304290522747d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9825709931204205d, 239, "diffcalc-test")]
[TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
[TestCase(0.55231632896800109d, 4, "very-fast-slider")]
[TestCase(9.6779746353001634d, 239, "diffcalc-test")]
[TestCase(1.7691451263718989d, 54, "zero-length-sliders")]
[TestCase(0.55785578988249407d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(6.7331304290522747d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
@@ -759,9 +759,9 @@ namespace osu.Game.Rulesets.Osu.Tests
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder
},
ControlPointInfo = cpi
ControlPointInfo = cpi,
BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder
});
playableBeatmap = Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo);
});
@@ -0,0 +1,248 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene
{
protected override Ruleset CreateRuleset() => new OsuRuleset();
protected override string? ExportLocation => null;
private static readonly object[][] no_mod_test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// Additionally, note that offsets provided in double will be rounded to the nearest integer.
// OD = 5 test cases.
// GREAT hit window is ( -50ms, 50ms)
// OK hit window is (-100ms, 100ms)
// MEH hit window is (-150ms, 150ms)
new object[] { 5f, 48d, HitResult.Great },
new object[] { 5f, 49d, HitResult.Great },
new object[] { 5f, 50d, HitResult.Ok },
new object[] { 5f, 51d, HitResult.Ok },
new object[] { 5f, 98d, HitResult.Ok },
new object[] { 5f, 99d, HitResult.Ok },
new object[] { 5f, 100d, HitResult.Meh },
new object[] { 5f, 101d, HitResult.Meh },
new object[] { 5f, 148d, HitResult.Meh },
new object[] { 5f, 149d, HitResult.Meh },
new object[] { 5f, 150d, HitResult.Miss },
new object[] { 5f, 151d, HitResult.Miss },
// OD = 5.7 test cases.
// GREAT hit window is ( -45ms, 45ms)
// OK hit window is ( -94ms, 94ms)
// MEH hit window is (-143ms, 143ms)
new object[] { 5.7f, 43d, HitResult.Great },
new object[] { 5.7f, 44d, HitResult.Great },
new object[] { 5.7f, 45d, HitResult.Ok },
new object[] { 5.7f, 46d, HitResult.Ok },
new object[] { 5.7f, 92d, HitResult.Ok },
new object[] { 5.7f, 93d, HitResult.Ok },
new object[] { 5.7f, 94d, HitResult.Meh },
new object[] { 5.7f, 95d, HitResult.Meh },
new object[] { 5.7f, 141d, HitResult.Meh },
new object[] { 5.7f, 142d, HitResult.Meh },
new object[] { 5.7f, 143d, HitResult.Miss },
new object[] { 5.7f, 144d, HitResult.Miss },
};
private static readonly object[][] hard_rock_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 7.
// GREAT hit window is ( -38ms, 38ms)
// OK hit window is ( -84ms, 84ms)
// MEH hit window is (-130ms, 130ms)
new object[] { 5f, 36d, HitResult.Great },
new object[] { 5f, 37d, HitResult.Great },
new object[] { 5f, 38d, HitResult.Ok },
new object[] { 5f, 39d, HitResult.Ok },
new object[] { 5f, 82d, HitResult.Ok },
new object[] { 5f, 83d, HitResult.Ok },
new object[] { 5f, 84d, HitResult.Meh },
new object[] { 5f, 85d, HitResult.Meh },
new object[] { 5f, 128d, HitResult.Meh },
new object[] { 5f, 129d, HitResult.Meh },
new object[] { 5f, 130d, HitResult.Miss },
new object[] { 5f, 131d, HitResult.Miss },
// OD = 8 test cases.
// This would lead to "effective" OD of 11.2,
// but the effects are capped to OD 10.
// GREAT hit window is ( -20ms, 20ms)
// OK hit window is ( -60ms, 60ms)
// MEH hit window is (-100ms, 100ms)
new object[] { 8f, 18d, HitResult.Great },
new object[] { 8f, 19d, HitResult.Great },
new object[] { 8f, 20d, HitResult.Ok },
new object[] { 8f, 21d, HitResult.Ok },
new object[] { 8f, 58d, HitResult.Ok },
new object[] { 8f, 59d, HitResult.Ok },
new object[] { 8f, 60d, HitResult.Meh },
new object[] { 8f, 61d, HitResult.Meh },
new object[] { 8f, 98d, HitResult.Meh },
new object[] { 8f, 99d, HitResult.Meh },
new object[] { 8f, 100d, HitResult.Miss },
new object[] { 8f, 101d, HitResult.Miss },
};
private static readonly object[][] easy_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 2.5.
// GREAT hit window is ( -65ms, 65ms)
// OK hit window is (-120ms, 120ms)
// MEH hit window is (-175ms, 175ms)
new object[] { 5f, 63d, HitResult.Great },
new object[] { 5f, 64d, HitResult.Great },
new object[] { 5f, 65d, HitResult.Ok },
new object[] { 5f, 66d, HitResult.Ok },
new object[] { 5f, 118d, HitResult.Ok },
new object[] { 5f, 119d, HitResult.Ok },
new object[] { 5f, 120d, HitResult.Meh },
new object[] { 5f, 121d, HitResult.Meh },
new object[] { 5f, 173d, HitResult.Meh },
new object[] { 5f, 174d, HitResult.Meh },
new object[] { 5f, 175d, HitResult.Miss },
new object[] { 5f, 176d, HitResult.Miss },
};
private const double hit_circle_time = 100;
[TestCaseSource(nameof(no_mod_test_cases))]
public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
// required for correct playback in stable
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2),
new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(hard_rock_test_cases))]
public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
// required for correct playback in stable
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2),
new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new OsuModHardRock()]
}
};
RunTest($@"HR single circle @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(easy_test_cases))]
public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
var replay = new Replay
{
Frames =
{
// required for correct playback in stable
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2),
new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new OsuModEasy()]
}
};
RunTest($@"EZ single circle @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static OsuBeatmap createBeatmap(float overallDifficulty)
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new OsuBeatmap
{
HitObjects =
{
new HitCircle
{
StartTime = hit_circle_time,
Position = OsuPlayfield.BASE_SIZE / 2
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
return beatmap;
}
}
}
@@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Rulesets.Osu.Replays;
@@ -26,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Tests
[Cached]
private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo);
private readonly StopwatchClock clock = new StopwatchClock();
[SetUpSteps]
public void SetUpSteps()
{
@@ -35,7 +38,10 @@ namespace osu.Game.Rulesets.Osu.Tests
{
new OsuPlayfieldAdjustmentContainer
{
Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()),
Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay())
{
Clock = new FramedClock(clock)
},
},
settings = new ReplayAnalysisSettings(config),
};
@@ -55,11 +61,23 @@ namespace osu.Game.Rulesets.Osu.Tests
settings.ShowAimMarkers.Value = true;
settings.ShowCursorPath.Value = true;
});
AddToggleStep("toggle pause", running =>
{
if (running)
clock.Stop();
else
clock.Start();
});
}
[Test]
public void TestHitMarkers()
{
AddStep("stop at 2000", () =>
{
clock.Stop();
clock.Seek(2000);
});
AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true);
AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible);
AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false);
@@ -69,6 +87,11 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestAimMarker()
{
AddStep("stop at 2000", () =>
{
clock.Stop();
clock.Seek(2000);
});
AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true);
AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible);
AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false);
@@ -78,6 +101,11 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestAimLines()
{
AddStep("stop at 2000", () =>
{
clock.Stop();
clock.Seek(2000);
});
AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true);
AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible);
AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false);
@@ -87,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private Replay fabricateReplay()
{
var frames = new List<ReplayFrame>();
var random = new Random();
var random = new Random(20250522);
int posX = 250;
int posY = 250;
@@ -109,7 +137,7 @@ namespace osu.Game.Rulesets.Osu.Tests
frames.Add(new OsuReplayFrame
{
Time = Time.Current + i * 15,
Time = i * 15,
Position = new Vector2(posX, posY),
Actions = actions.ToList(),
});
@@ -0,0 +1,97 @@
// 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.Allocation;
using osu.Framework.Audio;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Storyboards;
using osu.Game.Tests.Visual;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneReplayRecording : PlayerTestScene
{
protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
[Resolved]
private AudioManager audioManager { get; set; } = null!;
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap
{
HitObjects =
{
new HitCircle
{
Position = OsuPlayfield.BASE_SIZE / 2,
StartTime = 0,
},
new HitCircle
{
Position = OsuPlayfield.BASE_SIZE / 2,
StartTime = 5000,
},
new HitCircle
{
Position = OsuPlayfield.BASE_SIZE / 2,
StartTime = 10000,
},
new HitCircle
{
Position = OsuPlayfield.BASE_SIZE / 2,
StartTime = 15000,
}
}
};
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) =>
new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
[Test]
public void TestRecording()
{
seekTo(0);
AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single()));
AddStep("press X", () => InputManager.PressKey(Key.X));
seekTo(15);
AddStep("release X", () => InputManager.ReleaseKey(Key.X));
AddAssert("right button press recorded to replay", () => Player.Score.Replay.Frames.OfType<OsuReplayFrame>().Any(f => f.Actions.SequenceEqual([OsuAction.RightButton])));
seekTo(5000);
AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single()));
AddStep("press Z", () => InputManager.PressKey(Key.Z));
seekTo(5015);
AddStep("release Z", () => InputManager.ReleaseKey(Key.Z));
AddAssert("left button press recorded to replay", () => Player.Score.Replay.Frames.OfType<OsuReplayFrame>().Any(f => f.Actions.SequenceEqual([OsuAction.LeftButton])));
seekTo(10000);
AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single()));
AddStep("press C", () => InputManager.PressKey(Key.C));
seekTo(10015);
AddStep("release C", () => InputManager.ReleaseKey(Key.C));
AddAssert("smoke button press recorded to replay", () => Player.Score.Replay.Frames.OfType<OsuReplayFrame>().Any(f => f.Actions.SequenceEqual([OsuAction.Smoke])));
}
[Test]
public void TestPressAndReleaseOnSameFrame()
{
seekTo(0);
AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single()));
AddStep("press X", () => InputManager.PressKey(Key.X));
AddStep("release X", () => InputManager.ReleaseKey(Key.X));
AddAssert("right button press recorded to replay", () => Player.Score.Replay.Frames.OfType<OsuReplayFrame>().Any(f => f.Actions.SequenceEqual([OsuAction.RightButton])));
}
private void seekTo(double time)
{
AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time));
AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500));
}
}
}
@@ -0,0 +1,113 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Osu.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneReplayStability : ReplayStabilityTestScene
{
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// GREAT hit window is [ -50ms, 50ms]
// OK hit window is [-100ms, 100ms]
// MEH hit window is [-150ms, 150ms]
// MISS hit window is [-400ms, 400ms]
new object[] { 5f, 49d, HitResult.Great },
new object[] { 5f, 49.2d, HitResult.Great },
new object[] { 5f, 49.7d, HitResult.Great },
new object[] { 5f, 50d, HitResult.Great },
new object[] { 5f, 50.4d, HitResult.Ok },
new object[] { 5f, 50.9d, HitResult.Ok },
new object[] { 5f, 51d, HitResult.Ok },
new object[] { 5f, 99d, HitResult.Ok },
new object[] { 5f, 99.2d, HitResult.Ok },
new object[] { 5f, 99.7d, HitResult.Ok },
new object[] { 5f, 100d, HitResult.Ok },
new object[] { 5f, 100.4d, HitResult.Meh },
new object[] { 5f, 100.9d, HitResult.Meh },
new object[] { 5f, 101d, HitResult.Meh },
new object[] { 5f, 149d, HitResult.Meh },
new object[] { 5f, 149.2d, HitResult.Meh },
new object[] { 5f, 149.7d, HitResult.Meh },
new object[] { 5f, 150d, HitResult.Meh },
new object[] { 5f, 150.4d, HitResult.Miss },
new object[] { 5f, 150.9d, HitResult.Miss },
new object[] { 5f, 151d, HitResult.Miss },
// OD = 5.7 test cases.
// GREAT hit window is [ -45.8ms, 45.8ms]
// OK hit window is [ -94.4ms, 94.4ms]
// MEH hit window is [-143.0ms, 143.0ms]
// MISS hit window is [-400.0ms, 400.0ms]
new object[] { 5.7f, 45d, HitResult.Great },
new object[] { 5.7f, 45.2d, HitResult.Great },
new object[] { 5.7f, 45.8d, HitResult.Great },
new object[] { 5.7f, 45.9d, HitResult.Ok },
new object[] { 5.7f, 46d, HitResult.Ok },
new object[] { 5.7f, 46.4d, HitResult.Ok },
new object[] { 5.7f, 94d, HitResult.Ok },
new object[] { 5.7f, 94.2d, HitResult.Ok },
new object[] { 5.7f, 94.4d, HitResult.Ok },
new object[] { 5.7f, 94.48d, HitResult.Ok },
new object[] { 5.7f, 94.9d, HitResult.Meh },
new object[] { 5.7f, 95d, HitResult.Meh },
new object[] { 5.7f, 95.4d, HitResult.Meh },
new object[] { 5.7f, 142d, HitResult.Meh },
new object[] { 5.7f, 142.7d, HitResult.Meh },
new object[] { 5.7f, 143d, HitResult.Meh },
new object[] { 5.7f, 143.4d, HitResult.Miss },
new object[] { 5.7f, 143.9d, HitResult.Miss },
new object[] { 5.7f, 144d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double hit_circle_time = 100;
var beatmap = new OsuBeatmap
{
HitObjects =
{
new HitCircle
{
StartTime = hit_circle_time,
Position = OsuPlayfield.BASE_SIZE / 2
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
},
};
var replay = new Replay
{
Frames =
{
new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2),
new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2),
}
};
RunTest(beatmap, replay, [expectedResult]);
}
}
}
@@ -86,9 +86,12 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestSpinningSamplePitchShift()
{
PausableSkinnableSound spinSample = null;
AddStep("Add spinner", () => SetContents(_ => testSingle(5, true, 4000)));
AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8);
AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8);
AddUntilStep("wait for spin sample", () => (spinSample = getSpinningSample()) != null);
AddUntilStep("Pitch starts low", () => spinSample.Frequency.Value < 0.8);
AddUntilStep("Pitch increases", () => spinSample.Frequency.Value > 0.8);
PausableSkinnableSound getSpinningSample() =>
drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
@@ -22,6 +22,7 @@ using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Storyboards;
using osu.Game.Tests;
using osu.Game.Tests.Visual;
using osuTK;
@@ -107,6 +108,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}
[Test]
[FlakyTest]
public void TestVibrateWithoutSpinningOnCentreWithDoubleTime()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
+8 -4
View File
@@ -1,10 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Beatmaps
@@ -16,26 +16,30 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
int circles = HitObjects.Count(c => c is HitCircle);
int sliders = HitObjects.Count(s => s is Slider);
int spinners = HitObjects.Count(s => s is Spinner);
int sum = Math.Max(1, circles + sliders);
return new[]
{
new BeatmapStatistic
{
Name = BeatmapsetsStrings.ShowStatsCountCircles,
Name = "Circles",
Content = circles.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles),
BarDisplayLength = circles / (float)sum,
},
new BeatmapStatistic
{
Name = BeatmapsetsStrings.ShowStatsCountSliders,
Name = "Sliders",
Content = sliders.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders),
BarDisplayLength = sliders / (float)sum,
},
new BeatmapStatistic
{
Name = @"Spinner Count",
Name = @"Spinners",
Content = spinners.ToString(),
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners),
BarDisplayLength = Math.Min(spinners / 10f, 1),
}
};
}
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
ComboOffset = comboData?.ComboOffset ?? 0,
// 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,
TickDistanceMultiplier = beatmap.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(original.StartTime).SliderVelocity : 1,
GenerateTicks = generateTicksData?.GenerateTicks ?? true,
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1,
}.Yield();
@@ -15,7 +15,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
{
public class OsuBeatmapProcessor : BeatmapProcessor
{
private const int stack_distance = 3;
/// <summary>
/// The maximum distance between the end of one object and the start of another
/// which allows the objects to be stacked on top of another.
/// </summary>
public const int STACK_DISTANCE = 3;
public OsuBeatmapProcessor(IBeatmap beatmap)
: base(beatmap)
@@ -55,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
foreach (var h in hitObjects)
h.StackHeight = 0;
if (beatmap.BeatmapInfo.BeatmapVersion >= 6)
if (beatmap.BeatmapVersion >= 6)
applyStacking(beatmap, hitObjects, 0, hitObjects.Count - 1);
else
applyStackingOld(beatmap, hitObjects);
@@ -93,8 +97,8 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
// We are no longer within stacking range of the next object.
break;
if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < stack_distance
|| (stackBaseObject is Slider && Vector2Extensions.Distance(stackBaseObject.EndPosition, objectN.Position) < stack_distance))
if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < STACK_DISTANCE
|| (stackBaseObject is Slider && Vector2Extensions.Distance(stackBaseObject.EndPosition, objectN.Position) < STACK_DISTANCE))
{
stackBaseIndex = n;
@@ -163,7 +167,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
* o <- hitCircle has stack of -1
* o <- hitCircle has stack of -2
*/
if (objectN is Slider && Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance)
if (objectN is Slider && Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < STACK_DISTANCE)
{
int offset = objectI.StackHeight - objectN.StackHeight + 1;
@@ -171,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
{
// For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
OsuHitObject objectJ = hitObjects[j];
if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance)
if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < STACK_DISTANCE)
objectJ.StackHeight -= offset;
}
@@ -180,7 +184,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
break;
}
if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < stack_distance)
if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < STACK_DISTANCE)
{
// Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out.
//NOTE: Sliders with start positions stacking are a special case that is also handled here.
@@ -204,7 +208,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
// We are no longer within stacking range of the previous object.
break;
if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance)
if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < STACK_DISTANCE)
{
objectN.StackHeight = objectI.StackHeight + 1;
objectI = objectN;
@@ -245,12 +249,12 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
// Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
// if we use `EndTime` here it would result in unexpected stacking.
if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < stack_distance)
if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < STACK_DISTANCE)
{
currHitObject.StackHeight++;
startTime = hitObjects[j].StartTime;
}
else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < stack_distance)
else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < STACK_DISTANCE)
{
// Case for sliders - bump notes down and right, rather than up and left.
sliderStack++;
@@ -12,9 +12,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 1.95;
private const double acute_angle_multiplier = 2.6;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
@@ -64,16 +65,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double acuteAngleBonus = 0;
double sliderBonus = 0;
double velocityChangeBonus = 0;
double wiggleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same.
{
if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null)
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
double lastLastAngle = osuLastLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
@@ -81,20 +82,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2.
acuteAngleBonus = 0;
else
{
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
* Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter.
}
// Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)));
// Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3)));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
}
}
@@ -122,6 +130,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
aimStrain += wiggleBonus * wiggle_multiplier;
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier);
@@ -132,8 +142,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
return aimStrain;
}
private static double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2);
private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
private static double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle);
private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -52,12 +52,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var currentObj = (OsuDifficultyHitObject)current.Previous(i);
var currentHitObject = (OsuHitObject)(currentObj.BaseObject);
cumulativeStrainTime += lastObj.StrainTime;
if (!(currentObj.BaseObject is Spinner))
{
double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.StackedEndPosition).Length;
cumulativeStrainTime += lastObj.StrainTime;
// We want to nerf objects that can be easily seen within the Flashlight circle radius.
if (i == 0)
smallDistNerf = Math.Min(1.0, jumpDistance / 75.0);
@@ -2,9 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@@ -14,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94;
private const double distance_multiplier = 0.9;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
@@ -24,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and how easily they can be cheesed.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
{
if (current.BaseObject is Spinner)
return 0;
@@ -56,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0;
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
@@ -8,6 +8,7 @@ using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty
{
@@ -19,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("aim_difficulty")]
public double AimDifficulty { get; set; }
/// <summary>
/// The number of <see cref="Slider"/>s weighted by difficulty.
/// </summary>
[JsonProperty("aim_difficult_slider_count")]
public double AimDifficultSliderCount { get; set; }
/// <summary>
/// The difficulty corresponding to the speed skill.
/// </summary>
@@ -52,24 +59,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; }
/// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
/// <summary>
/// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("overall_difficulty")]
public double OverallDifficulty { get; set; }
/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
@@ -97,8 +86,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM, AimDifficulty);
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightDifficulty())
@@ -109,6 +96,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -117,14 +105,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
AimDifficulty = values[ATTRIB_ID_AIM];
SpeedDifficulty = values[ATTRIB_ID_SPEED];
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;

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