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

Compare commits

...

1252 Commits

592 changed files with 29051 additions and 8455 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 }}
+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
+4
View File
@@ -21,3 +21,7 @@ M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberiz
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);
}
}
@@ -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.321.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.604.1" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+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());
}
@@ -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),
}
};
}
@@ -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,
@@ -6,6 +6,7 @@ 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;
@@ -36,6 +37,24 @@ 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 ExtendedIconInformation
{
get
{
if (UserAdjustedSettingsCount != 1)
return string.Empty;
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
+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);
}
}
@@ -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,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.Graphics;
using osu.Framework.Graphics.Containers;
@@ -68,7 +69,7 @@ namespace osu.Game.Rulesets.Catch.UI
// 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(base_aspect_ratio / aspectRatio);
scaleContainer.Scale = new Vector2(Math.Min(1, base_aspect_ratio / aspectRatio));
}
}
@@ -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
};
}
}
}
@@ -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; }
}
}
}
@@ -18,7 +18,12 @@ namespace osu.Game.Rulesets.Mania.Tests
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[SetUp]
public void SetUp() => Schedule(() => toggleTouchControls(false));
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
@@ -71,6 +76,35 @@ 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
@@ -132,6 +166,38 @@ namespace osu.Game.Rulesets.Mania.Tests
() => 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)
@@ -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;
@@ -25,17 +24,6 @@ namespace osu.Game.Rulesets.Mania.Configuration
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait);
#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
}
public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
@@ -52,8 +40,6 @@ namespace osu.Game.Rulesets.Mania.Configuration
public enum ManiaRulesetSetting
{
[Obsolete("Use ScrollSpeed instead.")] // Can be removed 2023-11-30
ScrollTime,
ScrollSpeed,
ScrollDirection,
TimingBasedNoteColouring,
@@ -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,
@@ -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();
@@ -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[]
{
@@ -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;
@@ -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)
@@ -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));
@@ -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);
@@ -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 =>
{
@@ -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(),
};
}
+16 -2
View File
@@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Mania.UI
private IBindable<ManiaMobileLayout> mobilePlayStyle = null!;
private float leftColumnSpacing;
private float rightColumnSpacing;
public Column(int index, bool isSpecial)
{
Index = index;
@@ -126,6 +129,14 @@ namespace osu.Game.Rulesets.Mania.UI
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()
@@ -187,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
+7 -6
View File
@@ -124,14 +124,15 @@ namespace osu.Game.Rulesets.Mania.UI
for (int i = 0; i < stageDefinition.Columns; i++)
{
if (i > 0)
{
float spacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, i - 1))
float leftSpacing = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, i))
?.Value ?? Stage.COLUMN_SPACING;
columns[i].Margin = new MarginPadding { Left = 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))
@@ -60,8 +60,9 @@ namespace osu.Game.Rulesets.Mania.UI
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);
@@ -109,7 +110,13 @@ 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);
@@ -74,6 +74,7 @@ namespace osu.Game.Rulesets.Mania.UI
receptorGridContent.Add(new ColumnInputReceptor
{
Action = { BindTarget = column.Action },
Spacing = { BindTarget = Spacing },
});
receptorGridDimensions.Add(new Dimension());
@@ -122,6 +123,7 @@ namespace osu.Game.Rulesets.Mania.UI
public partial class ColumnInputReceptor : CompositeDrawable
{
public readonly IBindable<ManiaAction> Action = new Bindable<ManiaAction>();
public readonly IBindable<float> Spacing = new BindableFloat();
private readonly Box highlightOverlay;
@@ -159,6 +161,10 @@ namespace osu.Game.Rulesets.Mania.UI
};
}
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);
@@ -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;
});
}
}
}
@@ -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),
}
};
}
@@ -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)
@@ -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++;
@@ -275,6 +275,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
else
{
double minDistance = distanceSnapProvider?.GetBeatSnapDistance() * oldVelocityMultiplier ?? 1;
// do not allow the slider to extend beyond the path's calculated distance.
// this can happen in two specific circumstances:
// - floating point issues (`minDistance` is just ever so slightly larger than the calculated distance)
// - the slider was placed with a higher beat snap active than the current one,
// therefore snapping it to the current beat snap distance would mean extrapolating it beyond its actual shape as defined by its control points
minDistance = Math.Min(minDistance, HitObject.Path.CalculatedDistance);
// Add a small amount to the proposed distance to make it easier to snap to the full length of the slider.
proposedDistance = distanceSnapProvider?.FindSnappedDistance((float)proposedDistance + 1, HitObject.StartTime, HitObject) ?? proposedDistance;
proposedDistance = Math.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance);
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Osu.Edit.Checks;
@@ -16,6 +17,7 @@ namespace osu.Game.Rulesets.Osu.Edit
// Compose
new CheckOffscreenObjects(),
new CheckTooShortSpinners(),
new CheckConcurrentObjects(),
// Spread
new CheckTimeDistanceEquality(),
@@ -7,6 +7,7 @@ using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
@@ -16,10 +17,15 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
{
// If the pair of hit objects in question here could feasibly be on the same stack, do not provide a distance snap value -
// they're likely too close to one another for the distance snap value to be useful anyway even if they somehow are not.
if (Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position) < OsuBeatmapProcessor.STACK_DISTANCE)
return 0;
var lastObjectWithVelocity = EditorBeatmap.HitObjects.TakeWhile(ho => ho != after).OfType<IHasSliderVelocity>().LastOrDefault();
float expectedDistance = DurationToDistance(after.StartTime - before.GetEndTime(), before.StartTime, lastObjectWithVelocity);
float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position);
float actualDistance = Vector2.Distance(((OsuHitObject)before).StackedEndPosition, ((OsuHitObject)after).StackedPosition);
return actualDistance / expectedDistance;
}
@@ -254,11 +254,15 @@ namespace osu.Game.Rulesets.Osu.Edit
[CanBeNull]
public SnapResult TrySnapToDistanceGrid(Vector2 screenSpacePosition, double? fixedTime = null)
{
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True || distanceSnapGrid == null)
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True || distanceSnapGrid?.IsLoaded != true)
return null;
var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition);
(Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition), fixedTime);
if (pos.X < 0 || pos.X > OsuPlayfield.BASE_SIZE.X || pos.Y < 0 || pos.Y > OsuPlayfield.BASE_SIZE.Y)
return null;
return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, playfield);
}
@@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Osu.Edit
Current = new BindableNumber<int>(3)
{
MinValue = 3,
MaxValue = 10,
MaxValue = 32,
Precision = 1,
},
Instantaneous = true
@@ -91,6 +91,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
KeyboardStep = 0.1f,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
@@ -105,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
KeyboardStep = 1,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
@@ -119,6 +121,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{
Caption = "Stack Leniency",
HintText = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.",
KeyboardStep = 0.1f,
Current = new BindableFloat(Beatmap.StackLeniency)
{
Default = 0.7f,
@@ -7,6 +7,7 @@ using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects;
@@ -36,6 +37,24 @@ namespace osu.Game.Rulesets.Osu.Mods
ReadCurrentFromDifficulty = diff => diff.ApproachRate,
};
public override string ExtendedIconInformation
{
get
{
if (UserAdjustedSettingsCount != 1)
return string.Empty;
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
+1 -1
View File
@@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModEasy : ModEasyWithExtraLives
{
public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!";
public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and extra lives!";
}
}
@@ -43,7 +43,10 @@ namespace osu.Game.Rulesets.Osu.Mods
foreach (var obj in beatmap.HitObjects.OfType<OsuHitObject>())
{
if (obj.NewCombo) { lastNewComboTime = obj.StartTime; }
if (obj.NewCombo)
{
lastNewComboTime = obj.StartTime;
}
applyFadeInAdjustment(obj);
}
+1 -1
View File
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
var spinner = (DrawableSpinner)drawable;
spinner.RotationTracker.Tracking = true;
spinner.RotationTracker.Tracking = spinner.RotationTracker.IsSpinnableTime;
// early-return if we were paused to avoid division-by-zero in the subsequent calculations.
if (Precision.AlmostEquals(spinner.Clock.Rate, 0))
@@ -16,6 +16,7 @@ using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.Osu.Mods
{
@@ -39,6 +40,9 @@ namespace osu.Game.Rulesets.Osu.Mods
if (slider.Time.Current < slider.HitObject.StartTime)
return;
if ((slider.Clock as IGameplayClock)?.IsRewinding == true)
return;
var tail = slider.NestedHitObjects.OfType<StrictTrackingDrawableSliderTail>().First();
if (!tail.Judged)
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -149,5 +150,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected float CalculateDrawableRelativePosition(Drawable drawable) => (drawable.ScreenSpaceDrawQuad.Centre.X - parentScreenSpaceRectangle.X) / parentScreenSpaceRectangle.Width;
protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(HitObject, judgement);
protected void ApplyRepeatFadeIn(Drawable target, double fadeTime)
{
DrawableSlider slider = (DrawableSlider)ParentHitObject;
int repeatIndex = ((SliderEndCircle)HitObject).RepeatIndex;
Debug.Assert(slider != null);
// When snaking in is enabled, the first end circle needs to be delayed until the snaking completes.
bool delayFadeIn = slider.SliderBody?.SnakingIn.Value == true && repeatIndex == 0;
if (repeatIndex > 0)
fadeTime = Math.Min(slider.HitObject.SpanDuration, fadeTime);
target
.FadeOut()
.Delay(delayFadeIn ? (slider.HitObject.TimePreempt) / 3 : 0)
.FadeIn(fadeTime);
}
}
}
@@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject;
private double animDuration;
public SkinnableDrawable CirclePiece { get; private set; }
public SkinnableDrawable Arrow { get; private set; }
@@ -87,21 +85,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void UpdateInitialTransforms()
{
// When snaking in is enabled, the first end circle needs to be delayed until the snaking completes.
bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0;
base.UpdateInitialTransforms();
animDuration = Math.Min(300, HitObject.SpanDuration);
this
.FadeOut()
.Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0)
.FadeIn(HitObject.RepeatIndex == 0 ? HitObject.TimeFadeIn : animDuration);
ApplyRepeatFadeIn(CirclePiece, HitObject.TimeFadeIn);
ApplyRepeatFadeIn(Arrow, 150);
}
protected override void UpdateHitStateTransforms(ArmedState state)
{
base.UpdateHitStateTransforms(state);
double animDuration = Math.Min(300, HitObject.SpanDuration);
switch (state)
{
case ArmedState.Idle:
@@ -181,10 +176,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// More or less matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338)
AccentColour.Value = Color4.White;
Alpha = Interpolation.ValueAt(Time.Current, 1f, 0f, HitStateUpdateTime, HitStateUpdateTime + 700);
Arrow.Alpha = 0;
}
Arrow.Alpha = hit ? 0 : 1;
LifetimeEnd = HitStateUpdateTime + 700;
}
@@ -86,13 +86,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
base.UpdateInitialTransforms();
// When snaking in is enabled, the first end circle needs to be delayed until the snaking completes.
bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0;
CirclePiece
.FadeOut()
.Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0)
.FadeIn(HitObject.TimeFadeIn);
ApplyRepeatFadeIn(CirclePiece, HitObject.TimeFadeIn);
}
protected override void UpdateHitStateTransforms(ArmedState state)
@@ -277,13 +277,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
base.Update();
if (HandleUserInput)
{
bool isValidSpinningTime = Time.Current >= HitObject.StartTime && Time.Current <= HitObject.EndTime;
RotationTracker.Tracking = !Result.HasResult
&& correctButtonPressed()
&& isValidSpinningTime;
}
RotationTracker.Tracking = RotationTracker.IsSpinnableTime && !Result.HasResult && correctButtonPressed();
if (spinningSample != null && spinnerFrequencyModulate)
spinningSample.Frequency.Value = spinning_sample_modulated_base_frequency + Progress;
@@ -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.Osu.Replays
return new LegacyReplayFrame(Time, Position.X, Position.Y, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is OsuReplayFrame osuFrame && Time == osuFrame.Time && Position == osuFrame.Position && Actions.SequenceEqual(osuFrame.Actions);
}
}
@@ -85,9 +85,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration);
Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.5f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out));
// When hit, don't animate further. This avoids a scale being applied on a scale and looking very weird.
return;
}
else
Scale = Vector2.One;
Scale = Vector2.One;
const float move_distance = -12;
const float scale_amount = 1.3f;
@@ -3,12 +3,16 @@
using osu.Framework.Extensions.Color4Extensions;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonSliderBody : PlaySliderBody
{
// Eventually this would be a user setting.
public float BodyAlpha { get; init; } = 1;
protected override void LoadComplete()
{
const float path_radius = ArgonMainCirclePiece.OUTER_GRADIENT_SIZE / 2;
@@ -26,6 +30,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
protected override Default.DrawableSliderPath CreateSliderPath() => new DrawableSliderPath();
protected override Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour)
{
return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(BodyAlpha);
}
private partial class DrawableSliderPath : Default.DrawableSliderPath
{
protected override Color4 ColourAt(float position)
@@ -16,13 +16,15 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
bool isPro = Skin is ArgonProSkin;
switch (lookup)
{
case SkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
// This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && (result == HitResult.Great || result == HitResult.Perfect))
if (isPro && (result == HitResult.Great || result == HitResult.Perfect))
return Drawable.Empty();
switch (result)
@@ -46,7 +48,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
return new ArgonMainCirclePiece(false);
case OsuSkinComponents.SliderBody:
return new ArgonSliderBody();
return new ArgonSliderBody
{
BodyAlpha = isPro ? 0.92f : 0.98f
};
case OsuSkinComponents.SliderBall:
return new ArgonSliderBall();
@@ -44,10 +44,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
SnakingOut.BindTo(configSnakingOut);
BorderColour = skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBorder)?.Value ?? Color4.White;
BorderColour = GetBorderColour(skin);
}
protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) =>
skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderTrackOverride)?.Value ?? hitObjectAccentColour;
protected virtual Color4 GetBorderColour(ISkinSource skin) => Color4.White;
protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) => hitObjectAccentColour;
}
}
@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
/// <summary>
/// Whether currently in the correct time range to allow spinning.
/// </summary>
private bool isSpinnableTime => drawableSpinner.HitObject.StartTime <= Time.Current && drawableSpinner.HitObject.EndTime > Time.Current;
public bool IsSpinnableTime => drawableSpinner.HitObject.StartTime <= Time.Current && drawableSpinner.HitObject.EndTime > Time.Current;
protected override bool OnMouseMove(MouseMoveEvent e)
{
@@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
lastAngle = thisAngle;
}
IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
IsSpinning.Value = IsSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed));
}
@@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
/// <param name="delta">The delta angle.</param>
public void AddRotation(float delta)
{
if (!isSpinnableTime)
if (!IsSpinnableTime)
return;
if (!rotationTransferred)
@@ -15,11 +15,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
protected override DrawableSliderPath CreateSliderPath() => new LegacyDrawableSliderPath();
protected override Color4 GetBorderColour(ISkinSource skin)
=> skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBorder)?.Value ?? Color4.White;
protected override Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour)
{
// legacy skins use a constant value for slider track alpha, regardless of the source colour.
return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(0.7f);
}
=> (skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderTrackOverride)?.Value ?? hitObjectAccentColour).Opacity(0.7f);
private partial class LegacyDrawableSliderPath : DrawableSliderPath
{
@@ -72,6 +72,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
var leaderboard = container.OfType<DrawableGameplayLeaderboard>().FirstOrDefault();
Vector2 pos = new Vector2();
@@ -89,6 +90,16 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
spectatorList.Anchor = Anchor.BottomLeft;
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = pos;
// maximum height of the spectator list is around ~172 units
pos += new Vector2(0, -185);
}
if (leaderboard != null)
{
leaderboard.Anchor = Anchor.BottomLeft;
leaderboard.Origin = Anchor.BottomLeft;
leaderboard.Position = pos;
}
})
{
@@ -97,6 +108,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
new LegacyDefaultComboCounter(),
new LegacyKeyCounterDisplay(),
new SpectatorList(),
new DrawableGameplayLeaderboard(),
}
};
}
@@ -0,0 +1,243 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
public partial class TestSceneTaikoModSimplifiedRhythm : TaikoModTestScene
{
[Test]
public void TestOneThirdConversion()
{
CreateModTest(new ModTestData
{
Mod = new TaikoModSimplifiedRhythm
{
OneThirdConversion = { Value = true },
},
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Hit { StartTime = 1000, Type = HitType.Centre },
new Hit { StartTime = 1500, Type = HitType.Centre },
new Hit { StartTime = 2000, Type = HitType.Centre },
new Hit { StartTime = 2333, Type = HitType.Rim }, // mod removes this
new Hit { StartTime = 2666, Type = HitType.Centre }, // mod moves this to 2500
new Hit { StartTime = 3000, Type = HitType.Centre },
new Hit { StartTime = 3500, Type = HitType.Centre },
},
},
ReplayFrames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000, TaikoAction.LeftCentre),
new TaikoReplayFrame(1200),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre),
new TaikoReplayFrame(1700),
new TaikoReplayFrame(2000, TaikoAction.LeftCentre),
new TaikoReplayFrame(2200),
new TaikoReplayFrame(2500, TaikoAction.LeftCentre),
new TaikoReplayFrame(2700),
new TaikoReplayFrame(3000, TaikoAction.LeftCentre),
new TaikoReplayFrame(3200),
new TaikoReplayFrame(3500, TaikoAction.LeftCentre),
new TaikoReplayFrame(3700),
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1
});
}
[Test]
public void TestOneSixthConversion() => CreateModTest(new ModTestData
{
Mod = new TaikoModSimplifiedRhythm
{
OneSixthConversion = { Value = true }
},
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Hit { StartTime = 1000, Type = HitType.Centre },
new Hit { StartTime = 1250, Type = HitType.Centre },
new Hit { StartTime = 1500, Type = HitType.Centre },
new Hit { StartTime = 1666, Type = HitType.Rim }, // mod removes this
new Hit { StartTime = 1833, Type = HitType.Centre }, // mod moves this to 1750
new Hit { StartTime = 2000, Type = HitType.Centre },
new Hit { StartTime = 2250, Type = HitType.Centre },
},
},
ReplayFrames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000, TaikoAction.LeftCentre),
new TaikoReplayFrame(1200),
new TaikoReplayFrame(1250, TaikoAction.LeftCentre),
new TaikoReplayFrame(1450),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre),
new TaikoReplayFrame(1600),
new TaikoReplayFrame(1750, TaikoAction.LeftCentre),
new TaikoReplayFrame(1800),
new TaikoReplayFrame(2000, TaikoAction.LeftCentre),
new TaikoReplayFrame(2200),
new TaikoReplayFrame(2250, TaikoAction.LeftCentre),
new TaikoReplayFrame(2450),
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1
});
[Test]
public void TestOneEighthConversion() => CreateModTest(new ModTestData
{
Mod = new TaikoModSimplifiedRhythm
{
OneEighthConversion = { Value = true }
},
Autoplay = false,
CreateBeatmap = () =>
{
const double one_eighth_timing = 125;
return new Beatmap
{
HitObjects = new List<HitObject>
{
new Hit { StartTime = 1000, Type = HitType.Centre },
new Hit { StartTime = 1250, Type = HitType.Centre },
new Hit { StartTime = 1500, Type = HitType.Centre },
new Hit { StartTime = 1500 + one_eighth_timing * 1, Type = HitType.Rim }, // mod removes this
new Hit { StartTime = 1500 + one_eighth_timing * 2 },
new Hit { StartTime = 2000, Type = HitType.Centre },
new Hit { StartTime = 2000 + one_eighth_timing * 1, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2000 + one_eighth_timing * 2, Type = HitType.Centre },
new Hit { StartTime = 2000 + one_eighth_timing * 3, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2000 + one_eighth_timing * 4, Type = HitType.Centre },
new Hit { StartTime = 2000 + one_eighth_timing * 5, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2000 + one_eighth_timing * 6, Type = HitType.Centre },
new Hit { StartTime = 2000 + one_eighth_timing * 7, Type = HitType.Centre }, // mod removes this
},
};
},
ReplayFrames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000, TaikoAction.LeftCentre),
new TaikoReplayFrame(1000),
new TaikoReplayFrame(1250, TaikoAction.LeftCentre),
new TaikoReplayFrame(1250),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre),
new TaikoReplayFrame(1500),
new TaikoReplayFrame(1750, TaikoAction.LeftCentre),
new TaikoReplayFrame(1750),
new TaikoReplayFrame(2000, TaikoAction.LeftCentre),
new TaikoReplayFrame(2000),
new TaikoReplayFrame(2250, TaikoAction.LeftCentre),
new TaikoReplayFrame(2250),
new TaikoReplayFrame(2500, TaikoAction.LeftCentre),
new TaikoReplayFrame(2500),
new TaikoReplayFrame(2750, TaikoAction.LeftCentre),
new TaikoReplayFrame(2750),
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 8 && Player.ScoreProcessor.Accuracy.Value == 1
});
/// <summary>
/// Regression tests a case of 1/3rd conversion where there are exactly div-3 number of hitobjects.
/// </summary>
[Test]
public void TestOnlyOneThirdConversion()
{
CreateModTest(new ModTestData
{
Mod = new TaikoModSimplifiedRhythm
{
OneThirdConversion = { Value = true },
},
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Hit { StartTime = 1000, Type = HitType.Centre },
new Hit { StartTime = 1333, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 1666, Type = HitType.Centre }, // mod moves this to 1500
new Hit { StartTime = 2000, Type = HitType.Centre },
new Hit { StartTime = 2333, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2666, Type = HitType.Centre }, // mod moves this to 2500
},
},
ReplayFrames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000, TaikoAction.LeftCentre),
new TaikoReplayFrame(1200),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre),
new TaikoReplayFrame(1700),
new TaikoReplayFrame(2000, TaikoAction.LeftCentre),
new TaikoReplayFrame(2200),
new TaikoReplayFrame(2500, TaikoAction.LeftCentre),
new TaikoReplayFrame(2700),
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 4 && Player.ScoreProcessor.Accuracy.Value == 1
});
}
/// <summary>
/// Regression tests a case of 1/6th conversion where there are exactly div-6 number of hitobjects.
/// </summary>
[Test]
public void TestOnlyOneSixthConversion() => CreateModTest(new ModTestData
{
Mod = new TaikoModSimplifiedRhythm
{
OneSixthConversion = { Value = true }
},
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Hit { StartTime = 1000, Type = HitType.Centre },
new Hit { StartTime = 1166, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 1333, Type = HitType.Centre }, // mod moves this to 1250
new Hit { StartTime = 1500, Type = HitType.Centre },
new Hit { StartTime = 1666, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 1833, Type = HitType.Centre }, // mod moves this to 1750
new Hit { StartTime = 2000, Type = HitType.Centre },
new Hit { StartTime = 2166, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2333, Type = HitType.Centre }, // mod moves this to 2250
new Hit { StartTime = 2500, Type = HitType.Centre },
new Hit { StartTime = 2666, Type = HitType.Centre }, // mod removes this
new Hit { StartTime = 2833, Type = HitType.Centre }, // mod moves this to 2750
},
},
ReplayFrames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000, TaikoAction.LeftCentre),
new TaikoReplayFrame(1200),
new TaikoReplayFrame(1250, TaikoAction.LeftCentre),
new TaikoReplayFrame(1450),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre),
new TaikoReplayFrame(1600),
new TaikoReplayFrame(1750, TaikoAction.LeftCentre),
new TaikoReplayFrame(1800),
new TaikoReplayFrame(2000, TaikoAction.LeftCentre),
new TaikoReplayFrame(2200),
new TaikoReplayFrame(2250, TaikoAction.LeftCentre),
new TaikoReplayFrame(2450),
new TaikoReplayFrame(2500, TaikoAction.LeftCentre),
new TaikoReplayFrame(2600),
new TaikoReplayFrame(2750, TaikoAction.LeftCentre),
new TaikoReplayFrame(2800),
},
PassCondition = () => Player.ScoreProcessor.Combo.Value == 8 && Player.ScoreProcessor.Accuracy.Value == 1
});
}
}
@@ -0,0 +1,211 @@
// 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.Scoring;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Taiko.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 string? ExportLocation => null;
protected override Ruleset CreateRuleset() => new TaikoRuleset();
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.
// OD = 5 test cases.
// GREAT hit window is (-35ms, 35ms)
// OK hit window is (-80ms, 80ms)
new object[] { 5f, -33d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Ok },
new object[] { 5f, -36d, HitResult.Ok },
new object[] { 5f, -78d, HitResult.Ok },
new object[] { 5f, -79d, HitResult.Ok },
new object[] { 5f, -80d, HitResult.Miss },
new object[] { 5f, -81d, HitResult.Miss },
// OD = 7.8 test cases.
// GREAT hit window is (-26ms, 26ms)
// OK hit window is (-63ms, 63ms)
new object[] { 7.8f, -24d, HitResult.Great },
new object[] { 7.8f, -25d, HitResult.Great },
new object[] { 7.8f, -26d, HitResult.Ok },
new object[] { 7.8f, -27d, HitResult.Ok },
new object[] { 7.8f, -61d, HitResult.Ok },
new object[] { 7.8f, -62d, HitResult.Ok },
new object[] { 7.8f, -63d, HitResult.Miss },
new object[] { 7.8f, -64d, 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 (-29ms, 29ms)
// OK hit window is (-68ms, 68ms)
new object[] { 5f, -27d, HitResult.Great },
new object[] { 5f, -28d, HitResult.Great },
new object[] { 5f, -29d, HitResult.Ok },
new object[] { 5f, -30d, HitResult.Ok },
new object[] { 5f, -66d, HitResult.Ok },
new object[] { 5f, -67d, HitResult.Ok },
new object[] { 5f, -68d, HitResult.Miss },
new object[] { 5f, -69d, HitResult.Miss },
// OD = 7.8 test cases.
// This would lead to "effective" OD of 10.92,
// but the effects are capped to OD 10.
// GREAT hit window is (-20ms, 20ms)
// OK hit window is (-50ms, 50ms)
new object[] { 7.8f, -18d, HitResult.Great },
new object[] { 7.8f, -19d, HitResult.Great },
new object[] { 7.8f, -20d, HitResult.Ok },
new object[] { 7.8f, -21d, HitResult.Ok },
new object[] { 7.8f, -48d, HitResult.Ok },
new object[] { 7.8f, -49d, HitResult.Ok },
new object[] { 7.8f, -50d, HitResult.Miss },
new object[] { 7.8f, -51d, 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 ( -42ms, 42ms)
// OK hit window is (-100ms, 100ms)
new object[] { 5f, -40d, HitResult.Great },
new object[] { 5f, -41d, HitResult.Great },
new object[] { 5f, -42d, HitResult.Ok },
new object[] { 5f, -43d, HitResult.Ok },
new object[] { 5f, -98d, HitResult.Ok },
new object[] { 5f, -99d, HitResult.Ok },
new object[] { 5f, -100d, HitResult.Miss },
new object[] { 5f, -101d, HitResult.Miss },
};
private const double hit_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 =
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"single hit @ 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 =
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new TaikoModHardRock()]
}
};
RunTest($@"HR single hit @ 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 =
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new TaikoModHardRock()]
}
};
RunTest($@"EZ single hit @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static TaikoBeatmap createBeatmap(float overallDifficulty)
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new TaikoBeatmap
{
HitObjects =
{
new Hit
{
StartTime = hit_time,
Type = HitType.Centre,
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new TaikoRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
return beatmap;
}
}
}
@@ -0,0 +1,73 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Storyboards;
using osu.Game.Tests.Visual;
using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Tests
{
public partial class TestSceneReplayRecording : PlayerTestScene
{
protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset();
[Resolved]
private AudioManager audioManager { get; set; } = null!;
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap
{
HitObjects =
{
new Hit { StartTime = 0, },
new Hit { StartTime = 5000, },
new Hit { StartTime = 10000, },
new Hit { 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("press D", () => InputManager.PressKey(Key.D));
seekTo(15);
AddStep("release D", () => InputManager.ReleaseKey(Key.D));
AddAssert("left rim press recorded to replay", () => Player.Score.Replay.Frames.OfType<TaikoReplayFrame>().Any(f => f.Actions.SequenceEqual([TaikoAction.LeftRim])));
seekTo(5000);
AddStep("press F", () => InputManager.PressKey(Key.F));
seekTo(5015);
AddStep("release F", () => InputManager.ReleaseKey(Key.F));
AddAssert("left centre press recorded to replay", () => Player.Score.Replay.Frames.OfType<TaikoReplayFrame>().Any(f => f.Actions.SequenceEqual([TaikoAction.LeftCentre])));
seekTo(10000);
AddStep("press J", () => InputManager.PressKey(Key.J));
seekTo(10015);
AddStep("release J", () => InputManager.ReleaseKey(Key.J));
AddAssert("right centre press recorded to replay", () => Player.Score.Replay.Frames.OfType<TaikoReplayFrame>().Any(f => f.Actions.SequenceEqual([TaikoAction.RightCentre])));
seekTo(15000);
AddStep("press K", () => InputManager.PressKey(Key.K));
seekTo(15015);
AddStep("release K", () => InputManager.ReleaseKey(Key.K));
AddAssert("right rim press recorded to replay", () => Player.Score.Replay.Frames.OfType<TaikoReplayFrame>().Any(f => f.Actions.SequenceEqual([TaikoAction.RightRim])));
}
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,96 @@
// 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.Scoring;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Taiko.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 [-35ms, 35ms]
// OK hit window is [-80ms, 80ms]
// MISS hit window is [-95ms, 95ms]
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -34.2d, HitResult.Great },
new object[] { 5f, -34.7d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Great },
new object[] { 5f, -35.2d, HitResult.Ok },
new object[] { 5f, -35.8d, HitResult.Ok },
new object[] { 5f, -36d, HitResult.Ok },
new object[] { 5f, -79d, HitResult.Ok },
new object[] { 5f, -79.3d, HitResult.Ok },
new object[] { 5f, -79.7d, HitResult.Ok },
new object[] { 5f, -80d, HitResult.Ok },
new object[] { 5f, -80.2d, HitResult.Miss },
new object[] { 5f, -80.8d, HitResult.Miss },
new object[] { 5f, -81d, HitResult.Miss },
// OD = 7.8 test cases.
// GREAT hit window is [-26.6ms, 26.6ms]
// OK hit window is [-63.2ms, 63.2ms]
// MISS hit window is [-81.0ms, 81.0ms]
new object[] { 7.8f, -26d, HitResult.Great },
new object[] { 7.8f, -26.4d, HitResult.Great },
new object[] { 7.8f, -26.59d, HitResult.Great },
new object[] { 7.8f, -26.8d, HitResult.Ok },
new object[] { 7.8f, -27d, HitResult.Ok },
new object[] { 7.8f, -27.1d, HitResult.Ok },
new object[] { 7.8f, -63d, HitResult.Ok },
new object[] { 7.8f, -63.18d, HitResult.Ok },
new object[] { 7.8f, -63.4d, HitResult.Ok },
new object[] { 7.8f, -63.7d, HitResult.Miss },
new object[] { 7.8f, -64d, HitResult.Miss },
new object[] { 7.8f, -64.2d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double hit_time = 100;
var beatmap = new TaikoBeatmap
{
HitObjects =
{
new Hit
{
StartTime = hit_time,
Type = HitType.Centre,
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new TaikoRuleset().RulesetInfo,
},
};
var replay = new Replay
{
Frames =
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitOffset + 20),
}
};
RunTest(beatmap, replay, [expectedResult]);
}
}
}
@@ -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;
@@ -15,26 +16,30 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
int hits = HitObjects.Count(s => s is Hit);
int drumRolls = HitObjects.Count(s => s is DrumRoll);
int swells = HitObjects.Count(s => s is Swell);
int sum = Math.Max(1, hits + drumRolls);
return new[]
{
new BeatmapStatistic
{
Name = @"Hit Count",
Name = @"Hits",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles),
Content = hits.ToString(),
BarDisplayLength = hits / (float)sum,
},
new BeatmapStatistic
{
Name = @"Drumroll Count",
Name = @"Drumrolls",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders),
Content = drumRolls.ToString(),
BarDisplayLength = drumRolls / (float)sum,
},
new BeatmapStatistic
{
Name = @"Swell Count",
Name = @"Swells",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners),
Content = swells.ToString(),
BarDisplayLength = Math.Min(swells / 10f, 1),
}
};
}
@@ -60,6 +60,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
KeyboardStep = 0.1f,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
@@ -74,6 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
KeyboardStep = 1,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Taiko.Edit.Checks;
@@ -13,6 +14,10 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
// Compose
new CheckConcurrentObjects(),
// Settings
new CheckTaikoAbnormalDifficultySettings(),
};
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Taiko.Mods
@@ -20,6 +21,23 @@ namespace osu.Game.Rulesets.Taiko.Mods
ReadCurrentFromDifficulty = _ => 1,
};
public override string ExtendedIconInformation
{
get
{
if (UserAdjustedSettingsCount != 1)
return string.Empty;
if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed);
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
@@ -0,0 +1,130 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModSimplifiedRhythm : Mod, IApplicableToBeatmap
{
public override string Name => "Simplified Rhythm";
public override string Acronym => "SR";
public override double ScoreMultiplier => 0.6;
public override LocalisableString Description => "Simplify tricky rhythms!";
public override ModType Type => ModType.DifficultyReduction;
[SettingSource("1/3 to 1/2 conversion", "Converts 1/3 patterns to 1/2 rhythm.")]
public Bindable<bool> OneThirdConversion { get; } = new BindableBool();
[SettingSource("1/6 to 1/4 conversion", "Converts 1/6 patterns to 1/4 rhythm.")]
public Bindable<bool> OneSixthConversion { get; } = new BindableBool(true);
[SettingSource("1/8 to 1/4 conversion", "Converts 1/8 patterns to 1/4 rhythm.")]
public Bindable<bool> OneEighthConversion { get; } = new BindableBool();
public void ApplyToBeatmap(IBeatmap beatmap)
{
var taikoBeatmap = (TaikoBeatmap)beatmap;
var controlPointInfo = taikoBeatmap.ControlPointInfo;
Hit[] hits = taikoBeatmap.HitObjects.OfType<Hit>().ToArray();
if (hits.Length == 0)
return;
var conversions = new List<(int, int)>();
if (OneEighthConversion.Value) conversions.Add((8, 4));
if (OneSixthConversion.Value) conversions.Add((6, 4));
if (OneThirdConversion.Value) conversions.Add((3, 2));
bool inPattern = false;
foreach ((int baseRhythm, int adjustedRhythm) in conversions)
{
int patternStartIndex = 0;
for (int i = 1; i < hits.Length; i++)
{
double snapValue = getSnapBetweenNotes(controlPointInfo, hits[i - 1], hits[i]);
if (inPattern)
{
// pattern continues
if (snapValue == baseRhythm)
continue;
inPattern = false;
processPattern(i);
}
else
{
if (snapValue == baseRhythm)
{
patternStartIndex = i - 1;
inPattern = true;
}
}
}
// Process the last pattern if we reached the end of the beatmap and are still in a pattern.
if (inPattern)
processPattern(hits.Length);
void processPattern(int patternEndIndex)
{
// Iterate through the pattern
for (int j = patternStartIndex; j < patternEndIndex; j++)
{
int indexInPattern = j - patternStartIndex;
switch (baseRhythm)
{
// 1/8: Remove every second note
case 8:
{
if (indexInPattern % 2 == 1)
{
taikoBeatmap.HitObjects.Remove(hits[j]);
}
break;
}
// 1/6 and 1/3: Remove every second note and adjust time of every third
case 6:
case 3:
{
if (indexInPattern % 3 == 1)
taikoBeatmap.HitObjects.Remove(hits[j]);
else if (indexInPattern % 3 == 2)
hits[j].StartTime = hits[j - 2].StartTime + controlPointInfo.TimingPointAt(hits[j].StartTime).BeatLength / adjustedRhythm;
break;
}
default:
throw new ArgumentOutOfRangeException(nameof(baseRhythm));
}
}
}
}
}
private int getSnapBetweenNotes(ControlPointInfo controlPointInfo, Hit currentNote, Hit nextNote)
{
var currentTimingPoint = controlPointInfo.TimingPointAt(currentNote.StartTime);
return controlPointInfo.GetClosestBeatDivisor(currentTimingPoint.Time + (nextNote.StartTime - currentNote.StartTime));
}
}
}
@@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Taiko.Mods
foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks)
periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1));
static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh);
static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Ok);
}
nonGameplayPeriods = new PeriodTracker(periods);
@@ -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;
@@ -42,5 +43,8 @@ namespace osu.Game.Rulesets.Taiko.Replays
return new LegacyReplayFrame(Time, null, null, state);
}
public override bool IsEquivalentTo(ReplayFrame other)
=> other is TaikoReplayFrame taikoFrame && Time == taikoFrame.Time && Actions.SequenceEqual(taikoFrame.Actions);
}
}
@@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Shapes;
@@ -112,5 +113,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
.FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine);
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableHitObject.IsNotNull())
drawableHitObject.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}

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