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

Compare commits

..

78 Commits

284 changed files with 6507 additions and 4434 deletions
+8 -11
View File
@@ -3,28 +3,25 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2023.3.3",
"version": "2025.2.3",
"commands": [
"jb"
]
},
"nvika": {
"version": "4.0.0",
"commands": [
"nvika"
]
],
"rollForward": false
},
"codefilesanity": {
"version": "0.0.37",
"commands": [
"CodeFileSanity"
]
],
"rollForward": false
},
"ppy.localisationanalyser.tools": {
"version": "2025.1208.0",
"commands": [
"localisation"
]
],
"rollForward": false
}
}
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
path: ${{ inputs.id }}
repository: 'smoogipoo/diffcalc-sheet-generator'
+50 -17
View File
@@ -6,6 +6,7 @@ concurrency:
permissions:
contents: read # to fetch code (actions/checkout)
security-events: write # for reporting InspectCode issues
jobs:
inspect-code:
@@ -13,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -27,7 +28,7 @@ jobs:
run: dotnet restore osu.Desktop.slnf
- name: Restore inspectcode cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/inspectcode
key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }}
@@ -49,10 +50,14 @@ jobs:
exit $exit_code
- name: InspectCode
run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
- name: NVika
run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors
uses: JetBrains/ReSharper-InspectCode@v0.12
with:
# this is WTF tier but if you don't specify *both* of these the defaults assume `build: true`
build: false
no-build: true
solution: ./osu.Desktop.slnf
caches-home: inspectcode
verbosity: WARN
test:
name: Test
@@ -71,10 +76,10 @@ jobs:
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -96,30 +101,58 @@ jobs:
NUnit.ConsoleOut=0
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
# https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#cancelled
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ always() }}
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx
test-results:
name: Test results
runs-on: ubuntu-latest
# we want to wait for the `test` job to complete, but run regardless of whether it succeeds or fails
# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-not-requiring-successful-dependent-jobs
if: ${{ !cancelled() }}
needs: test
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download results
uses: actions/download-artifact@v8
with:
pattern: osu-test-results-*
merge-multiple: true
- name: Add test results summary to workflow run
uses: dorny/test-reporter@v3.0.0
with:
name: Results
path: "*.trx"
reporter: dotnet-trx
list-suites: 'failed'
list-tests: 'failed'
use-actions-summary: 'true'
build-only-android:
name: Build only (Android)
runs-on: windows-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup JDK 11
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: microsoft
java-version: 11
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -135,10 +168,10 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
+5 -4
View File
@@ -3,7 +3,8 @@ name: Pack and nuget
on:
push:
tags:
- '*'
- '*.*.*'
- '!*-*'
jobs:
notify_pending_production_deploy:
@@ -43,14 +44,14 @@ jobs:
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- 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
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -76,7 +77,7 @@ jobs:
dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: osu
path: |
-45
View File
@@ -1,45 +0,0 @@
# This is a workaround to allow PRs to report their coverage. This will run inside the base repository.
# See:
# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories
# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token
name: Annotate CI run with test results
on:
workflow_run:
workflows: [ "Continuous Integration" ]
types:
- completed
permissions:
contents: read
actions: read
checks: write
jobs:
annotate:
name: Annotate CI run with test results
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ github.event.workflow_run.repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Download results
uses: actions/download-artifact@v4
with:
pattern: osu-test-results-*
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Annotate CI run with test results
uses: dorny/test-reporter@v1.8.0
with:
name: Results
path: "*.trx"
reporter: dotnet-trx
list-suites: 'failed'
list-tests: 'failed'
+2 -2
View File
@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ppy
+13
View File
@@ -13,6 +13,19 @@
"preLaunchTask": "Build osu! (Debug)",
"console": "internalConsole"
},
{
"name": "osu! (Debug, Second Client)",
"type": "coreclr",
"request": "launch",
"program": "dotnet",
"args": [
"${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll",
"--debug-client-id=1"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build osu! (Debug)",
"console": "internalConsole"
},
{
"name": "osu! (Release)",
"type": "coreclr",
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyFreeform
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyScrolling
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.310.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.318.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+1
View File
@@ -58,6 +58,7 @@ namespace osu.Desktop
private readonly RichPresence presence = new RichPresence
{
Assets = new Assets { LargeImageKey = "osu_logo_lazer" },
Timestamps = Timestamps.Now,
Secrets = new Secrets
{
JoinSecret = null,
@@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
namespace osu.Desktop.MacOS
{
/// <summary>
/// Checks if the game is located at `Applications` folder and displays a warning notification if not so.
/// </summary>
public partial class MacOSAppLocationChecker : Component
{
[Resolved]
private INotificationOverlay notification { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
string assemblyPath = RuntimeInfo.EntryAssembly.Location;
bool inRootApp = assemblyPath.StartsWith("/Applications/", StringComparison.Ordinal);
bool inUserApp = assemblyPath.StartsWith(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications/"), StringComparison.Ordinal);
if (!inRootApp && !inUserApp)
notification.Post(new MacOSAppLocationNotification());
Expire();
}
private partial class MacOSAppLocationNotification : SimpleNotification
{
public MacOSAppLocationNotification()
{
Text = NotificationsStrings.MacOSAppLocation(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Icon = FontAwesome.Solid.ShieldAlt;
IconContent.Colour = colours.YellowDark;
}
}
}
}
+13 -5
View File
@@ -5,7 +5,6 @@ using System;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32;
using osu.Desktop.Performance;
using osu.Desktop.Security;
@@ -15,12 +14,12 @@ using osu.Desktop.Updater;
using osu.Framework;
using osu.Framework.Logging;
using osu.Game.Updater;
using osu.Desktop.MacOS;
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;
using osu.Game.Performance;
using osu.Game.Utils;
@@ -123,7 +122,7 @@ namespace osu.Desktop
public override bool RestartAppWhenExited()
{
Task.Run(() => Velopack.UpdateExe.Start(waitPid: (uint)Environment.ProcessId)).FireAndForget();
RestartOnExitAction = () => Velopack.UpdateExe.Start(waitPid: (uint)Environment.ProcessId);
return true;
}
@@ -133,8 +132,17 @@ namespace osu.Desktop
LoadComponentAsync(new DiscordRichPresence(), Add);
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
break;
case RuntimeInfo.Platform.macOS when !IsPackageManaged && IsDeployedBuild:
if (!IsPackageManaged && IsDeployedBuild)
LoadComponentAsync(new MacOSAppLocationChecker(), Add);
break;
}
LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);
@@ -27,6 +27,8 @@ namespace osu.Desktop.Security
if (Environment.IsPrivilegedProcess)
notifications.Post(new ElevatedPrivilegesNotification());
Expire();
}
private partial class ElevatedPrivilegesNotification : SimpleNotification
+4 -4
View File
@@ -146,11 +146,11 @@ namespace osu.Desktop.Updater
action();
}
private void restartToApplyUpdate(Velopack.UpdateManager updateManager, UpdateInfo update) => Task.Run(async () =>
private void restartToApplyUpdate(Velopack.UpdateManager updateManager, UpdateInfo update)
{
await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
});
game.RestartOnExitAction = () => updateManager.WaitExitThenApplyUpdates(update.TargetFullRelease);
game.AttemptExit();
}
private static void log(string text) => Logger.Log($"VelopackUpdateManager: {text}");
}
@@ -193,20 +193,20 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public Color4 HyperDashColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDash)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDash)] = value;
}
public Color4 HyperDashAfterImageColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashAfterImage)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashAfterImage)] = value;
}
public Color4 HyperDashFruitColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashFruit)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashFruit)] = value;
}
public TestSkin()
+1
View File
@@ -155,6 +155,7 @@ namespace osu.Game.Rulesets.Catch
new CatchModMuted(),
new CatchModNoScope(),
new CatchModMovingFast(),
new CatchModSynesthesia(),
};
case ModType.System:
@@ -15,7 +15,6 @@ using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Catch.Difficulty
{
@@ -23,6 +22,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
private const double difficulty_multiplier = 4.59;
private float halfCatcherWidth;
public override int Version => 20251020;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new CatchDifficultyAttributes { Mods = mods };
@@ -45,19 +46,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
CatchHitObject? lastObject = null;
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
// In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
{
@@ -74,11 +68,16 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return objects;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
return new Skill[]
{
new Movement(mods),
new Movement(mods, halfCatcherWidth, clockRate),
};
}
@@ -11,16 +11,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{
private const double direction_change_bonus = 21.0;
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier)
{
var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
double catcherSpeedMultiplier = current.ClockRate;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
@@ -44,30 +40,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
}
// Linear spacing nerf.
double linearSpacingCount = 0;
for (int i = 0; i < Math.Min(current.Index, 10); i++)
{
var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i);
// Only same direction movements matter as they do not take any additional inputs.
if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0)
break;
double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime);
double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime);
double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1);
if (relativeDifference > 0.05)
break;
linearSpacingCount++;
}
distanceAddition *= Math.Pow(0.7, linearSpacingCount);
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
@@ -17,14 +17,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected override int SectionLength => 750;
public Movement(Mod[] mods)
protected readonly float HalfCatcherWidth;
/// <summary>
/// The speed multiplier applied to the player's catcher.
/// </summary>
private readonly double catcherSpeedMultiplier;
public Movement(Mod[] mods, float halfCatcherWidth, double clockRate)
: base(mods)
{
HalfCatcherWidth = halfCatcherWidth;
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
// TODO: Support variable clockrates caused by mods such as ModTimeRamp
// (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing)
catcherSpeedMultiplier = clockRate;
}
protected override double StrainValueOf(DifficultyHitObject current)
{
return MovementEvaluator.EvaluateDifficultyOf(current);
return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier);
}
}
}
@@ -0,0 +1,60 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Mods
{
/// <summary>
/// Mod that colours <see cref="HitObject"/>s based on the musical division they are on
/// </summary>
public class CatchModSynesthesia : ModSynesthesia, IApplicableToBeatmap, IApplicableToDrawableHitObject
{
private readonly OsuColour colours = new OsuColour();
private IBeatmap? currentBeatmap { get; set; }
public void ApplyToBeatmap(IBeatmap beatmap)
{
//Store a reference to the current beatmap to look up the beat divisor when notes are drawn
if (currentBeatmap != beatmap)
currentBeatmap = beatmap;
}
public void ApplyToDrawableHitObject(DrawableHitObject d)
{
if (currentBeatmap == null) return;
Color4? timingBasedColour = null;
d.HitObjectApplied += _ =>
{
// Block bananas from getting coloured.
if (d.HitObject is not Banana)
{
timingBasedColour = BindableBeatDivisor.GetColourFor(currentBeatmap.ControlPointInfo.GetClosestBeatDivisor(d.HitObject.StartTime), colours);
}
// Colour droplets into a solid colour, as droplets aren't generated snapped to timeline ticks.
if (d.HitObject is Droplet)
{
timingBasedColour = Color4.LightGreen;
}
};
// Need to set this every update to ensure it doesn't get overwritten by DrawableHitObject.OnApply() -> UpdateComboColour().
d.OnUpdate += _ =>
{
if (timingBasedColour != null)
d.AccentColour.Value = timingBasedColour.Value;
};
}
}
}
@@ -10,6 +10,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects;
@@ -505,6 +506,29 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit()));
}
/// <summary>
/// This ensures that the value of <see cref="DrawableHoldNote.MissingStartTime"/>
/// will be set correctly when the body receives a judgment during the hold.
///
/// -----[ ]-----
/// x o
/// </summary>
[Test]
public void TestReleaseDuringHoldMissingStartTime()
{
performTest([
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_during_hold_1)
]);
assertHeadJudgement(HitResult.Perfect);
assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss);
AddAssert("body judgement is miss", () => !judgementResults.Single(j => j.HitObject is HoldNoteBody).IsHit);
AddAssert("body judgement time indicates during hold", () => judgementResults.Single(j => j.HitObject is HoldNoteBody).TimeAbsolute, () => Is.EqualTo(time_during_hold_1).Within(100));
}
private void assertHitObjectJudgement(HitObject hitObject, HitResult result)
=> AddAssert($"object judged as {result}", () => judgementResults.First(j => j.HitObject == hitObject).Type, () => Is.EqualTo(result));
@@ -19,7 +19,6 @@ using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Difficulty
{
@@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new ManiaDifficultyAttributes { Mods = mods };
@@ -63,13 +62,11 @@ namespace osu.Game.Rulesets.Mania.Difficulty
return 1;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
double clockRate = ModUtils.CalculateRateWithMods(mods);
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime)));
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
@@ -91,7 +88,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
// Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required.
protected override IEnumerable<DifficultyHitObject> SortObjects(IEnumerable<DifficultyHitObject> input) => input;
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[]
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[]
{
new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns)
};
@@ -31,10 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public override bool DisplayResult => false;
/// <summary>
/// Whether the user is currently pressing the hold note.
/// </summary>
public IBindable<bool> IsHolding => isHolding;
private readonly Bindable<bool> isHolding = new Bindable<bool>();
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public IBindable<double?> MissingStartTime => missingStartTime;
private readonly Bindable<double?> missingStartTime = new Bindable<double?>();
public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child;
public DrawableHoldNoteBody Body => bodyContainer.Child;
@@ -197,11 +208,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public override void OnKilled()
{
base.OnKilled();
// flush the final state of holding on kill.
// this matters because some skin implementations like legacy skin
// insert drawables in the hierarchy that are not a child of this DHO
// (see `LegacyBodyPiece` and related machinations with `lightContainer` being added at column level)
isHolding.Value = Result.IsHolding(Time.Current);
missingStartTime.Value = null;
(bodyPiece.Drawable as IHoldNoteBody)?.Recycle();
}
@@ -209,6 +222,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
base.Update();
if (Head.Judged && !Head.IsHit)
missingStartTime.Value ??= Head.Result.TimeAbsolute;
if (Body.HasHoldBreak)
missingStartTime.Value ??= Body.Result.TimeAbsolute;
if (Tail.Judged && !Tail.IsHit)
missingStartTime.Value ??= Tail.Result.TimeAbsolute;
isHolding.Value = Result.IsHolding(Time.Current);
// Pad the full size container so its contents (i.e. the masking container) reach under the tail.
@@ -3,6 +3,7 @@
#nullable disable
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects.Drawables;
@@ -14,6 +15,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableHoldNoteHead : DrawableNote
{
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public readonly IBindable<double?> MissingStartTime = new Bindable<double?>();
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead;
public DrawableHoldNoteHead()
@@ -28,6 +35,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Origin = Anchor.TopCentre;
}
protected override void OnApply()
{
base.OnApply();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.BindTo(parentHold.MissingStartTime);
}
protected override void OnFree()
{
base.OnFree();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.UnbindFrom(parentHold.MissingStartTime);
}
public bool UpdateResult() => base.UpdateResult(true);
protected override void UpdateHitStateTransforms(ArmedState state)
@@ -3,6 +3,7 @@
#nullable disable
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Scoring;
@@ -14,6 +15,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableHoldNoteTail : DrawableNote
{
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public readonly IBindable<double?> MissingStartTime = new Bindable<double?>();
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
@@ -30,6 +37,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Origin = Anchor.TopCentre;
}
protected override void OnApply()
{
base.OnApply();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.BindTo(parentHold.MissingStartTime);
}
protected override void OnFree()
{
base.OnFree();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.UnbindFrom(parentHold.MissingStartTime);
}
public void UpdateResult() => base.UpdateResult(true);
protected override void CheckForResult(bool userTriggered, double timeOffset) =>
+2 -1
View File
@@ -118,7 +118,8 @@ namespace osu.Game.Rulesets.Mania.Objects
AddNested(Body = new HoldNoteBody
{
StartTime = StartTime,
Column = Column
Column = Column,
Duration = Duration
});
}
@@ -3,6 +3,7 @@
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
@@ -13,9 +14,11 @@ namespace osu.Game.Rulesets.Mania.Objects
/// On hit - the hold note was held correctly for the full duration.<br />
/// On miss - the hold note was released at some point during its judgement period.
/// </summary>
public class HoldNoteBody : ManiaHitObject
public class HoldNoteBody : ManiaHitObject, IHasDuration
{
public override Judgement CreateJudgement() => new HoldNoteBodyJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public double Duration { get; set; }
public double EndTime => StartTime + Duration;
}
}
@@ -25,17 +25,10 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
private readonly IBindable<bool> isHitting = new Bindable<bool>();
/// <summary>
/// Stores the start time of the fade animation that plays when any of the nested
/// hitobjects of the hold note are missed.
/// </summary>
private readonly Bindable<double?> missFadeTime = new Bindable<double?>();
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
private Drawable? bodySprite;
private Drawable? lightContainer;
private Drawable? light;
private LegacyNoteBodyStyle? bodyStyle;
@@ -87,6 +80,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindTo(scrollingInfo.Direction);
isHitting.BindTo(holdNote.IsHolding);
missingStartTime.BindTo(holdNote.MissingStartTime);
bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d =>
{
@@ -109,26 +103,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindValueChanged(onDirectionChanged, true);
isHitting.BindValueChanged(onIsHittingChanged, true);
missFadeTime.BindValueChanged(onMissFadeTimeChanged, true);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
holdNote.ApplyCustomUpdateState += applyCustomUpdateState;
applyCustomUpdateState(holdNote, holdNote.State.Value);
}
private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state)
{
switch (hitObject)
{
// Ensure that the hold note is also faded out when the head/tail/body is missed.
// Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
case DrawableHoldNoteTail:
case DrawableHoldNoteHead:
case DrawableHoldNoteBody:
if (state == ArmedState.Miss)
missFadeTime.Value ??= hitObject.HitStateUpdateTime;
break;
}
holdNote.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)
@@ -187,23 +164,19 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
}
}
private void onMissFadeTimeChanged(ValueChangedEvent<double?> missFadeTimeChange)
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missFadeTimeChange.NewValue == null)
if (missingStartTime.Value == null)
return;
// this update could come from any nested object of the hold note (or even from an input).
// make sure the transforms are consistent across all affected parts.
using (BeginAbsoluteSequence(missFadeTimeChange.NewValue.Value))
{
// colour and duration matches stable
// transforms not applied to entire hold note in order to not affect hit lighting
const double fade_duration = 60;
holdNote.Head.FadeColour(Colour4.DarkGray, fade_duration);
holdNote.Tail.FadeColour(Colour4.DarkGray, fade_duration);
bodySprite?.FadeColour(Colour4.DarkGray, fade_duration);
}
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override void Update()
@@ -213,9 +186,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (!isHitting.Value)
(bodySprite as TextureAnimation)?.GotoFrame(0);
if (holdNote.Body.HasHoldBreak)
missFadeTime.Value = holdNote.Body.Result.TimeAbsolute;
int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1);
// here we go...
@@ -251,7 +221,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
base.Dispose(isDisposing);
if (holdNote.IsNotNull())
holdNote.ApplyCustomUpdateState -= applyCustomUpdateState;
holdNote.ApplyCustomUpdateState -= onApplyCustomUpdateState;
lightContainer?.Expire();
}
@@ -1,18 +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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyHoldNoteHeadPiece : LegacyNotePiece
{
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
missingStartTime.BindTo(((DrawableHoldNoteHead)drawableObject).MissingStartTime);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
drawableObject.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missingStartTime.Value == null)
return;
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override Drawable? GetAnimation(ISkinSource skin)
{
// TODO: Should fallback to the head from default legacy skin instead of note.
return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage)
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= onApplyCustomUpdateState;
}
}
}
@@ -1,8 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
@@ -10,6 +14,36 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyHoldNoteTailPiece : LegacyNotePiece
{
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
missingStartTime.BindTo(((DrawableHoldNoteTail)drawableObject).MissingStartTime);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
drawableObject.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missingStartTime.Value == null)
return;
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
// Invert the direction
@@ -25,5 +59,13 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage)
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= onApplyCustomUpdateState;
}
}
}
@@ -9,9 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Input;
using osu.Framework.Platform;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Input.Handlers;
@@ -63,16 +61,11 @@ namespace osu.Game.Rulesets.Mania.UI
public double TargetTimeRange { get; protected set; }
private double currentTimeRange;
// Stores the current speed adjustment active in gameplay.
private readonly Track speedAdjustmentTrack = new TrackVirtual(0);
private ISkinSource currentSkin = null!;
[Resolved]
private GameHost gameHost { get; set; } = null!;
public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
: base(ruleset, beatmap, mods)
{
@@ -119,7 +112,7 @@ namespace osu.Game.Rulesets.Mania.UI
TargetTimeRange = ComputeScrollTime(speed.NewValue);
});
TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value);
TimeRange.Value = TargetTimeRange = ComputeScrollTime(configScrollSpeed.Value);
Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout);
mobileLayout.BindValueChanged(_ => updateMobileLayout(), true);
@@ -179,9 +172,7 @@ namespace osu.Game.Rulesets.Mania.UI
// This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position.
float scale = lengthToHitPosition / length_to_default_hit_position;
// we're intentionally using the game host's update clock here to decouple the time range tween from the gameplay clock (which can be arbitrarily paused, or even rewinding)
currentTimeRange = Interpolation.DampContinuously(currentTimeRange, TargetTimeRange, 50, gameHost.UpdateThread.Clock.ElapsedFrameTime);
TimeRange.Value = currentTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale;
TimeRange.Value = TargetTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale;
}
/// <summary>
@@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
Vector2 oldStartPos = default;
Vector2 oldEndPos = default;
double oldDistance = default;
double oldDistance = 0;
var oldControlPointTypes = controlPoints.Select(p => p.Type);
AddStep("Add slider", () =>
@@ -255,7 +255,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
Vector2 oldStartPos = default;
Vector2 oldEndPos = default;
double oldDistance = default;
double oldDistance = 0;
var oldControlPointTypes = segmentedSliderPath.Select(p => p.Type);
@@ -34,30 +34,6 @@ namespace osu.Game.Rulesets.Osu.Tests
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
[TestCase(239, "diffcalc-test")]
[TestCase(54, "zero-length-sliders")]
[TestCase(4, "very-fast-slider")]
public void TestOffsetChanges(int expectedMaxCombo, string name)
{
const double offset_iterations = 400;
var beatmap = GetBeatmap(name);
var attributes = CreateDifficultyCalculator(beatmap).Calculate();
double expectedStarRating = attributes.StarRating;
for (int i = 0; i < offset_iterations; i++)
{
foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects)
beatmapHitObject.StartTime++;
attributes = CreateDifficultyCalculator(beatmap).Calculate();
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
}
}
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);
protected override Ruleset CreateRuleset() => new OsuRuleset();
@@ -1,42 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class AgilityEvaluator
{
private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers
/// <summary>
/// Evaluates the difficulty of fast aiming
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
if (current.BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.LazyJumpDistance;
double distanceScaled = Math.Min(distance, distance_cap) / distance_cap;
double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime;
agilityDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5);
agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return agilityDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000));
}
}
@@ -1,126 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class FlowAimEvaluator
{
private const double velocity_change_multiplier = 0.52;
/// <summary>
/// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them.
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
// If the last object is a slider, then we extend the travel velocity through the slider into the current object.
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double flowDifficulty = currVelocity;
// Apply high circle size bonus to the base velocity.
// We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling
flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
// Rhythm changes are harder to flow
flowDifficulty *= 1 + Math.Min(0.25,
Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4));
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value);
double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0;
double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1);
// Low angular velocity flow (angles are consistent) is easier to follow than erratic flow
flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0);
}
// If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement
double overlappedNotesWeight = 1;
if (current.Index > 2)
{
double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj);
double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj);
double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj);
overlappedNotesWeight = 1 - o1 * o2 * o3;
}
if (osuCurrObj.Angle != null)
{
// Acute angles are also hard to flow
flowDifficulty += currVelocity *
SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) *
overlappedNotesWeight;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime),
Math.Abs(prevVelocity - currVelocity));
flowDifficulty += overlapVelocityBuff *
distRatio *
overlappedNotesWeight *
velocity_change_multiplier;
}
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
// Include slider velocity to make velocity more consistent with snap
flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
}
// Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that
flowDifficulty = Math.Pow(flowDifficulty, 1.45);
// Reduce difficulty for low spacing since spacing below radius is always to be flowed
return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
}
private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second)
{
var firstBase = (OsuHitObject)first.BaseObject;
var secondBase = (OsuHitObject)second.BaseObject;
double objectRadius = firstBase.Radius;
double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition);
return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1);
}
}
}
@@ -1,220 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class SnapAimEvaluator
{
private const double wide_angle_multiplier = 9.67;
private const double acute_angle_multiplier = 2.41;
private const double slider_multiplier = 1.5;
private const double velocity_change_multiplier = 0.9;
private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation
private const double maximum_repetition_nerf = 0.15;
private const double maximum_vector_influence = 0.5;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double snapDifficulty = currVelocity; // Start difficulty with regular velocity.
// Penalize angle repetition.
snapDifficulty *= vectorAngleRepetition(osuCurrObj, osuLastObj);
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double velocityInfluence = Math.Min(currVelocity, prevVelocity);
double acuteAngleBonus = 0;
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = CalcAngleAcuteness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2);
}
double wideAngleBonus = calcAngleWideness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here
wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3)));
// Rescaling velocity for the wide angle bonus
const double wide_angle_time_scale = 1.45;
double wideAngleCurrVelocity = currDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale);
double wideAnglePrevVelocity = prevDistance / Math.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale);
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale));
}
wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity);
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.55 * (1 - distance);
}
}
// Add in acute angle bonus or wide angle bonus, whichever is larger.
snapDifficulty += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
double wiggleBonus = velocityInfluence
* DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
snapDifficulty += wiggleBonus * wiggle_multiplier;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
// We want to use just the object jump without slider velocity when awarding differences
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
double velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
snapDifficulty += velocityChangeBonus * velocity_change_multiplier;
}
// Reward sliders based on velocity.
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
snapDifficulty += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier;
}
// Apply high circle size bonus
snapDifficulty *= osuCurrObj.SmallCircleBonus;
snapDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return snapDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65)));
private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous)
{
if (current.Angle == null || previous.Angle == null)
return 1;
const double note_limit = 6;
double constantAngleCount = 0;
for (int index = 0; index < note_limit; index++)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Only consider vectors in the same jump section, stopping to change rhythm ruins momentum
if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime))
break;
if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull())
{
double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value);
// Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1.
// https://www.desmos.com/calculator/a8jesv5sv2
constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference));
}
}
double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2);
double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER);
double currAngle = current.Angle.Value;
double lastAngle = previous.Angle.Value;
double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor));
double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted;
return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2);
}
private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -0,0 +1,172 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.55;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity.
}
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime;
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity);
}
double wideAngleBonus = 0;
double acuteAngleBonus = 0;
double sliderBonus = 0;
double velocityChangeBonus = 0;
double wiggleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition.
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
}
wideAngleBonus = calcWideAngleBonus(currAngle);
// Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.35 * (1 - distance);
}
}
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime;
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
}
if (osuLastObj.BaseObject is Slider)
{
// Reward sliders based on velocity.
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
aimStrain += wiggleBonus * wiggle_multiplier;
aimStrain += velocityChangeBonus * velocity_change_multiplier;
// Add in acute angle bonus or wide angle bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply high circle size bonus
aimStrain *= osuCurrObj.SmallCircleBonus;
// Add in additional slider velocity bonus.
if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier;
return aimStrain;
}
private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -2,12 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@@ -32,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and whether the hidden mod is enabled.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden)
{
if (current.BaseObject is Spinner)
return 0;
@@ -44,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double smallDistNerf = 1.0;
double cumulativeStrainTime = 0.0;
double flashlightDifficulty = 0.0;
double result = 0.0;
OsuDifficultyHitObject lastObj = osuCurrent;
@@ -70,9 +66,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0);
// Bonus based on how visible the object is.
double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType<OsuModHidden>().Any(m => !m.OnlyFadeApproachCircles.Value)));
double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden));
flashlightDifficulty += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime;
result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime;
if (currentObj.Angle != null && osuCurrent.Angle != null)
{
@@ -85,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
lastObj = currentObj;
}
flashlightDifficulty = Math.Pow(smallDistNerf * flashlightDifficulty, 2.0);
result = Math.Pow(smallDistNerf * result, 2.0);
// Additional bonus for Hidden due to there being no approach circles.
if (mods.OfType<OsuModHidden>().Any())
flashlightDifficulty *= 1.0 + hidden_bonus;
if (hidden)
result *= 1.0 + hidden_bonus;
// Nerf patterns with repeated angles.
flashlightDifficulty *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0);
result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0);
double sliderBonus = 0.0;
@@ -112,9 +108,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
sliderBonus /= (osuSlider.RepeatCount + 1);
}
flashlightDifficulty += sliderBonus * slider_multiplier;
result += sliderBonus * slider_multiplier;
return flashlightDifficulty;
return result;
}
}
}
@@ -1,272 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class ReadingEvaluator
{
private const double reading_window_size = 3000; // 3 seconds
private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers
private const double hidden_multiplier = 0.28;
private const double density_multiplier = 2.4;
private const double density_difficulty_base = 2.5;
private const double preempt_balancing_factor = 140000;
private const double preempt_starting_point = 500; // AR 9.66 in milliseconds
private const double minimum_angle_relevancy_time = 2000; // 2 seconds
private const double maximum_angle_relevancy_time = 200;
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden)
{
if (current.BaseObject is Spinner || current.Index == 0)
return 0;
var currObj = (OsuDifficultyHitObject)current;
var nextObj = (OsuDifficultyHitObject)current.Next(0);
double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff
double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj);
double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj);
double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj);
double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity);
double hiddenDifficulty = hidden
? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor)
: 0;
double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt);
double readingDifficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty);
// Having less time to process information is harder
readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime);
return readingDifficulty;
}
/// <summary>
/// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// /// </list>
/// </summary>
private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor,
double pastObjectDifficultyInfluence, double currentVisibleObjectDensity)
{
// Consider future densities too because it can make the path the cursor takes less clear
double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity);
if (nextObj != null)
{
// Reduce difficulty if movement to next object is small
futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold);
}
// Value higher note densities exponentially
double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity;
// Award only denser than average maps.
noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base);
// Apply a soft cap to general density reading to account for partial memorization
noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier;
return noteDensityDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the approach rate is very high based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>how many milliseconds elapse between the approach circle appearing and touching the inner circle</description></item>
/// </list>
/// </summary>
private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt)
{
// Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
// https://www.desmos.com/calculator/c175335a71
double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor;
preemptDifficulty *= constantAngleNerfFactor * velocity;
return preemptDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the hidden mod is active based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>time the current object spends invisible,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>if the current object is perfectly stacked to the previous one</description></item>
/// </list>
/// </summary>
private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity,
double constantAngleNerfFactor)
{
// Higher preempt means that time spent invisible is higher too, we want to reward that
double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01;
// Account for both past and current densities
double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3;
double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01;
// Apply a soft cap to general HD reading to account for partial memorization
hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier;
var previousObj = (OsuDifficultyHitObject)currObj.Previous(0);
// Buff perfect stacks only if current note is completely invisible at the time you click the previous note.
if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt)
hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes
return hiddenDifficulty;
}
private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj)
{
double pastObjectDifficultyInfluence = 0;
foreach (var loopObj in retrievePastVisibleObjects(currObj))
{
double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false);
// When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly.
loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold);
// Account less for objects close to the max reading window
double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
loopDifficulty *= timeNerfFactor;
pastObjectDifficultyInfluence += loopDifficulty;
}
return pastObjectDifficultyInfluence;
}
// Returns a list of objects that are visible on screen at the point in time the current object becomes visible.
private static IEnumerable<OsuDifficultyHitObject> retrievePastVisibleObjects(OsuDifficultyHitObject current)
{
for (int i = 0; i < current.Index; i++)
{
OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i);
if (hitObject.IsNull() ||
current.StartTime - hitObject.StartTime > reading_window_size ||
hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked
break;
yield return hitObject;
}
}
// Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window.
private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current)
{
double visibleObjectCount = 0;
OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0);
while (hitObject != null)
{
if (hitObject.StartTime - current.StartTime > reading_window_size ||
current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked.
break;
double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor;
hitObject = (OsuDifficultyHitObject?)hitObject.Next(0);
}
return visibleObjectCount;
}
// Returns a factor of how often the current object's angle has been repeated in a certain time frame.
// It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity.
// https://www.desmos.com/calculator/eb057a4822
private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current)
{
double constantAngleCount = 0;
int index = 0;
double currentTimeGap = 0;
OsuDifficultyHitObject loopObjPrev0 = current;
OsuDifficultyHitObject? loopObjPrev1 = null;
OsuDifficultyHitObject? loopObjPrev2 = null;
while (currentTimeGap < minimum_angle_relevancy_time)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Account less for objects that are close to the time limit.
double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time);
if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull())
{
double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value);
double angleDifferenceAlternating = Math.PI;
if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null)
{
angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value);
angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value);
double weight = 1.0;
// Be sure that one of the angles is very sharp, when other is wide
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5);
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120);
// Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference
angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight);
}
double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor;
}
currentTimeGap = current.StartTime - loopObj.StartTime;
index++;
loopObjPrev2 = loopObjPrev1;
loopObjPrev1 = loopObjPrev0;
loopObjPrev0 = loopObj;
}
return Math.Clamp(2 / constantAngleCount, 0.2, 1);
}
// Returns a nerfing factor for when objects are very distant in time, affecting reading less.
private static double getTimeNerfFactor(double deltaTime)
{
return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1);
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000));
}
}
@@ -8,16 +8,15 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 26.0;
private const double rhythm_overall_multiplier = 1.0;
private const double rhythm_ratio_multiplier = 15.0;
/// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@@ -27,9 +26,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (current.BaseObject is Spinner)
return 0;
var currentOsuObject = (OsuDifficultyHitObject)current;
double rhythmComplexitySum = 0;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon);
@@ -56,8 +57,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
if (currObj.BaseObject is Spinner)
continue;
// scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
@@ -65,56 +64,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
// Use custom cap value to ensure that at this point delta time is actually zero
// Use custom cap value to ensure that that at this point delta time is actually zero
double currDelta = Math.Max(currObj.DeltaTime, 1e-7);
double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7);
double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7);
// Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change
if (island.Delta == int.MaxValue)
island = new Island((int)currDelta, deltaDifferenceEpsilon);
// calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta);
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
// reduce ratio bonus if delta difference is too big
double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier;
// if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion
// while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple
// for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double
if (prevObj.BaseObject is Slider)
{
double sliderLazyEndDelta = currObj.MinimumJumpTime;
double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta);
double sliderRealEndDelta = currObj.LastObjectEndDeltaTime;
double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta);
double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference));
effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio);
}
bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon;
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
double effectiveRatio = windowPenalty * currRatio * differenceMultiplier;
if (firstDeltaSwitch)
{
if (Math.Abs(prevDelta - currDelta) > deltaDifferenceEpsilon)
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.5;
effectiveRatio *= 0.125;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;
// repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland))
@@ -129,9 +116,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5;
if (isSpeedingUp)
effectiveRatio *= 0.65;
var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
if (islandCount != default)
@@ -150,10 +134,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
}
else
{
if (island.DeltaCount > 0)
{
islandCounts.Add((island, 1));
}
islandCounts.Add((island, 1));
}
// scale down the difficulty if the object is doubletappable
@@ -195,18 +176,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
prevObj = currObj;
}
// If the current island is long we don't want the sum to have as big of an effect
rhythmComplexitySum *= DifficultyCalculationUtils.ReverseLerp(island.DeltaCount, 22, 3);
double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0));
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though);
}
private static double getEffectiveRatio(double deltaDifference)
{
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
return rhythmDifficulty;
}
private class Island : IEquatable<Island>
@@ -238,12 +211,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
public bool IsSimilarPolarity(Island other)
{
// single delta islands shouldn't be compared
if (DeltaCount <= 1 || other.DeltaCount <= 1)
return false;
return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount % 2 == other.DeltaCount % 2;
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
return DeltaCount % 2 == other.DeltaCount % 2;
}
public bool Equals(Island? other)
@@ -2,39 +2,47 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.8;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
/// <list type="bullet">
/// <item><description>time between pressing the previous and current object,</description></item>
/// <item><description>distance between those objects,</description></item>
/// <item><description>and how easily they can be cheesed.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
{
if (current.BaseObject is Spinner)
return 0;
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double strainTime = osuCurrObj.AdjustedDeltaTime;
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1);
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1);
// speedBonus will be 0.0 for BPM < 200
double speedBonus = 0.0;
@@ -43,15 +51,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
// Base difficulty with all bonuses
double speedDifficulty = (1 + speedBonus) * 1000 / strainTime;
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
// Cap distance at single_spacing_threshold
distance = Math.Min(distance, single_spacing_threshold);
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps
distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0;
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles
return speedDifficulty * doubletapness;
return difficulty * doubletapness;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000));
}
}
@@ -45,12 +45,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight_difficulty")]
public double FlashlightDifficulty { get; set; }
/// <summary>
/// The difficulty corresponding to the reading skill.
/// </summary>
[JsonProperty("reading_difficulty")]
public double ReadingDifficulty { get; set; }
/// <summary>
/// Describes how much of <see cref="AimDifficulty"/> is contributed to by hitcircles or sliders.
/// A value closer to 1.0 indicates most of <see cref="AimDifficulty"/> is contributed by hitcircles.
@@ -81,9 +75,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; }
[JsonProperty("reading_difficult_note_count")]
public double ReadingDifficultNoteCount { get; set; }
[JsonProperty("nested_score_per_object")]
public double NestedScorePerObject { get; set; }
@@ -93,6 +84,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("maximum_legacy_combo_score")]
public double MaximumLegacyComboScore { get; set; }
/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
public double DrainRate { get; set; }
/// <summary>
/// The number of hitcircles in the beatmap.
/// </summary>
@@ -115,7 +111,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM, AimDifficulty);
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_READING, ReadingDifficulty);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightDifficulty())
@@ -132,7 +127,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject);
yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier);
yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore);
yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -141,7 +135,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
AimDifficulty = values[ATTRIB_ID_AIM];
SpeedDifficulty = values[ATTRIB_ID_SPEED];
ReadingDifficulty = values[ATTRIB_ID_READING];
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
@@ -154,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT];
LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER];
MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE];
ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;
SpinnerCount = onlineInfo.SpinnerCount;
@@ -8,7 +8,6 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
@@ -17,12 +16,13 @@ using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuDifficultyCalculator : DifficultyCalculator
{
private const double star_rating_multiplier = 0.0265;
public override int Version => 20251020;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return (79.5 - hitWindowGreat) / 6;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new OsuDifficultyAttributes { Mods = mods };
@@ -55,30 +55,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty
var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders);
var speed = skills.OfType<Speed>().Single();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault();
var reading = skills.OfType<Reading>().Single();
double aimDifficultyValue = aim.DifficultyValue();
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
double speedDifficultyValue = speed.DifficultyValue();
double readingDifficultyValue = reading.DifficultyValue();
double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue);
double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue);
double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue);
double speedNotes = speed.RelevantNoteCount();
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue);
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue);
double aimDifficultStrainCount = aim.CountTopWeightedStrains();
double speedDifficultStrainCount = speed.CountTopWeightedStrains();
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders();
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains();
double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders();
double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount);
double difficultSliders = aim.GetDifficultSliders();
double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, ModUtils.CalculateRateWithMods(mods));
double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate);
double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate);
int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
@@ -86,15 +80,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
int totalHits = beatmap.HitObjects.Count;
double sliderFactor = aimDifficultyValue > 0
? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)
: 1;
double drainRate = beatmap.Difficulty.DrainRate;
var osuRatingCalculator = new OsuRatingCalculator(totalHits, overallDifficulty);
double aimDifficultyValue = aim.DifficultyValue();
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
double speedDifficultyValue = speed.DifficultyValue();
double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1;
var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor);
double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue);
double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue);
double readingRating = osuRatingCalculator.ComputeReadingRating(readingDifficultyValue);
double flashlightRating = 0.0;
@@ -102,18 +100,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty
flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue());
double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap);
var simulator = new OsuLegacyScoreSimulator();
var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap);
double baseAimPerformance = OsuPerformanceCalculator.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating);
double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating);
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance);
double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance);
double basePerformance =
Math.Pow(
Math.Pow(baseAimPerformance, 1.1) +
Math.Pow(baseSpeedPerformance, 1.1) +
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = calculateStarRating(basePerformance);
@@ -126,13 +127,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = speedRating,
SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating,
ReadingDifficulty = readingRating,
SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultStrainCount,
SpeedDifficultStrainCount = speedDifficultStrainCount,
ReadingDifficultNoteCount = readingDifficultNoteCount,
AimTopWeightedSliderFactor = aimTopWeightedSliderFactor,
SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCircleCount,
SliderCount = sliderCount,
@@ -145,29 +145,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return attributes;
}
public static double SumCognitionDifficulty(double reading, double flashlight)
private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue)
{
if (reading <= 0)
return flashlight;
double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue));
double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue));
if (flashlight <= 0)
return reading;
double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1);
// Nerf flashlight value in cognition sum when reading is greater than flashlight
return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0));
return calculateStarRating(totalValue);
}
private double calculateStarRating(double basePerformance)
{
return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER);
if (basePerformance <= 0.00001)
return 0;
return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
// The first jump is formed by the first two hitobjects of the map.
// If the map has less than two OsuHitObjects, the enumerator will not return anything.
for (int i = 1; i < beatmap.HitObjects.Count; i++)
@@ -178,14 +177,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return objects;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
var skills = new List<Skill>
{
new Aim(mods, true),
new Aim(mods, false),
new Speed(mods),
new Reading(mods)
new Speed(mods)
};
if (mods.Any(h => h is OsuModFlashlight))
@@ -115,13 +115,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double missCount = 0;
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (score.MaxCombo < fullComboThreshold)
missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5);
@@ -21,9 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight")]
public double Flashlight { get; set; }
[JsonProperty("reading")]
public double Reading { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
@@ -51,7 +48,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed);
yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy);
yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight);
yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading);
}
}
}
@@ -9,7 +9,6 @@ using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
@@ -20,8 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceCalculator : PerformanceCalculator
{
public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
public const double PERFORMANCE_NORM_EXPONENT = 1.1;
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private bool usingClassicSliderAccuracy;
private bool usingScoreV2;
@@ -52,18 +50,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double greatHitWindow;
private double okHitWindow;
private double mehHitWindow;
private double overallDifficulty;
private double approachRate;
private double drainRate;
private double? speedDeviation;
private double aimEstimatedSliderBreaks;
private double speedEstimatedSliderBreaks;
public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0);
public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
@@ -101,12 +95,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate);
drainRate = difficulty.DrainRate;
double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
double? scoreBasedEstimatedMissCount = null;
if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null)
if (usingClassicSliderAccuracy && score.LegacyTotalScore != null)
{
var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
@@ -122,12 +115,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
if (effectiveMissCount > 0)
{
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.AimTopWeightedSliderFactor, osuAttributes);
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.SpeedTopWeightedSliderFactor, osuAttributes);
}
double multiplier = PERFORMANCE_BASE_MULTIPLIER;
if (score.Mods.Any(m => m is OsuModNoFail))
@@ -153,12 +140,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
double readingValue = computeReadingValue(osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);
double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue);
double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier;
double totalValue =
Math.Pow(
Math.Pow(aimValue, 1.1) +
Math.Pow(speedValue, 1.1) +
Math.Pow(accuracyValue, 1.1) +
Math.Pow(flashlightValue, 1.1), 1.0 / 1.1
) * multiplier;
return new OsuPerformanceAttributes
{
@@ -166,7 +156,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Speed = speedValue,
Accuracy = accuracyValue,
Flashlight = flashlightValue,
Reading = readingValue,
EffectiveMissCount = effectiveMissCount,
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
@@ -205,14 +194,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimDifficulty *= sliderNerfFactor;
}
double aimValue = DifficultyToPerformance(aimDifficulty);
double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty);
double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) +
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus;
if (effectiveMissCount > 0)
{
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
@@ -220,10 +211,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate);
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate);
else if (score.Mods.Any(m => m is OsuModTraceable))
{
aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor);
aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor);
}
aimValue *= accuracy;
@@ -236,33 +227,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0;
double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus;
if (effectiveMissCount > 0)
{
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
}
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds))
{
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
speedValue *= 1.12;
}
else if (score.Mods.Any(m => m is OsuModTraceable))
{
speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate);
}
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
speedValue *= speedHighDeviationMultiplier;
// An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window.
// For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10.
double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35);
// Calculate accuracy assuming the worst case scenario
double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount);
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff);
double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat));
double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk));
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Find the proportion of 300s on speed notes assuming the hit window was the effective hit window.
double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation);
// Scale speed value by normalized accuracy.
speedValue *= Math.Pow(effectiveAccuracy, 2);
// Scale the speed value with accuracy and OD.
speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2);
return speedValue;
}
@@ -292,19 +294,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
accuracyValue *= amountHitObjectsWithAccuracy < 1000
? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)
: Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1);
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3));
// Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
if (score.Mods.Any(m => m is OsuModBlinds))
accuracyValue *= 1.14;
else if (score.Mods.Any(m => m is OsuModTraceable))
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable))
{
// Decrease bonus for AR > 10
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
}
if (score.Mods.Any(m => m is OsuModFlashlight))
accuracyValue *= 1.02;
return accuracyValue;
}
@@ -327,19 +330,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue;
}
private double computeReadingValue(OsuDifficultyAttributes attributes)
{
double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty);
if (effectiveMissCount > 0)
readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount);
// Scale the reading value with accuracy _harshly_.
readingValue *= Math.Pow(accuracy, 3);
return readingValue;
}
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
{
if (attributes.SliderCount <= 0)
@@ -349,13 +339,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (usingClassicSliderAccuracy)
{
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
@@ -390,22 +376,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
{
int nonMissMistakes = countOk + countMeh;
if (!usingClassicSliderAccuracy || nonMissMistakes == 0)
if (!usingClassicSliderAccuracy || countOk == 0)
return 0;
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor);
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor);
// Scores with more Oks and Mehs are more likely to have slider breaks.
// We add an arbitrary value to both sides of the division to make it more stable on extreme ends.
double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4);
// Scores with more Oks are more likely to have slider breaks.
double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk;
// There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred.
estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2);
return estimatedSliderBreaks * nonMissMistakeAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
}
/// <summary>
@@ -487,7 +470,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (speedDeviation == null)
return 0;
double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
// Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
// This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
@@ -506,34 +489,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return adjustedSpeedValue / speedValue;
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Traceable.
/// </summary>
private double calculateTraceableBonus(double sliderFactor = 1)
{
// We want to reward slider aim less, more so at lower AR
double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2);
double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6);
// Start from normal curve, rewarding lower AR up to AR7
double traceableBonus = 0.0275;
traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor;
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor;
return traceableBonus;
}
// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 1);
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss;
@@ -2,6 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty
{
@@ -9,21 +13,64 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
private const double difficulty_multiplier = 0.0675;
private readonly Mod[] mods;
private readonly int totalHits;
private readonly double approachRate;
private readonly double overallDifficulty;
private readonly double mechanicalDifficultyRating;
private readonly double sliderFactor;
public OsuRatingCalculator(int totalHits, double overallDifficulty)
public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor)
{
this.mods = mods;
this.totalHits = totalHits;
this.approachRate = approachRate;
this.overallDifficulty = overallDifficulty;
this.mechanicalDifficultyRating = mechanicalDifficultyRating;
this.sliderFactor = sliderFactor;
}
public double ComputeAimRating(double aimDifficultyValue)
{
double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275;
if (mods.Any(m => m is OsuModAutopilot))
return 0;
double aimRating = CalculateDifficultyRating(aimDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
aimRating = Math.Pow(aimRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
aimRating *= 0.9;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
aimRating *= 1.0 - magnetisedStrength;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (mods.Any(h => h is OsuModRelax))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateAimVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor);
}
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
@@ -32,24 +79,73 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public double ComputeSpeedRating(double speedDifficultyValue)
{
return CalculateDifficultyRating(speedDifficultyValue);
}
if (mods.Any(m => m is OsuModRelax))
return 0;
public double ComputeReadingRating(double readingDifficultyValue)
{
double readingRating = CalculateDifficultyRating(readingDifficultyValue);
double speedRating = CalculateDifficultyRating(speedDifficultyValue);
if (mods.Any(m => m is OsuModAutopilot))
speedRating *= 0.5;
if (mods.Any(m => m is OsuModMagnetised))
{
// reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
speedRating *= 1.0 - magnetisedStrength * 0.3;
}
double ratingMultiplier = 1.0;
ratingMultiplier *= 0.75 + Math.Pow(Math.Max(0, overallDifficulty), 2.2) / 800;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
return readingRating * Math.Cbrt(ratingMultiplier);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (mods.Any(m => m is OsuModAutopilot))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateSpeedVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor);
}
ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750;
return speedRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeFlashlightRating(double flashlightDifficultyValue)
{
if (!mods.Any(m => m is OsuModFlashlight))
return 0;
double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
flashlightRating = Math.Pow(flashlightRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
flashlightRating *= 0.7;
else if (mods.Any(m => m is OsuModAutopilot))
flashlightRating *= 0.4;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
flashlightRating *= 1.0 - magnetisedStrength;
}
if (mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = mods.OfType<OsuModDeflate>().First().StartScale.Value;
flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
double ratingMultiplier = 1.0;
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
@@ -62,6 +158,56 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightRating * Math.Sqrt(ratingMultiplier);
}
private double calculateAimVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
private double calculateSpeedVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Hidden and Traceable.
/// </summary>
public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1)
{
// NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side.
bool isAlwaysPartiallyVisible = mods.OfType<OsuModHidden>().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType<OsuModTraceable>().Any();
// Start from normal curve, rewarding lower AR up to AR7
// TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses
// This means it has an advantage over HD, so we decrease the multiplier to compensate
// This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible)
double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7));
readingBonus *= visibilityFactor;
// We want to reward slideraim on low AR less
double sliderVisibilityFactor = Math.Pow(sliderFactor, 3);
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor;
return readingBonus;
}
public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier;
}
}
@@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
@@ -36,22 +35,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public readonly double AdjustedDeltaTime;
/// <summary>
/// Amount of time elapsed between lastDifficultyObject's <see cref="DifficultyHitObject.EndTime"/> and <see cref="DifficultyHitObject.StartTime"/> capped to a minimum of <see cref="MIN_DELTA_TIME"/>ms.
/// </summary>
public double LastObjectEndDeltaTime { get; private set; }
/// <summary>
/// Time (in ms) between the object first appearing and the time it needs to be clicked.
/// <see cref="OsuHitObject.TimePreempt"/> adjusted by clock rate.
/// </summary>
public readonly double Preempt;
/// <summary>
/// Normalised distance from the start position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double JumpDistance { get; private set; }
/// <summary>
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// <para>
@@ -118,10 +101,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
public double? Angle { get; private set; }
/// <summary>
/// Angle of the vector created between current and current-1
/// normalised to consider symmetrical vectors in any axis to be the same angle.
/// Retrieves the full hit window for a Great <see cref="HitResult"/>.
/// </summary>
public double? NormalisedVectorAngle { get; private set; }
public double HitWindowGreat { get; private set; }
/// <summary>
/// Selective bonus for maps with higher circle size.
@@ -139,11 +121,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime;
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70);
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40);
Preempt = BaseObject.TimePreempt / clockRate;
if (BaseObject is Slider sliderObject)
{
HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
else
{
HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
computeSliderCursorPosition();
setDistances(clockRate);
@@ -160,9 +148,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
}
double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt;
// Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod.
double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN);
double fadeInDuration = BaseObject.TimeFadeIn;
if (hidden)
{
@@ -189,16 +175,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{
double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5);
// Can't doubletap if circles don't intersect
double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2);
return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio));
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
}
return 0;
@@ -209,12 +189,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (BaseObject is Slider currentSlider)
{
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3));
TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME);
}
MinimumJumpTime = AdjustedDeltaTime;
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || LastObject is Spinner)
return;
@@ -224,8 +202,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition;
JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor;
LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor;
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
MinimumJumpTime = AdjustedDeltaTime;
MinimumJumpDistance = LazyJumpDistance;
if (LastObject is Slider lastSlider && lastDifficultyObject != null)
@@ -261,18 +239,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner)
{
if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
lastCursorPosition = prevSlider.HeadCircle.StackedPosition;
Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject);
double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition);
Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition;
Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition;
Vector2 v = BaseObject.StackedPosition - lastCursorPosition;
NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X));
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
Angle = Math.Min(angle, sliderAngle);
Angle = Math.Abs(Math.Atan2(det, dot));
}
}
@@ -384,30 +359,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
}
}
private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition)
{
Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject);
if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
{
OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2];
lastLastCursorPosition = secondLastNestedObject.StackedPosition;
}
return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
}
private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition)
{
Vector2 v1 = lastLastPosition - lastPosition;
Vector2 v2 = currentPosition - lastPosition;
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
return Math.Abs(Math.Atan2(det, dot));
}
private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject)
{
return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition;
+10 -192
View File
@@ -4,14 +4,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -19,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// <summary>
/// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
/// </summary>
public class Aim : VariableLengthStrainSkill
public class Aim : OsuStrainSkill
{
public readonly bool IncludeSliders;
@@ -31,39 +27,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
private double skillMultiplierSnap => 70.9;
private double skillMultiplierAgility => 2.35;
private double skillMultiplierFlow => 242.0;
private double skillMultiplierTotal => 1.12;
private double combinedSnapNormExponent => 1.2;
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
/// </summary>
private int reducedSectionTime => 4000;
/// <summary>
/// The baseline multiplier applied to the section with the biggest strain.
/// </summary>
private double reducedStrainBaseline => 0.727;
private double skillMultiplier => 26;
private double strainDecayBase => 0.15;
private readonly List<double> sliderStrains = new List<double>();
private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000);
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) =>
currentStrain * strainDecay(time - current.Previous(0).StartTime);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
if (Mods.Any(m => m is OsuModAutopilot))
return 0;
double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain *= decay;
currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay);
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier;
if (current.BaseObject is Slider)
sliderStrains.Add(currentStrain);
@@ -71,73 +47,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return currentStrain;
}
private double calculateModAdjustedDifficulty(DifficultyHitObject current)
{
double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap;
double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility;
double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow;
double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
totalDifficulty *= 1.0 - magnetisedStrength;
}
return totalDifficulty;
}
private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty)
{
// We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams
// Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping
// So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow
double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty);
double pFlow = 1 - pSnap;
if (Mods.Any(m => m is OsuModTouchDevice))
{
// we don't adjust agility here since agility represents TD difficulty in a decent enough way
snapDifficulty = Math.Pow(snapDifficulty, 0.89);
combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
}
if (Mods.Any(m => m is OsuModRelax))
{
combinedSnapDifficulty *= 0.75;
flowDifficulty *= 0.6;
}
double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow;
double totalStrain = totalDifficulty * skillMultiplierTotal;
return totalStrain;
}
// A function that turns the ratio of snap : flow into the probability of snapping/flowing
// It has the constraints:
// P(snap) + P(flow) = 1 (the object is always either snapped or flowed)
// P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible)
// Therefore: f(x) + f(1/x) = 1
// 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing)
// This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant
private static double calculateSnapFlowProbability(double ratio)
{
const double k = 7.27;
if (ratio == 0)
return 0;
if (double.IsNaN(ratio))
return 1;
return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio));
}
public double GetDifficultSliders()
{
if (sliderStrains.Count == 0)
@@ -151,97 +60,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0))));
}
public double CountTopWeightedSliders(double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return 0;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1));
}
public override double DifficultyValue()
{
double difficulty = 0;
double time = 0;
var strains = getReducedStrainPeaks();
// Difficulty is a continuous weighted sum of the sorted strains
foreach (StrainPeak strain in strains)
{
/* Weighting function can be thought of as:
b
DecayWeight^x dx
a
where a = startTime and b = endTime
Technically, the function below has been slightly modified from the equation above.
The real function would be
double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime);
...
return difficulty / Math.Log(1 / DecayWeight);
E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122...
This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill.
Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes.
*/
double startTime = time;
double endTime = time + strain.SectionLength / MaxSectionLength;
double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime);
difficulty += strain.Value * weight;
time = endTime;
}
return difficulty / (1 - DecayWeight);
}
/// <summary>
/// Returns a sorted enumerable of strain peaks with the highest values reduced.
/// </summary>
/// <returns></returns>
private IEnumerable<StrainPeak> getReducedStrainPeaks()
{
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0);
List<StrainPeak> strains = peaks.OrderByDescending(p => p.Value).ToList();
const int chunk_size = 20;
double time = 0;
int strainsToRemove = 0; // All strains are removed at the end for optimization purposes
// We are reducing the highest strains first to account for extreme difficulty spikes
// Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains
while (strains.Count > strainsToRemove && time < reducedSectionTime)
{
StrainPeak strain = strains[strainsToRemove];
for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1)));
strains.Add(new StrainPeak(
strain.Value * Interpolation.Lerp(reducedStrainBaseline, 1.0, scale),
Math.Min(chunk_size, strain.SectionLength - addedTime)
));
}
time += strain.SectionLength;
strainsToRemove++;
}
strains.RemoveRange(0, strainsToRemove);
return strains.OrderByDescending(p => p.Value);
}
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
}
}
@@ -5,7 +5,6 @@ using System;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Mods;
@@ -17,12 +16,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Flashlight : StrainSkill
{
private readonly bool hasHiddenMod;
public Flashlight(Mod[] mods)
: base(mods)
{
hasHiddenMod = mods.Any(m => m is OsuModHidden);
}
private double skillMultiplier => 0.058;
private double skillMultiplier => 0.05512;
private double strainDecayBase => 0.15;
private double currentStrain;
@@ -33,43 +35,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current)
{
if (!Mods.Any(m => m is OsuModFlashlight))
return 0;
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += calculateModAdjustedDifficulty(current) * skillMultiplier;
currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier;
return currentStrain;
}
private double calculateModAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods);
if (Mods.Any(m => m is OsuModTouchDevice))
difficulty = Math.Pow(difficulty, 0.9);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
difficulty *= 1.0 - magnetisedStrength;
}
if (Mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = Mods.OfType<OsuModDeflate>().First().StartScale.Value;
difficulty *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
if (Mods.Any(m => m is OsuModRelax))
difficulty *= 0.7;
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.4;
return difficulty;
}
public override double DifficultyValue() => GetCurrentStrainPeaks().Sum();
public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2);
@@ -0,0 +1,62 @@
// 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 osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using System.Linq;
using osu.Framework.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public abstract class OsuStrainSkill : StrainSkill
{
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
/// </summary>
protected virtual int ReducedSectionCount => 10;
/// <summary>
/// The baseline multiplier applied to the section with the biggest strain.
/// </summary>
protected virtual double ReducedStrainBaseline => 0.75;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
{
}
public override double DifficultyValue()
{
double difficulty = 0;
double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p > 0);
List<double> strains = peaks.OrderDescending().ToList();
// We are reducing the highest strains first to account for extreme difficulty spikes
for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1)));
strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale);
}
// Difficulty is the weighted sum of the highest strains from every section.
// We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderDescending())
{
difficulty += strain * weight;
weight *= DecayWeight;
}
return difficulty;
}
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;
}
}
@@ -1,121 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public class Reading : HarmonicSkill
{
private readonly List<DifficultyHitObject> objectList = new List<DifficultyHitObject>();
private readonly bool hasHiddenMod;
public Reading(Mod[] mods)
: base(mods)
{
hasHiddenMod = mods.OfType<OsuModHidden>().Any(m => !m.OnlyFadeApproachCircles.Value);
}
private double currentStrain;
private double skillMultiplier => 2.5;
private double strainDecayBase => 0.8;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double ObjectDifficultyOf(DifficultyHitObject current)
{
objectList.Add(current);
double decay = strainDecay(current.DeltaTime);
currentStrain *= decay;
currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier;
return currentStrain;
}
private double calculateModAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod);
if (Mods.Any(m => m is OsuModTouchDevice))
difficulty = Math.Pow(difficulty, 0.89);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
difficulty *= 1.0 - magnetisedStrength;
}
if (Mods.Any(m => m is OsuModRelax))
difficulty *= 0.4;
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.1;
return difficulty;
}
protected override void ApplyDifficultyTransformation(double[] difficulties)
{
const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised
int reducedNoteCount = calculateReducedNoteCount();
for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1)));
difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale);
}
}
private int calculateReducedNoteCount()
{
const double reduced_difficulty_duration = 60 * 1000;
if (objectList.Count == 0)
return 0;
double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration;
int reducedNoteCount = 0;
foreach (var hitObject in objectList)
{
if (hitObject.StartTime > reducedDuration)
break;
reducedNoteCount++;
}
return reducedNoteCount;
}
public override double CountTopWeightedObjectDifficulties(double difficultyValue)
{
if (ObjectDifficulties.Count == 0)
return 0.0;
if (NoteWeightSum == 0)
return 0.0;
double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical
if (consistentTopNote == 0)
return 0;
return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1));
}
}
}
@@ -3,33 +3,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using System.Linq;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
/// <summary>
/// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit.
/// </summary>
public class Speed : HarmonicSkill
public class Speed : OsuStrainSkill
{
private double skillMultiplier => 1.16;
private double skillMultiplier => 1.47;
private double strainDecayBase => 0.3;
private double currentStrain;
private double currentRhythm;
private readonly List<double> sliderStrains = new List<double>();
private double currentStrain;
private double strainDecayBase => 0.3;
protected override double HarmonicScale => 20;
protected override double DecayExponent => 0.9;
protected override int ReducedSectionCount => 5;
public Speed(Mod[] mods)
: base(mods)
@@ -38,17 +35,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double ObjectDifficultyOf(DifficultyHitObject current)
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
if (Mods.Any(m => m is OsuModRelax))
return 0;
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain *= decay;
currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier;
double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm;
@@ -58,44 +52,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return totalStrain;
}
private double calculateModAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = SpeedEvaluator.EvaluateDifficultyOf(current);
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.5;
return difficulty;
}
public double RelevantNoteCount()
{
if (ObjectDifficulties.Count == 0)
if (ObjectStrains.Count == 0)
return 0;
double maxStrain = ObjectDifficulties.Max();
double maxStrain = ObjectStrains.Max();
if (maxStrain == 0)
return 0;
return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
}
public double CountTopWeightedSliders(double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
if (NoteWeightSum == 0)
return 0.0;
double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top note be if all note values were identical
if (consistentTopNote == 0)
return 0;
// Use a weighted sum of all notes. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopNote, 0.88, 10, 1.1));
}
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
}
}
@@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Utils
{
public static class OsuStrainUtils
{
public static double CountTopWeightedSliders(IReadOnlyCollection<double> sliderStrains, double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return 0;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1));
}
}
}
@@ -141,6 +141,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// </summary>
public void MissForcefully() => ApplyMinResult();
// ReSharper disable once FunctionRecursiveOnAllPaths (TODO: remove after fixed https://youtrack.jetbrains.com/issue/RIDER-135036/Incorrect-recursive-on-all-execution-paths-inspection)
private RectangleF parentScreenSpaceRectangle => ((DrawableOsuHitObject)ParentHitObject)?.parentScreenSpaceRectangle ?? Parent!.ScreenSpaceDrawQuad.AABBFloat;
/// <summary>
@@ -9,12 +9,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
public partial class TrianglesPiece : Triangles
{
protected override bool CreateNewTriangles => false;
protected override float SpawnRatio => 0.5f;
public TrianglesPiece(int? seed = null)
: base(seed)
{
TriangleScale = 1.2f;
SpawnRatio = 0.5f;
HideAlphaDiscrepancies = false;
ClampAxes = Axes.None;
}
@@ -5,11 +5,9 @@ using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
@@ -18,11 +16,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
/// <summary>
/// Evaluate the difficulty of a hitobject considering its interval change.
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow)
{
if (hitObject.BaseObject is not Hit)
return 0;
TaikoRhythmData rhythmData = ((TaikoDifficultyHitObject)hitObject).RhythmData;
double difficulty = 0.0d;
@@ -30,8 +25,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
double samePattern = 0;
double intervalPenalty = 0;
double hitWindow = hitObject.HitWindow(HitResult.Great);
if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects
{
sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow);
@@ -63,8 +56,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
intervalDifficulty *= DifficultyCalculationUtils.Logistic(
durationDifference / hitWindow,
midpointOffset: 0.35,
multiplier: 2,
midpointOffset: 0.7,
multiplier: 1.0,
maxValue: 1);
}
}
@@ -72,8 +65,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
// Penalise patterns that can be hit within a single hit window.
intervalDifficulty *= DifficultyCalculationUtils.Logistic(
sameRhythmGroupedHitObjects.Duration / hitWindow,
midpointOffset: 0.3,
multiplier: 2,
midpointOffset: 0.6,
multiplier: 1,
maxValue: 1);
return Math.Pow(intervalDifficulty, 0.75);
@@ -7,10 +7,10 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
using osu.Game.Rulesets.Taiko.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
{
@@ -17,14 +17,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
protected override double SkillMultiplier => 1.0;
protected override double StrainDecayBase => 0.4;
public Rhythm(Mod[] mods)
private readonly double greatHitWindow;
public Rhythm(Mod[] mods, double greatHitWindow)
: base(mods)
{
this.greatHitWindow = greatHitWindow;
}
protected override double StrainValueOf(DifficultyHitObject current)
{
double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current);
double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow);
// To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty.
double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain
@@ -10,12 +10,13 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Utils;
using osu.Game.Rulesets.Taiko.Scoring;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
@@ -40,14 +41,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0;
isRelax = mods.Any(h => h is TaikoModRelax);
return new Skill[]
{
new Rhythm(mods),
new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate),
new Reading(mods),
new Colour(mods),
new Stamina(mods, false, isConvert),
@@ -63,15 +67,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
new TaikoModHardRock(),
};
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
var difficultyHitObjects = new List<DifficultyHitObject>();
var centreObjects = new List<TaikoDifficultyHitObject>();
var rimObjects = new List<TaikoDifficultyHitObject>();
var noteObjects = new List<TaikoDifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
// Generate TaikoDifficultyHitObjects from the beatmap's hit objects.
for (int i = 2; i < beatmap.HitObjects.Count; i++)
{
@@ -95,7 +97,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
return difficultyHitObjects;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new TaikoDifficultyAttributes { Mods = mods };
@@ -106,16 +108,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
var stamina = skills.OfType<Stamina>().Single(s => !s.SingleColourStamina);
var singleColourStamina = skills.OfType<Stamina>().Single(s => s.SingleColourStamina);
double staminaDifficultyValue = stamina.DifficultyValue();
double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double readingSkill = reading.DifficultyValue() * reading_skill_multiplier;
double colourSkill = colour.DifficultyValue() * colour_skill_multiplier;
double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier;
double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5);
double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue);
double staminaDifficultStrains = stamina.CountTopWeightedStrains();
// As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm.
patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10);
@@ -184,10 +184,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
}
List<double> hitObjectStrainPeaks = combinePeaks(
rhythm.GetObjectDifficulties(),
reading.GetObjectDifficulties(),
colour.GetObjectDifficulties(),
stamina.GetObjectDifficulties()
rhythm.GetObjectStrains().ToList(),
reading.GetObjectStrains().ToList(),
colour.GetObjectStrains().ToList(),
stamina.GetObjectStrains().ToList()
);
if (hitObjectStrainPeaks.Count == 0)
@@ -209,7 +209,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// <summary>
/// Combines lists of peak strains from multiple skills into a list of single peak strains for each section.
/// </summary>
private List<double> combinePeaks(IReadOnlyList<double> rhythmPeaks, IReadOnlyList<double> readingPeaks, IReadOnlyList<double> colourPeaks, IReadOnlyList<double> staminaPeaks)
private List<double> combinePeaks(List<double> rhythmPeaks, List<double> readingPeaks, List<double> colourPeaks, List<double> staminaPeaks)
{
var combinedPeaks = new List<double>();
@@ -1,7 +1,9 @@
// 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.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using osu.Framework.Allocation;
@@ -24,6 +26,36 @@ namespace osu.Game.Tests.Beatmaps.IO
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[TestCase(96, 32)]
[TestCase(32, 32)]
[TestCase(24, 32)]
[TestCase(23, 16)]
[TestCase(15, 16)]
[TestCase(12, 16)]
[TestCase(11, 8)]
[TestCase(6, 8)]
[TestCase(2, 4)]
public void TestGridSizeClampedToStableValues(int set, int expected)
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz"));
AddStep("adjust grid", () => beatmap.Beatmap.GridSize = set);
AddStep("save", () => beatmaps.Save((beatmap.BeatmapInfo as BeatmapInfo)!, beatmap.Beatmap));
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
AddAssert("grid clamped", () => beatmap.Beatmap.GridSize, () => Is.EqualTo(expected));
}
[Test]
public void TestObjectsSnappedAfterTruncatingExport()
{
@@ -131,6 +163,48 @@ namespace osu.Game.Tests.Beatmaps.IO
}
}
[Test]
public void TestExportUsesCarriageReturnLineFeed()
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"legacy-export-stability-test.olz"));
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddAssert(".osu file uses CRLF line endings",
() => hasBareLineFeed(outStream.GetBuffer()),
() => Is.False);
bool hasBareLineFeed(byte[] archiveBytes)
{
using var memoryStream = new MemoryStream(archiveBytes);
using var archiveReader = new ZipArchiveReader(memoryStream);
foreach (string filename in archiveReader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.Ordinal)))
{
byte[] content = archiveReader.GetStream(filename).ReadAllBytesToArray();
for (int i = 0; i < content.Length; i++)
{
if (content[i] != '\n')
continue;
if (i == 0 || content[i - 1] != '\r')
return true;
}
}
return false;
}
}
private IWorkingBeatmap importBeatmapFromStream(Stream stream)
{
var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely();
@@ -65,6 +65,7 @@ namespace osu.Game.Tests.Beatmaps
}
[Test]
[FlakyTest] // one fix attempted in https://github.com/ppy/osu/pull/37178, didn't work
public void TestInvalidationFlow()
{
BeatmapInfo postEditBeatmapInfo = null;
@@ -187,6 +187,7 @@ namespace osu.Game.Tests.Database
}
[Test]
[FlakyTest]
public void TestCustomRulesetScoreNotSubjectToUpgrades([Values] bool available)
{
RulesetInfo rulesetInfo = null!;
@@ -258,6 +258,6 @@ namespace osu.Game.Tests.Gameplay
}
private void disableLayeredHitSounds()
=> AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[LegacySetting.LayeredHitSounds.ToString()] = "0");
=> AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[nameof(LegacySetting.LayeredHitSounds)] = "0");
}
}
@@ -223,17 +223,17 @@ namespace osu.Game.Tests.NonVisual
protected override Mod[] DifficultyAdjustmentMods { get; }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
throw new NotImplementedException();
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
throw new NotImplementedException();
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
throw new NotImplementedException();
}
@@ -219,6 +219,46 @@ namespace osu.Game.Tests.NonVisual.Filtering
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("\"quoted words\"", false)]
[TestCase("\"the artist\"", false)]
[TestCase("the artist \"quoted words\"", false)]
[TestCase("\"unknown\"", true)]
public void TestCriteriaMatchingTermsAdjacentToPunctuation(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
exampleBeatmapInfo.Metadata.Title = "the artist \"quoted words\"";
var criteria = new FilterCriteria
{
Ruleset = new RulesetInfo { OnlineID = 6 },
AllowConvertedBeatmaps = true,
SearchText = terms
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("~quoted words~", false)]
[TestCase("the artist", false)]
[TestCase("the artist ~quoted words~", false)]
[TestCase("~unknown~", true)]
public void TestCriteriaMatchingTermsAdjacentToMathSymbols(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
exampleBeatmapInfo.Metadata.Title = "the artist ~quoted words~";
var criteria = new FilterCriteria
{
Ruleset = new RulesetInfo { OnlineID = 6 },
AllowConvertedBeatmaps = true,
SearchText = terms
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("", false)]
[TestCase("Goes", false)]
@@ -15,7 +15,6 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Tests.Beatmaps;
using osu.Game.Utils;
namespace osu.Game.Tests.NonVisual
{
@@ -173,15 +172,13 @@ namespace osu.Game.Tests.NonVisual
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
=> new TestDifficultyAttributes { Objects = beatmap.HitObjects.ToArray() };
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
foreach (var obj in beatmap.HitObjects.OfType<TestHitObject>())
{
if (!obj.Skip)
@@ -194,7 +191,7 @@ namespace osu.Game.Tests.NonVisual
return objects;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new PassThroughSkill(mods) };
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new PassThroughSkill(mods) };
private class PassThroughSkill : Skill
{
@@ -203,9 +200,8 @@ namespace osu.Game.Tests.NonVisual
{
}
protected override double ProcessInternal(DifficultyHitObject current)
public override void Process(DifficultyHitObject current)
{
return 0;
}
public override double DifficultyValue() => 1;
@@ -25,6 +25,7 @@ namespace osu.Game.Tests.Visual.Components
private NotificationOverlay notificationOverlay = null!;
private ChatOverlay chatOverlay = null!;
private TestMetadataClient metadataClient = null!;
private FriendPresenceNotifier notifier = null!;
[SetUp]
public void Setup() => Schedule(() =>
@@ -45,7 +46,11 @@ namespace osu.Game.Tests.Visual.Components
notificationOverlay,
chatOverlay,
metadataClient,
new FriendPresenceNotifier()
notifier = new FriendPresenceNotifier
{
// Speeds up tests that don't rely on this debounce a little bit.
OfflineDebounceTime = 0
}
}
};
@@ -127,5 +132,27 @@ namespace osu.Game.Tests.Visual.Components
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
[Test]
public void TestOfflineDebounce()
{
AddStep("set debounce time", () =>
{
notifier.NotificationDebounceTime = 0;
notifier.OfflineDebounceTime = 5000;
});
AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
for (int i = 0; i < 3; i++)
{
AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddStep("bring friend offline", () => metadataClient.FriendPresenceUpdated(1, null));
}
AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddUntilStep("offline notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
}
}
@@ -128,6 +128,7 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
[FlakyTest]
public void TestLengthAndStarRatingUpdated()
{
WorkingBeatmap working = null;
@@ -24,6 +24,7 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
[FlakyTest]
public void TestLocallyModifyingOnlineBeatmap()
{
string initialHash = string.Empty;
@@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
@@ -140,14 +139,16 @@ namespace osu.Game.Tests.Visual.Editing
private void setUpEditor(RulesetInfo ruleset)
{
BeatmapSetInfo beatmapSet = null!;
BeatmapSetInfo? beatmapSet = null;
AddStep("Import test beatmap", () =>
Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()
);
AddStep("Retrieve beatmap", () =>
beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()
);
AddUntilStep("Retrieve beatmap", () =>
{
beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected)?.Value.Detach();
return beatmapSet != null;
});
AddStep("Present beatmap", () => Game.PresentBeatmap(beatmapSet));
AddUntilStep("Wait for song select", () =>
Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)
@@ -157,7 +158,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset);
AddStep("Open editor for ruleset", () =>
((SoloSongSelect)Game.ScreenStack.CurrentScreen)
.Edit(beatmapSet.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name))
.Edit(beatmapSet!.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name))
);
AddUntilStep("Wait for editor open", () => editor?.ReadyForUse == true);
}
@@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private bool seek;
[Test]
[FlakyTest]
[Ignore("Still failing even with [FlakyTest] applied.")]
public void TestAllSamplesStopDuringSeek()
{
DrawableSlider? slider = null;
@@ -211,7 +211,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("move mouse to centre of screen", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre));
AddUntilStep("wait for settings overlay hidden", () => settingsOverlay().Expanded.Value, () => Is.False);
PlayerSettingsOverlay settingsOverlay() => Player.ChildrenOfType<PlayerSettingsOverlay>().Single();
ReplaySettingsOverlay settingsOverlay() => Player.ChildrenOfType<ReplaySettingsOverlay>().Single();
}
private void loadPlayerWithBeatmap(IBeatmap? beatmap = null)
@@ -503,27 +503,27 @@ namespace osu.Game.Tests.Visual.Gameplay
InputManager.Click(MouseButton.Left);
});
AddStep("Right-click TopLeft anchor", () =>
AddStep("Click TopLeft anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("TopLeft"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("TopLeft item checked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
AddStep("Right-click Centre anchor", () =>
AddStep("Click Centre anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("Centre"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("Centre item checked", () => (getMenuItemByText("Centre").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
AddAssert("TopLeft item unchecked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.False);
AddStep("Right-click Closest anchor", () =>
AddStep("Click Closest anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("Closest"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("Closest item checked", () => (getMenuItemByText("Closest").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
@@ -331,7 +331,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
p.RequestResults = _ => resultsRequested = true;
});
AddUntilStep("wait for load", () => playlist.ChildrenOfType<DrawableLinkCompiler>().Any() && playlist.ChildrenOfType<BeatmapCardThumbnail>().First().DrawWidth > 0);
AddUntilStep("wait for load", () => playlist.ChildrenOfType<DrawableLinkCompiler>().Any()
&& playlist.ChildrenOfType<LinkFlowContainer>().First().ChildrenOfType<SpriteText>().Any()
&& playlist.ChildrenOfType<BeatmapCardThumbnail>().First().DrawWidth > 0);
AddStep("move mouse to first item title", () => InputManager.MoveMouseTo(playlist.ChildrenOfType<LinkFlowContainer>().First().ChildrenOfType<SpriteText>().First()));
AddAssert("first item title not hovered", () => playlist.ChildrenOfType<DrawableLinkCompiler>().First().IsHovered, () => Is.False);
@@ -50,47 +50,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.CacheAs(ongoingOperationTracker = new OngoingOperationTracker());
Dependencies.CacheAs(availabilityTracker.Object);
availabilityTracker.SetupGet(a => a.Availability).Returns(beatmapAvailability);
multiplayerClient.SetupGet(m => m.LocalUser).Returns(() => localUser);
multiplayerClient.SetupGet(m => m.Room).Returns(() => multiplayerRoom);
// By default, the local user is to be the host.
multiplayerClient.SetupGet(m => m.IsHost).Returns(() => ReferenceEquals(multiplayerRoom.Host, localUser));
// Assume all state changes are accepted by the server.
multiplayerClient.Setup(m => m.ChangeState(It.IsAny<MultiplayerUserState>()))
.Callback((MultiplayerUserState r) =>
{
Logger.Log($"Changing local user state from {localUser.State} to {r}");
localUser.State = r;
raiseRoomUpdated();
});
multiplayerClient.Setup(m => m.StartMatch())
.Callback(() =>
{
multiplayerClient.Raise(m => m.LoadRequested -= null);
// immediately "end" gameplay, as we don't care about that part of the process.
changeUserState(localUser.UserID, MultiplayerUserState.Idle);
});
multiplayerClient.Setup(m => m.SendMatchRequest(It.IsAny<MatchUserRequest>()))
.Callback((MatchUserRequest request) =>
{
switch (request)
{
case StartMatchCountdownRequest countdownStart:
setRoomCountdown(countdownStart.Duration);
break;
case StopCountdownRequest:
clearRoomCountdown();
break;
}
});
Children = new Drawable[]
{
ongoingOperationTracker,
@@ -103,10 +62,51 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AddStep("reset state", () =>
{
multiplayerClient.Invocations.Clear();
multiplayerClient.Reset();
multiplayerClient.SetupGet(m => m.LocalUser).Returns(() => localUser);
multiplayerClient.SetupGet(m => m.Room).Returns(() => multiplayerRoom);
// By default, the local user is to be the host.
multiplayerClient.SetupGet(m => m.IsHost).Returns(() => ReferenceEquals(multiplayerRoom.Host, localUser));
// Assume all state changes are accepted by the server.
multiplayerClient.Setup(m => m.ChangeState(It.IsAny<MultiplayerUserState>()))
.Callback((MultiplayerUserState r) =>
{
Logger.Log($"Changing local user state from {localUser.State} to {r}");
localUser.State = r;
raiseRoomUpdated();
});
multiplayerClient.Setup(m => m.StartMatch())
.Callback(() =>
{
multiplayerClient.Raise(m => m.LoadRequested -= null);
// immediately "end" gameplay, as we don't care about that part of the process.
changeUserState(localUser.UserID, MultiplayerUserState.Idle);
});
multiplayerClient.Setup(m => m.SendMatchRequest(It.IsAny<MatchUserRequest>()))
.Callback((MatchUserRequest request) =>
{
switch (request)
{
case StartMatchCountdownRequest countdownStart:
setRoomCountdown(countdownStart.Duration);
break;
case StopCountdownRequest:
clearRoomCountdown();
break;
}
});
beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable();
availabilityTracker.Reset();
availabilityTracker.SetupGet(a => a.Availability).Returns(beatmapAvailability);
PlaylistItem item = new PlaylistItem(Beatmap.Value.BeatmapInfo)
{
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
@@ -375,6 +375,22 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestAbortMatch()
{
setUpMatchCallbacks();
// Ready
ClickButtonWhenEnabled<MultiplayerReadyButton>();
// Start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// Abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once));
}
private void setUpMatchCallbacks()
{
AddStep("setup client", () =>
{
@@ -383,6 +399,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
multiplayerClient.Raise(m => m.LoadRequested -= null);
multiplayerClient.Object.Room!.State = MultiplayerRoomState.WaitingForLoad;
raiseRoomUpdated();
// The local user state doesn't really matter, so let's do the same as the base implementation for these tests.
changeUserState(localUser.UserID, MultiplayerUserState.Idle);
@@ -395,19 +412,133 @@ namespace osu.Game.Tests.Visual.Multiplayer
raiseRoomUpdated();
});
});
}
// Ready
ClickButtonWhenEnabled<MultiplayerReadyButton>();
[Test]
public void TestRefereeSpectating()
{
AddStep("set up referee", () =>
{
multiplayerClient.SetupGet(m => m.IsReferee).Returns(true);
multiplayerClient.SetupGet(m => m.IsHost).Returns(false);
multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee;
raiseRoomUpdated();
});
// Start match
const int users = 10;
AddStep("add many users", () =>
{
for (int i = 0; i < users; i++)
addUser(new APIUser { Id = i, Username = "Another user" });
});
AddAssert("button disabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.False);
AddStep("move to spectate", () => changeUserState(multiplayerClient.Object.LocalUser!.UserID, MultiplayerUserState.Spectating));
AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready));
AddAssert("button enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
// start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// Abort
// abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once));
}
[Test]
public void TestRefereeFlowWithoutCountdown()
{
AddStep("set up referee", () =>
{
multiplayerClient.SetupGet(m => m.IsReferee).Returns(true);
multiplayerClient.SetupGet(m => m.IsHost).Returns(false);
multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee;
raiseRoomUpdated();
});
const int users = 10;
AddStep("add many users", () =>
{
for (int i = 0; i < users; i++)
addUser(new APIUser { Id = i, Username = "Another user" });
});
AddAssert("button disabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.False);
AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready));
AddAssert("button enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
// start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once));
}
[Test]
public void TestRefereeFlowWithCountdown()
{
AddStep("set up referee", () =>
{
multiplayerClient.SetupGet(m => m.IsReferee).Returns(true);
multiplayerClient.SetupGet(m => m.IsHost).Returns(false);
multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee;
raiseRoomUpdated();
});
const int users = 10;
AddStep("add many users", () =>
{
for (int i = 0; i < users; i++)
addUser(new APIUser { Id = i, Username = "Another user" });
});
AddAssert("button disabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.False);
AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready));
AddAssert("button enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
AddUntilStep("countdown button shown", () => this.ChildrenOfType<MultiplayerCountdownButton>().SingleOrDefault()?.IsPresent == true);
ClickButtonWhenEnabled<MultiplayerCountdownButton>();
AddStep("click the first countdown button", () =>
{
var popoverButton = this.ChildrenOfType<Popover>().Single().ChildrenOfType<OsuButton>().First();
InputManager.MoveMouseTo(popoverButton);
InputManager.Click(MouseButton.Left);
});
AddStep("check request received", () =>
{
multiplayerClient.Verify(m => m.SendMatchRequest(It.Is<StartMatchCountdownRequest>(req =>
req.Duration == TimeSpan.FromSeconds(10)
)), Times.Once);
});
ClickButtonWhenEnabled<MultiplayerCountdownButton>();
AddStep("click the cancel button", () =>
{
var popoverButton = this.ChildrenOfType<Popover>().Single().ChildrenOfType<OsuButton>().Last();
InputManager.MoveMouseTo(popoverButton);
InputManager.Click(MouseButton.Left);
});
AddStep("check request received", () =>
{
multiplayerClient.Verify(m => m.SendMatchRequest(It.IsAny<StopCountdownRequest>()), Times.Once);
});
}
private void verifyGameplayStartFlow()
{
checkLocalUserState(MultiplayerUserState.Ready);
@@ -156,7 +156,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
// components wrapped in skinnable target containers load asynchronously, potentially taking more than one frame to load.
// therefore use until step rather than direct assert to account for that.
AddUntilStep("all interactive elements removed", () => this.ChildrenOfType<Player>().All(p =>
!p.ChildrenOfType<PlayerSettingsOverlay>().Any() &&
!p.ChildrenOfType<ReplaySettingsOverlay>().Any() &&
!p.ChildrenOfType<HoldForMenuButton>().Any() &&
p.ChildrenOfType<ArgonSongProgressBar>().SingleOrDefault()?.Interactive == false));
@@ -662,7 +662,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("invoke on back button", () => multiplayerComponents.OnBackButton());
AddAssert("mod overlay is hidden", () => this.ChildrenOfType<MultiplayerUserModSelectOverlay>().Single().State.Value == Visibility.Hidden);
AddAssert("mod overlay is hidden", () => this.ChildrenOfType<MultiplayerUserModSelectOverlay>().All(o => o.State.Value == Visibility.Hidden));
AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden);
@@ -17,6 +17,7 @@ using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@@ -57,6 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
Dependencies.CacheAs<BeatmapStore>(new RealmDetachedBeatmapStore());
Dependencies.Cache(new ChannelManager(API));
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
@@ -351,6 +353,30 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("button hidden", () => this.ChildrenOfType<MultiplayerRoomPanel>().Single().ChangeSettingsButton.Alpha, () => Is.EqualTo(0));
}
[Test]
public void TestChangeSettingsButtonAlwaysVisibleForReferee()
{
AddStep("add playlist item", () =>
{
room.Playlist =
[
new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
];
});
AddStep("setup referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee);
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => RoomJoined);
AddUntilStep("button visible", () => this.ChildrenOfType<MultiplayerRoomPanel>().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0));
AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID }));
AddStep("make other user host", () => MultiplayerClient.TransferHost(PLAYER_1_ID));
AddAssert("button hidden", () => this.ChildrenOfType<MultiplayerRoomPanel>().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0));
}
[Test]
public void TestUserModSelectUpdatesWhenNotVisible()
{
@@ -439,6 +465,27 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any());
}
[Test]
public void TestRoll()
{
AddStep("set playlist", () =>
{
room.Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
];
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddStep("set channel", () => room.ChannelId = 1);
AddUntilStep("wait for room join", () => RoomJoined);
AddStep("roll", () => MultiplayerClient.SendMatchRequest(new RollRequest()));
}
[Test]
public void TestSettingsRemainsOpenOnRoomUpdate()
{
@@ -32,10 +32,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public partial class TestSceneMultiplayerParticipantsList : MultiplayerTestScene
{
public override void SetUpSteps()
private void setUpList()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
createNewParticipantsList();
@@ -44,6 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestAddUser()
{
setUpList();
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
@@ -56,9 +55,30 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddReferee()
{
setUpList();
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(3)
{
User = new APIUser
{
Id = 3,
Username = "Second",
CoverUrl = TestResources.COVER_IMAGE_3,
},
Role = MultiplayerRoomUserRole.Referee
}));
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddUnresolvedUser()
{
setUpList();
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add non-resolvable user", () => MultiplayerClient.TestAddUnresolvedUser());
@@ -75,6 +95,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestRemoveUser()
{
setUpList();
APIUser? secondUser = null;
AddStep("add a user", () =>
@@ -95,6 +117,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestGameStateHasPriorityOverDownloadState()
{
setUpList();
AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0)));
checkProgressBarVisibility(true);
@@ -109,6 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestCorrectInitialState()
{
setUpList();
AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0)));
createNewParticipantsList();
checkProgressBarVisibility(true);
@@ -117,6 +141,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestBeatmapDownloadingStates()
{
setUpList();
AddStep("set to unknown", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Unknown()));
AddStep("set to no map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded()));
AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0)));
@@ -140,6 +165,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestToggleReadyState()
{
setUpList();
AddAssert("ready mark invisible", () => !this.ChildrenOfType<StateDisplay>().Single().IsPresent);
AddStep("make user ready", () => MultiplayerClient.ChangeState(MultiplayerUserState.Ready));
@@ -152,6 +178,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestToggleSpectateState()
{
setUpList();
AddStep("make user spectating", () => MultiplayerClient.ChangeState(MultiplayerUserState.Spectating));
AddStep("make user idle", () => MultiplayerClient.ChangeState(MultiplayerUserState.Idle));
}
@@ -159,6 +186,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestCrownChangesStateWhenHostTransferred()
{
setUpList();
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
@@ -182,6 +210,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestHostGetsPinnedToTop()
{
setUpList();
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
@@ -199,8 +228,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
[Test]
public void TestKickButtonOnlyPresentWhenHost()
public void TestKickButtonPresentWhenHost()
{
setUpList();
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
@@ -219,9 +249,33 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
}
[Test]
public void TestKickButtonPresentWhenReferee()
{
AddStep("set up referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee);
setUpList();
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
Username = "Second",
CoverUrl = TestResources.COVER_IMAGE_3,
}));
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
AddStep("make local user host again", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id));
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
}
[Test]
public void TestKickButtonKicks()
{
setUpList();
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
@@ -239,6 +293,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
const int users_count = 200;
setUpList();
AddStep("add many users", () =>
{
for (int i = 0; i < users_count; i++)
@@ -297,6 +352,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestUserWithMods()
{
setUpList();
AddStep("add user", () =>
{
MultiplayerClient.AddUser(new APIUser
@@ -334,6 +390,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestUserWithStyle()
{
setUpList();
AddStep("add users", () =>
{
MultiplayerClient.AddUser(new APIUser
@@ -361,6 +418,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestModOverlap()
{
setUpList();
AddStep("add dummy mods", () =>
{
MultiplayerClient.ChangeUserMods(new Mod[]
@@ -419,6 +477,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestModsAndRuleset()
{
setUpList();
AddStep("add another user", () =>
{
MultiplayerClient.AddUser(new APIUser
@@ -453,6 +512,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestTeams()
{
setUpList();
AddStep("enable teams", () => MultiplayerClient.ChangeSettings(matchType: MatchType.TeamVersus));
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
@@ -41,10 +41,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(Realm);
}
public override void SetUpSteps()
private void setUpRoom()
{
base.SetUpSteps();
AddStep("create room", () => room = CreateDefaultRoom());
AddStep("join room", () => JoinRoom(room));
WaitForJoined();
@@ -80,6 +78,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestDeleteButtonAlwaysVisibleForHost()
{
setUpRoom();
AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers);
@@ -92,6 +92,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost()
{
setUpRoom();
AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers);
@@ -108,9 +110,35 @@ namespace osu.Game.Tests.Visual.Multiplayer
assertDeleteButtonVisibility(2, true);
}
[Test]
public void TestDeleteButtonAlwaysVisibleForReferee()
{
AddStep("ensure host will be referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee);
setUpRoom();
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 1234 }));
AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers);
addPlaylistItem(() => API.LocalUser.Value.OnlineID);
assertDeleteButtonVisibility(1, true);
addPlaylistItem(() => 1234);
assertDeleteButtonVisibility(2, true);
AddStep("set host only queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.HostOnly }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.HostOnly);
AddStep("set other user as host", () => MultiplayerClient.TransferHost(1234));
assertDeleteButtonVisibility(1, true);
assertDeleteButtonVisibility(2, true);
}
[Test]
public void TestSingleItemDoesNotHaveDeleteButton()
{
setUpRoom();
AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers);
@@ -120,6 +148,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestCurrentItemHasDeleteButtonIfNotSingle()
{
setUpRoom();
AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely());
AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers);
@@ -139,6 +169,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestChangeExistingItem()
{
setUpRoom();
AddStep("change beatmap", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem
{
ID = playlist.Items[0].ID,
@@ -116,6 +116,55 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("user still on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestTeamChangesLocked()
{
createRoom(() => new Room
{
Name = "Test Room",
Type = MatchType.TeamVersus,
Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
]
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("add another user", () => multiplayerClient.AddUser(new APIUser { Username = "otheruser", Id = 44 }));
AddStep("lock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = true;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("unlock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = false;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 1", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1);
AddStep("press own button again", () => InputManager.Click(MouseButton.Left));
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestSettingsUpdatedWhenChangingMatchType()
{
@@ -653,6 +653,7 @@ namespace osu.Game.Tests.Visual.Navigation
}
[Test]
[FlakyTest]
public void TestDeleteScoreAfterPlaying()
{
playToResults();
@@ -300,7 +300,7 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0)));
AddUntilStep("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0));
PlayerSettingsOverlay getPlayerSettingsOverlay() => ((Player)Game.ScreenStack.CurrentScreen).ChildrenOfType<PlayerSettingsOverlay>().SingleOrDefault();
ReplaySettingsOverlay getPlayerSettingsOverlay() => ((Player)Game.ScreenStack.CurrentScreen).ChildrenOfType<ReplaySettingsOverlay>().SingleOrDefault();
}
[Test]
@@ -12,7 +12,6 @@ using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Metadata;
@@ -219,13 +218,13 @@ namespace osu.Game.Tests.Visual.Online
}
private void waitForLoad()
=> AddUntilStep("wait for panels to load", () => this.ChildrenOfType<LoadingSpinner>().First().State.Value, () => Is.EqualTo(Visibility.Hidden));
=> AddUntilStep("wait for panels to load", () => this.ChildrenOfType<UserPanel>().Any());
private void assertVisiblePanelCount<T>(int expectedVisible)
where T : UserPanel
{
AddAssert($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType<FriendsList>().Last().ChildrenOfType<UserPanel>().All(p => p is T));
AddAssert($"{expectedVisible} panels visible", () => this.ChildrenOfType<FriendsList>().Last().ChildrenOfType<FriendsList.FilterableUserPanel>().Count(p => p.IsPresent),
AddUntilStep($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType<FriendsList>().Last().ChildrenOfType<UserPanel>().All(p => p is T));
AddUntilStep($"{expectedVisible} panels visible", () => this.ChildrenOfType<FriendsList>().Last().ChildrenOfType<FriendsList.FilterableUserPanel>().Count(p => p.IsPresent),
() => Is.EqualTo(expectedVisible));
}
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Game.Overlays;
using osu.Game.Overlays.Dashboard.UserSearch;
namespace osu.Game.Tests.Visual.Online
{
public partial class TestSceneUserSearchDisplay : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
protected override bool UseOnlineAPI => true;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = new UserSearchDisplay();
});
}
}
@@ -13,7 +13,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers.Markdown;
using osu.Game.Graphics.Containers.Markdown.Footnotes;
using osu.Game.Overlays;
@@ -143,98 +142,11 @@ outdated: true # not sure about the format for ""list of mods"".
AddAssert("No notice box visible", () => !markdownContainer.ChildrenOfType<Container>().Any());
}
[Test]
public void TestAbsoluteImage()
{
AddStep("Add absolute image", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh";
markdownContainer.Text = "![intro](/wiki/images/Client/Interface/img/intro-screen.jpg)";
});
}
[Test]
public void TestRelativeImage()
{
AddStep("Add relative image", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/";
markdownContainer.Text = "![intro](../images/Client/Interface/img/intro-screen.jpg)";
});
}
[Test]
public void TestBlockImage()
{
AddStep("Add paragraph with block image", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/";
markdownContainer.Text = @"Line before image
![play menu](../images/Client/Interface/img/play-menu.jpg ""Main Menu in osu!"")
Line after image";
});
}
[Test]
public void TestInlineImage()
{
AddStep("Add inline image", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh";
markdownContainer.Text = "![osu! mode icon](/wiki/shared/mode/osu.png) osu!";
});
}
[Test]
public void TestTableWithImageContent()
{
AddStep("Add Table", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh";
markdownContainer.Text = @"
| Image | Name | Effect |
| :-: | :-: | :-- |
| ![](/wiki/images/shared/judgement/osu!/hit300.png ""300"") | 300 | A possible score when tapping a hit circle precisely on time, completing a Slider and keeping the cursor over every tick, or completing a Spinner with the Spinner Metre full. A score of 300 appears in an blue score by default. Scoring nothing except 300s in a beatmap will award the player with the SS or SSH grade. |
| ![](/wiki/images/shared/judgement/osu!/hit300g.png ""Geki"") | () Geki | A term from Ouendan, called Elite Beat! in EBA. Appears when playing the last element in a combo in which the player has scored only 300s. Getting a Geki will give a sizable boost to the Life Bar. By default, it is blue. |
| ![](/wiki/images/shared/judgement/osu!/hit100.png ""100"") | 100 | A possible score one can get when tapping a Hit Object slightly late or early, completing a Slider and missing a number of ticks, or completing a Spinner with the Spinner Meter almost full. A score of 100 appears in a green score by default. When very skilled players test a beatmap and they get a lot of 100s, this may mean that the beatmap does not have correct timing. |
| ![](/wiki/images/shared/judgement/osu!/hit300k.png ""300 Katu"") ![](/wiki/Skinning/Interface/img/hit100k.png ""100 Katu"") | () Katu or Katsu | A term from Ouendan, called Beat! in EBA. Appears when playing the last element in a combo in which the player has scored at least one 100, but no 50s or misses. Getting a Katu will give a small boost to the Life Bar. By default, it is coloured green or blue depending on whether the Katu itself is a 100 or a 300. |
| ![](/wiki/images/shared/judgement/osu!/hit50.png ""50"") | 50 | A possible score one can get when tapping a hit circle rather early or late but not early or late enough to cause a miss, completing a Slider and missing a lot of ticks, or completing a Spinner with the Spinner Metre close to full. A score of 50 appears in a orange score by default. Scoring a 50 in a combo will prevent the appearance of a Katu or a Geki at the combo's end. |
| ![](/wiki/images/shared/judgement/osu!/hit0.png ""Miss"") | Miss | A possible score one can get when not tapping a hit circle or too early (based on OD and AR, it may *shake* instead), not tapping or holding the Slider at least once, or completing a Spinner with low Spinner Metre fill. Scoring a Miss will reset the current combo to 0 and will prevent the appearance of a Katu or a Geki at the combo's end. |
";
});
}
[Test]
public void TestWideImageNotExceedContainer()
{
AddStep("Add image", () =>
{
markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/osu!_Program_Files/";
markdownContainer.Text = "![](../images/Client/Program_files/img/file_structure.jpg \"The file structure of osu!'s installation folder, on Windows and macOS\")";
});
AddUntilStep("Wait image to load", () => markdownContainer.ChildrenOfType<DelayedLoadWrapper>().First().DelayedLoadCompleted);
AddStep("Change container width", () =>
{
markdownContainer.Width = 0.5f;
});
AddAssert("Image not exceed container width", () =>
{
var spriteImage = markdownContainer.ChildrenOfType<Sprite>().First();
return Precision.DefinitelyBigger(markdownContainer.DrawWidth, spriteImage.DrawWidth);
});
}
[Test]
public void TestFlag()
{
AddStep("Add flag", () =>
{
markdownContainer.CurrentPath = @"https://dev.ppy.sh";
markdownContainer.Text = "::{flag=\"AU\"}:: ::{flag=\"ZZ\"}::";
});
AddAssert("Two flags visible", () => markdownContainer.ChildrenOfType<DrawableFlag>().Count(), () => Is.EqualTo(2));
@@ -140,13 +140,14 @@ namespace osu.Game.Tests.Visual.Playlists
room = new Room
{
RoomID = 1,
MaxAttempts = 10,
Playlist =
[
// osu! beatmap
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
},
// osu! beatmap converted played in taiko
new PlaylistItem(importedSet.Beatmaps[1])
@@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestSceneBubbleChatHistory : OsuTestScene
{
private RankedPlayChatDisplay.BubbleChatHistory history = null!;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = history = new RankedPlayChatDisplay.BubbleChatHistory
{
Anchor = Anchor.Centre,
Origin = Anchor.BottomCentre,
Width = 300
};
});
[Test]
public void TestPostMessages()
{
int messageId = 1;
AddRepeatStep("post message", () => history.PostMessage(new APIUser { Id = 2 }, $"message {messageId++}"), 20);
}
[Test]
public void TestCollapse()
{
AddStep("set expanded", () => history.Expand());
AddStep("post some messages", () =>
{
for (int i = 0; i < 10; i++)
history.PostMessage(new APIUser { Id = 2 }, $"message {i}");
});
AddWaitStep("wait a bit", 10);
AddStep("set collapsed", () => history.Collapse());
AddWaitStep("wait a bit", 10);
AddStep("set expanded", () => history.Expand());
AddWaitStep("wait a bit", 10);
AddStep("set collapsed", () => history.Collapse());
}
}
}
@@ -2,15 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestSceneDiscardScreen : MultiplayerTestScene
public partial class TestSceneDiscardScreen : RankedPlayTestScene
{
private RankedPlayScreen screen = null!;
@@ -26,7 +26,26 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
var requestHandler = new BeatmapRequestHandler();
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
AddWaitStep("wait some", 5);
AddStep("reveal cards", () =>
{
for (int i = 0; i < 5; i++)
{
int i2 = i;
MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem
{
ID = i2,
BeatmapID = requestHandler.Beatmaps[i2].OnlineID
}).WaitSafely();
}
});
}
}
}
@@ -75,14 +75,14 @@ namespace osu.Game.Tests.Visual.RankedPlay
}
private double flushInterval = 1000;
private double recordInterval = 25;
private double recordInterval = 50;
private double fixedLatency;
private double maxLatency;
[Test]
public void TestCardHandReplay()
{
AddSliderStep("record interval", 0.0, 1000.0, 25.0, value =>
AddSliderStep("record interval", 0.0, 1000.0, 50.0, value =>
{
recordInterval = value;
recreateRecorder();
@@ -2,15 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestSceneOpponentPickScreen : MultiplayerTestScene
public partial class TestSceneOpponentPickScreen : RankedPlayTestScene
{
private RankedPlayScreen screen = null!;
@@ -26,7 +26,33 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
var requestHandler = new BeatmapRequestHandler();
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely());
AddStep("reveal cards", () =>
{
for (int i = 0; i < 5; i++)
{
int i2 = i;
MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem
{
ID = i2,
BeatmapID = requestHandler.Beatmaps[i2].OnlineID
}).WaitSafely();
}
});
AddWaitStep("wait", 15);
AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(2, hand => hand[0]).WaitSafely());
AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealUserCard(2, hand => hand[0], new MultiplayerPlaylistItem
{
ID = 0,
BeatmapID = requestHandler.Beatmaps[0].OnlineID
}).WaitSafely());
}
}
}
@@ -1,16 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand;
using osuTK.Input;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestScenePickScreen : MultiplayerTestScene
public partial class TestScenePickScreen : RankedPlayTestScene
{
private RankedPlayScreen screen = null!;
@@ -26,7 +31,50 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
var requestHandler = new BeatmapRequestHandler();
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely());
AddWaitStep("wait some", 5);
AddStep("reveal cards", () =>
{
for (int i = 0; i < 5; i++)
{
int i2 = i;
MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem
{
ID = i2,
BeatmapID = requestHandler.Beatmaps[i2].OnlineID
}).WaitSafely();
}
});
for (int i = 0; i < 3; i++)
{
int i2 = i;
AddStep($"click card {i2}", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>().ElementAt(i2));
InputManager.Click(MouseButton.Left);
});
}
AddWaitStep("wait", 3);
AddStep("click play button", () =>
{
var button = screen
.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>()
.First(it => it.Selected)
.ChildrenOfType<ShearedButton>()
.First();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
}
}
}
@@ -6,6 +6,7 @@ using Humanizer;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
@@ -33,12 +34,23 @@ namespace osu.Game.Tests.Visual.RankedPlay
};
}
[SetUpSteps]
public void SetupSteps()
{
AddStep("reset card hand", () => Child = handOfCards = new PlayerHandOfCards
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
});
}
[Test]
public void TestSingleSelectionMode()
{
AddStep("add cards", () =>
{
handOfCards.Clear();
for (int i = 0; i < 5; i++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
});
@@ -59,7 +71,6 @@ namespace osu.Game.Tests.Visual.RankedPlay
{
AddStep("add cards", () =>
{
handOfCards.Clear();
for (int i = 0; i < 5; i++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
});
@@ -84,7 +95,13 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep($"{i} {"cards".Pluralize(i == 1)}", () =>
{
handOfCards.Clear();
Child = handOfCards = new PlayerHandOfCards
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
};
for (int j = 0; j < numCards; j++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
@@ -138,7 +155,6 @@ namespace osu.Game.Tests.Visual.RankedPlay
{
AddStep("add cards", () =>
{
handOfCards.Clear();
for (int i = 0; i < 5; i++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
});
@@ -157,5 +173,24 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item));
}
}
[Test]
public void TestContract()
{
AddStep("add cards", () =>
{
for (int i = 0; i < 5; i++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
});
AddWaitStep("wait", 5);
AddStep("contract", () => handOfCards.Contract());
AddWaitStep("wait", 5);
AddAssert(
"all cards outside bounds", () =>
handOfCards
.ChildrenOfType<HandOfCards.HandCard>()
.All(card => !card.ScreenSpaceDrawQuad.AABBFloat.IntersectsWith(handOfCards.ScreenSpaceDrawQuad.AABBFloat))
);
}
}
}
@@ -17,7 +17,6 @@ namespace osu.Game.Tests.Visual.RankedPlay
private readonly Bindable<Colour4> gradientOuter = new Bindable<Colour4>(Color4Extensions.FromHex("AC6D97"));
private readonly Bindable<Colour4> gradientInner = new Bindable<Colour4>(Color4Extensions.FromHex("544483"));
private readonly Bindable<Colour4> dots = new Bindable<Colour4>(Color4Extensions.FromHex("D56CF6"));
public TestSceneRankedPlayBackground()
{
@@ -40,11 +39,6 @@ namespace osu.Game.Tests.Visual.RankedPlay
Scale = new Vector2(0.4f),
Current = gradientInner,
},
new BasicColourPicker
{
Scale = new Vector2(0.4f),
Current = dots,
}
]
}
];
@@ -54,9 +48,8 @@ namespace osu.Game.Tests.Visual.RankedPlay
{
base.LoadComplete();
gradientOuter.BindValueChanged(e => background.GradientOutside = e.NewValue, true);
gradientInner.BindValueChanged(e => background.GradientInside = e.NewValue, true);
dots.BindValueChanged(e => background.DotsColour = e.NewValue, true);
gradientOuter.BindValueChanged(e => background.GradientBottom = e.NewValue, true);
gradientInner.BindValueChanged(e => background.GradientTop = e.NewValue, true);
}
}
}
@@ -0,0 +1,129 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestSceneRankedPlayChat : MultiplayerTestScene
{
private ChannelManager channelManager = null!;
private Channel testChannel = null!;
private int messageIdSequence;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var api = parent.Get<IAPIProvider>();
Add(channelManager = new ChannelManager(api));
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.Cache(channelManager);
return dependencies;
}
[SetUp]
public void SetUp() => Schedule(() =>
{
messageIdSequence = 0;
testChannel = channelManager.JoinChannel(new Channel { Id = 1, Type = ChannelType.Multiplayer });
});
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom(MatchType.RankedPlay);
room.ChannelId = 1;
JoinRoom(room);
});
WaitForJoined();
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
}
[Test]
public void TestDiscardCardStage()
{
AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
postLocalUserMessage("this is a message from the local user");
postOpponentMessage("this is a message from the opponent");
}
[Test]
public void TestResultsStage()
{
AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state =>
{
int losingPlayer = state.Users.Keys.First();
foreach (var (id, userInfo) in state.Users)
{
if (id == losingPlayer)
{
userInfo.DamageInfo = new RankedPlayDamageInfo
{
RawDamage = 123_456,
Damage = 123_456,
OldLife = 500_000,
NewLife = 500_000 - 123_456,
};
userInfo.Life = 500_000 - 123_456;
}
else
{
userInfo.DamageInfo = new RankedPlayDamageInfo
{
RawDamage = 0,
Damage = 0,
OldLife = 1_000_000,
NewLife = 1_000_000,
};
}
}
}).WaitSafely());
}
private void postLocalUserMessage(string content)
{
AddStep("add local user message", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Timestamp = DateTimeOffset.Now,
Sender = API.LocalUser.Value,
Content = content
}));
}
private void postOpponentMessage(string content)
{
AddStep("add opponent message", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Timestamp = DateTimeOffset.Now,
Sender = new APIUser
{
Id = 2,
Username = "peppy"
},
Content = content
}));
}
}
}
@@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components;
using osu.Game.Tests.Visual.Multiplayer;
@@ -27,7 +28,7 @@ namespace osu.Game.Tests.Visual.RankedPlay
new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft)
{
State = { BindTarget = visibility },
Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue)
Child = new RankedPlayUserDisplay(new APIUser { Id = 2, Username = "peppy" }, Anchor.BottomLeft, RankedPlayColourScheme.Blue)
{
RelativeSizeAxes = Axes.Both,
}
@@ -35,7 +36,7 @@ namespace osu.Game.Tests.Visual.RankedPlay
new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.TopRight)
{
State = { BindTarget = visibility },
Child = new RankedPlayUserDisplay(2, Anchor.TopRight, RankedPlayColourScheme.Red)
Child = new RankedPlayUserDisplay(new APIUser { Id = 2, Username = "peppy" }, Anchor.TopRight, RankedPlayColourScheme.Red)
{
RelativeSizeAxes = Axes.Both,
}

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