同步代码,注意可能有虚化背景的残留

This commit is contained in:
LA
2025-10-15 10:12:12 +08:00
parent 6bc9867f64
commit eed36f1633
282 changed files with 7889 additions and 4271 deletions

View File

@@ -21,6 +21,8 @@ trim_trailing_whitespace = true
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130051/Cannot-resolve-symbol-inspections-incorrectly-firing-for-xmldoc-protected-member-references # temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130051/Cannot-resolve-symbol-inspections-incorrectly-firing-for-xmldoc-protected-member-references
resharper_c_sharp_warnings_cs1574_cs1584_cs1581_cs1580_highlighting = hint resharper_c_sharp_warnings_cs1574_cs1584_cs1581_cs1580_highlighting = hint
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130381/Rider-does-not-respect-propagated-NoWarn-CS1591?backToIssues=false
dotnet_diagnostic.CS1591.severity = none
#license header #license header
file_header_template = Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text. file_header_template = Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text.

View File

@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.908.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2025.1008.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.

View File

@@ -0,0 +1,77 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.IO;
using BenchmarkDotNet.Attributes;
using osu.Framework.IO.Stores;
using osu.Game.Beatmaps;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Taiko;
using osu.Game.Tests.Resources;
namespace osu.Game.Benchmarks
{
public class BenchmarkDifficultyCalculation : BenchmarkTest
{
private DifficultyCalculator osuCalculator = null!;
private DifficultyCalculator taikoCalculator = null!;
private DifficultyCalculator catchCalculator = null!;
private DifficultyCalculator maniaCalculator = null!;
public override void SetUp()
{
using var resources = new DllResourceStore(typeof(TestResources).Assembly);
using var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz");
using var archiveReader = new ZipArchiveReader(archive);
var osuBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Gamu) [Insane].osu");
var taikoBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (MMzz) [Oni].osu");
var catchBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Deif) [Salad].osu");
var maniaBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (ExPew) [Another].osu");
osuCalculator = new OsuRuleset().CreateDifficultyCalculator(osuBeatmap);
taikoCalculator = new TaikoRuleset().CreateDifficultyCalculator(taikoBeatmap);
catchCalculator = new CatchRuleset().CreateDifficultyCalculator(catchBeatmap);
maniaCalculator = new ManiaRuleset().CreateDifficultyCalculator(maniaBeatmap);
}
private WorkingBeatmap readBeatmap(ZipArchiveReader archiveReader, string beatmapName)
{
using var beatmapStream = new MemoryStream();
archiveReader.GetStream(beatmapName).CopyTo(beatmapStream);
beatmapStream.Seek(0, SeekOrigin.Begin);
using var reader = new LineBufferedReader(beatmapStream);
var decoder = Beatmaps.Formats.Decoder.GetDecoder<Beatmap>(reader);
return new FlatWorkingBeatmap(decoder.Decode(reader));
}
[Benchmark]
public void CalculateDifficultyOsu() => osuCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyTaiko() => taikoCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyCatch() => catchCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyMania() => maniaCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyOsuHundredTimes()
{
for (int i = 0; i < 100; i++)
{
osuCalculator.Calculate();
}
}
}
}

View File

@@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Catch.Tests
} }
[Test] [Test]
public void TestTinyDropletMissPreservesCatcherState() public void TestTinyDropletMissChangesCatcherState()
{ {
AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit
{ {
@@ -165,8 +165,8 @@ namespace osu.Game.Rulesets.Catch.Tests
})); }));
AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet()));
AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 })); AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 }));
// catcher state and hyper dash state is preserved // catcher state is changed but hyper dash state is preserved
checkState(CatcherAnimationState.Kiai); checkState(CatcherAnimationState.Fail);
checkHyperDash(true); checkHyperDash(true);
} }

View File

@@ -3,13 +3,13 @@
using System; using System;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Scoring.Legacy; using osu.Game.Scoring.Legacy;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Catch.Difficulty namespace osu.Game.Rulesets.Catch.Difficulty
{ {
@@ -51,15 +51,13 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Combo scaling // Combo scaling
if (catchAttributes.MaxCombo > 0) if (catchAttributes.MaxCombo > 0)
value *= Math.Min(Math.Pow(score.MaxCombo, 0.8) / Math.Pow(catchAttributes.MaxCombo, 0.8), 1.0); value *= Math.Min(Math.Pow(score.MaxCombo, 0.35) / Math.Pow(catchAttributes.MaxCombo, 0.35), 1.0);
var difficulty = score.BeatmapInfo!.Difficulty.Clone(); var difficulty = score.BeatmapInfo!.Difficulty.Clone();
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty)); score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
var track = new TrackVirtual(10000); double clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
double clockRate = track.Rate;
// this is the same as osu!, so there's potential to share the implementation... maybe // this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate;

View File

@@ -0,0 +1,65 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{
public static class MovementEvaluator
{
private const double direction_change_bonus = 21.0;
public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier)
{
var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);
double edgeDashBonus = 0;
// Direction change bonus.
if (Math.Abs(catchCurrent.DistanceMoved) > 0.1)
{
if (current.Index >= 1 && Math.Abs(catchLast.DistanceMoved) > 0.1 && Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchLast.DistanceMoved))
{
double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50;
double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38);
distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0);
}
// Base bonus for every movement, giving some weight to streams.
distanceAddition += 12.5 * Math.Min(Math.Abs(catchCurrent.DistanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2)
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
}
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
if (!catchCurrent.LastObject.HyperDash)
edgeDashBonus += 5.7;
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH)
if (current.Index >= 2 && Math.Abs(catchCurrent.ExactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2
&& catchCurrent.ExactDistanceMoved == -catchLast.ExactDistanceMoved && catchLast.ExactDistanceMoved == -catchLastLast.ExactDistanceMoved
&& catchCurrent.StrainTime == catchLast.StrainTime && catchLast.StrainTime == catchLastLast.StrainTime)
distanceAddition = 0;
return distanceAddition / weightedStrainTime;
}
}
}

View File

@@ -11,15 +11,49 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
{ {
public class CatchDifficultyHitObject : DifficultyHitObject public class CatchDifficultyHitObject : DifficultyHitObject
{ {
private const float normalized_hitobject_radius = 41.0f; public const float NORMALIZED_HALF_CATCHER_WIDTH = 41.0f;
private const float absolute_player_positioning_error = 16.0f;
public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject;
public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject; public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject;
/// <summary>
/// Normalized position of <see cref="BaseObject"/>.
/// </summary>
public readonly float NormalizedPosition; public readonly float NormalizedPosition;
/// <summary>
/// Normalized position of <see cref="LastObject"/>.
/// </summary>
public readonly float LastNormalizedPosition; public readonly float LastNormalizedPosition;
/// <summary>
/// Normalized position of the player required to catch <see cref="BaseObject"/>, assuming the player moves as little as possible.
/// </summary>
public float PlayerPosition { get; private set; }
/// <summary>
/// Normalized position of the player after catching <see cref="LastObject"/>.
/// </summary>
public float LastPlayerPosition { get; private set; }
/// <summary>
/// Normalized distance between <see cref="LastPlayerPosition"/> and <see cref="PlayerPosition"/>.
/// </summary>
/// <remarks>
/// The sign of the value indicates the direction of the movement: negative is left and positive is right.
/// </remarks>
public float DistanceMoved { get; private set; }
/// <summary>
/// Normalized distance the player has to move from <see cref="LastPlayerPosition"/> in order to catch <see cref="BaseObject"/> at its <see cref="NormalizedPosition"/>.
/// </summary>
/// <remarks>
/// The sign of the value indicates the direction of the movement: negative is left and positive is right.
/// </remarks>
public float ExactDistanceMoved { get; private set; }
/// <summary> /// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="CatchDifficultyHitObject"/>, with a minimum of 40ms. /// Milliseconds elapsed since the start time of the previous <see cref="CatchDifficultyHitObject"/>, with a minimum of 40ms.
/// </summary> /// </summary>
@@ -29,13 +63,35 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
// We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; float scalingFactor = NORMALIZED_HALF_CATCHER_WIDTH / halfCatcherWidth;
NormalizedPosition = BaseObject.EffectiveX * scalingFactor; NormalizedPosition = BaseObject.EffectiveX * scalingFactor;
LastNormalizedPosition = LastObject.EffectiveX * scalingFactor; LastNormalizedPosition = LastObject.EffectiveX * scalingFactor;
// Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure
StrainTime = Math.Max(40, DeltaTime); StrainTime = Math.Max(40, DeltaTime);
setMovementState();
}
private void setMovementState()
{
LastPlayerPosition = Index == 0 ? LastNormalizedPosition : ((CatchDifficultyHitObject)Previous(0)).PlayerPosition;
PlayerPosition = Math.Clamp(
LastPlayerPosition,
NormalizedPosition - (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error),
NormalizedPosition + (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error)
);
DistanceMoved = PlayerPosition - LastPlayerPosition;
// For the exact position we consider that the catcher is in the correct position for both objects
ExactDistanceMoved = NormalizedPosition - LastPlayerPosition;
// After a hyperdash we ARE in the correct position. Always!
if (LastObject.HyperDash)
PlayerPosition = NormalizedPosition;
} }
} }
} }

View File

@@ -1,8 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System; using osu.Game.Rulesets.Catch.Difficulty.Evaluators;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@@ -11,10 +10,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
{ {
public class Movement : StrainDecaySkill public class Movement : StrainDecaySkill
{ {
private const float absolute_player_positioning_error = 16f;
private const float normalized_hitobject_radius = 41.0f;
private const double direction_change_bonus = 21.0;
protected override double SkillMultiplier => 1; protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 0.2; protected override double StrainDecayBase => 0.2;
@@ -24,12 +19,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected readonly float HalfCatcherWidth; protected readonly float HalfCatcherWidth;
private float? lastPlayerPosition;
private float lastDistanceMoved;
private float lastExactDistanceMoved;
private double lastStrainTime;
private bool isInBuzzSection;
/// <summary> /// <summary>
/// The speed multiplier applied to the player's catcher. /// The speed multiplier applied to the player's catcher.
/// </summary> /// </summary>
@@ -49,80 +38,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
var catchCurrent = (CatchDifficultyHitObject)current; return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier);
lastPlayerPosition ??= catchCurrent.LastNormalizedPosition;
float playerPosition = Math.Clamp(
lastPlayerPosition.Value,
catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error),
catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error)
);
float distanceMoved = playerPosition - lastPlayerPosition.Value;
// For the exact position we consider that the catcher is in the correct position for both objects
float exactDistanceMoved = catchCurrent.NormalizedPosition - lastPlayerPosition.Value;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);
double edgeDashBonus = 0;
// Direction change bonus.
if (Math.Abs(distanceMoved) > 0.1)
{
if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved))
{
double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50;
double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38);
distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0);
}
// Base bonus for every movement, giving some weight to streams.
distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain;
}
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
if (!catchCurrent.LastObject.HyperDash)
edgeDashBonus += 5.7;
else
{
// After a hyperdash we ARE in the correct position. Always!
playerPosition = catchCurrent.NormalizedPosition;
}
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius)
if (Math.Abs(exactDistanceMoved) <= HalfCatcherWidth * 2 && exactDistanceMoved == -lastExactDistanceMoved && catchCurrent.StrainTime == lastStrainTime)
{
if (isInBuzzSection)
distanceAddition = 0;
else
isInBuzzSection = true;
}
else
{
isInBuzzSection = false;
}
lastPlayerPosition = playerPosition;
lastDistanceMoved = distanceMoved;
lastStrainTime = catchCurrent.StrainTime;
lastExactDistanceMoved = exactDistanceMoved;
return distanceAddition / weightedStrainTime;
} }
} }
} }

View File

@@ -224,7 +224,20 @@ namespace osu.Game.Rulesets.Catch.UI
addLighting(result, drawableObject.AccentColour.Value, positionInStack.X); addLighting(result, drawableObject.AccentColour.Value, positionInStack.X);
} }
// droplet doesn't affect the catcher state if (result.IsHit)
CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle;
else if (hitObject is not Banana)
CurrentState = CatcherAnimationState.Fail;
if (palpableObject.HitObject.LastInCombo)
{
if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result))
Explode();
else
Drop();
}
// droplet doesn't affect hyperdash state
if (hitObject is TinyDroplet) return; if (hitObject is TinyDroplet) return;
// if a hyper fruit was already handled this frame, just go where it says to go. // if a hyper fruit was already handled this frame, just go where it says to go.
@@ -244,19 +257,6 @@ namespace osu.Game.Rulesets.Catch.UI
else else
SetHyperDashState(); SetHyperDashState();
} }
if (result.IsHit)
CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle;
else if (!(hitObject is Banana))
CurrentState = CatcherAnimationState.Fail;
if (palpableObject.HitObject.LastInCombo)
{
if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result))
Explode();
else
Drop();
}
} }
public void OnRevertResult(JudgementResult result) public void OnRevertResult(JudgementResult result)

View File

@@ -0,0 +1,37 @@
// 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.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class IndividualStrainEvaluator
{
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
// We award a bonus if this note starts and ends before the end of another hold note.
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
{
holdFactor = 1.25;
break;
}
}
return 2.0 * holdFactor;
}
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class OverallStrainEvaluator
{
private const double release_threshold = 30;
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
// The current note is overlapped if a previous note or end is overlapping the current note body
isOverlapping |= Precision.DefinitelyBigger(maniaPrevious.EndTime, startTime, 1) &&
Precision.DefinitelyBigger(endTime, maniaPrevious.EndTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1);
// We give a slight bonus to everything if something is held meanwhile
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - maniaPrevious.EndTime));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
return (1 + holdAddition) * holdFactor;
}
}
}

View File

@@ -65,13 +65,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{ {
var sortedObjects = beatmap.HitObjects.ToArray(); var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); 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>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
List<DifficultyHitObject>[] perColumnObjects = new List<DifficultyHitObject>[totalColumns];
for (int column = 0; column < totalColumns; column++)
perColumnObjects[column] = new List<DifficultyHitObject>();
for (int i = 1; i < sortedObjects.Length; i++) for (int i = 1; i < sortedObjects.Length; i++)
objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count)); {
var currentObject = new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, perColumnObjects, objects.Count);
objects.Add(currentObject);
perColumnObjects[currentObject.Column].Add(currentObject);
}
return objects; return objects;
} }

View File

@@ -12,9 +12,59 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing
{ {
public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, int index) private readonly List<DifficultyHitObject>[] perColumnObjects;
private readonly int columnIndex;
public readonly int Column;
// The hit object earlier in time than this note in each column
public readonly ManiaDifficultyHitObject?[] PreviousHitObjects;
public readonly double ColumnStrainTime;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, List<DifficultyHitObject>[] perColumnObjects, int index)
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
int totalColumns = perColumnObjects.Length;
this.perColumnObjects = perColumnObjects;
Column = BaseObject.Column;
columnIndex = perColumnObjects[Column].Count;
PreviousHitObjects = new ManiaDifficultyHitObject[totalColumns];
ColumnStrainTime = StartTime - PrevInColumn(0)?.StartTime ?? StartTime;
if (index > 0)
{
ManiaDifficultyHitObject prevNote = (ManiaDifficultyHitObject)objects[index - 1];
for (int i = 0; i < prevNote.PreviousHitObjects.Length; i++)
PreviousHitObjects[i] = prevNote.PreviousHitObjects[i];
// intentionally depends on processing order to match live.
PreviousHitObjects[prevNote.Column] = prevNote;
}
}
/// <summary>
/// The previous object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="backwardsIndex">The number of notes to go back.</param>
/// <returns>The object in this column <paramref name="backwardsIndex"/> notes back, or null if this is the first note in the column.</returns>
public ManiaDifficultyHitObject? PrevInColumn(int backwardsIndex)
{
int index = columnIndex - (backwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
}
/// <summary>
/// The next object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="forwardsIndex">The number of notes to go forward.</param>
/// <returns>The object in this column <paramref name="forwardsIndex"/> notes forward, or null if this is the last note in the column.</returns>
public ManiaDifficultyHitObject? NextInColumn(int forwardsIndex)
{
int index = columnIndex + (forwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
} }
} }
} }

View File

@@ -2,10 +2,9 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mania.Difficulty.Evaluators;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@@ -15,23 +14,17 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
{ {
private const double individual_decay_base = 0.125; private const double individual_decay_base = 0.125;
private const double overall_decay_base = 0.30; private const double overall_decay_base = 0.30;
private const double release_threshold = 30;
protected override double SkillMultiplier => 1; protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1; protected override double StrainDecayBase => 1;
private readonly double[] startTimes;
private readonly double[] endTimes;
private readonly double[] individualStrains; private readonly double[] individualStrains;
private double highestIndividualStrain;
private double individualStrain;
private double overallStrain; private double overallStrain;
public Strain(Mod[] mods, int totalColumns) public Strain(Mod[] mods, int totalColumns)
: base(mods) : base(mods)
{ {
startTimes = new double[totalColumns];
endTimes = new double[totalColumns];
individualStrains = new double[totalColumns]; individualStrains = new double[totalColumns];
overallStrain = 1; overallStrain = 1;
} }
@@ -39,65 +32,24 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
var maniaCurrent = (ManiaDifficultyHitObject)current; var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
int column = maniaCurrent.BaseObject.Column;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information individualStrains[maniaCurrent.Column] = applyDecay(individualStrains[maniaCurrent.Column], maniaCurrent.ColumnStrainTime, individual_decay_base);
double holdFactor = 1.0; // Factor to all additional strains in case something else is held individualStrains[maniaCurrent.Column] += IndividualStrainEvaluator.EvaluateDifficultyOf(current);
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
for (int i = 0; i < endTimes.Length; ++i) // Take the hardest individualStrain for notes that happen at the same time (in a chord).
{ // This is to ensure the order in which the notes are processed does not affect the resultant total strain.
// The current note is overlapped if a previous note or end is overlapping the current note body highestIndividualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(highestIndividualStrain, individualStrains[maniaCurrent.Column]) : individualStrains[maniaCurrent.Column];
isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1);
// We give a slight bonus to everything if something is held meanwhile overallStrain = applyDecay(overallStrain, maniaCurrent.DeltaTime, overall_decay_base);
if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) && overallStrain += OverallStrainEvaluator.EvaluateDifficultyOf(current);
Precision.DefinitelyBigger(startTime, startTimes[i], 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
individualStrains[column] += 2.0 * holdFactor;
// For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column];
// Decay and increase overallStrain
overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base);
overallStrain += (1 + holdAddition) * holdFactor;
// Update startTimes and endTimes arrays
startTimes[column] = startTime;
endTimes[column] = endTime;
// By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
return individualStrain + overallStrain - CurrentStrain; return highestIndividualStrain + overallStrain - CurrentStrain;
} }
protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) =>
=> applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base) applyDecay(highestIndividualStrain, offset - current.Previous(0).StartTime, individual_decay_base)
+ applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base);
private double applyDecay(double value, double deltaTime, double decayBase) private double applyDecay(double value, double deltaTime, double decayBase)
=> value * Math.Pow(decayBase, deltaTime / 1000); => value * Math.Pow(decayBase, deltaTime / 1000);

View File

@@ -44,6 +44,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override void Update() protected override void Update()
{ {
// 使用ez2lazer特色调速系统
TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get<double>(ManiaRulesetSetting.ScrollSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollBaseSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed)) : TimelineTimeRange.Value; TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get<double>(ManiaRulesetSetting.ScrollSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollBaseSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed)) : TimelineTimeRange.Value;
base.Update(); base.Update();
} }

View File

@@ -91,6 +91,7 @@ namespace osu.Game.Rulesets.Mania
private partial class ManiaScrollSlider : RoundedSliderBar<double> private partial class ManiaScrollSlider : RoundedSliderBar<double>
{ {
// 自定义提示
private ManiaRulesetConfigManager config = null!; private ManiaRulesetConfigManager config = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]

View File

@@ -149,12 +149,16 @@ namespace osu.Game.Rulesets.Mania.Mods.LAsMods
if (NoScratch.Value && NoScratchTemplate.TryGetValue(keys, out var scratchToRemove)) if (NoScratch.Value && NoScratchTemplate.TryGetValue(keys, out var scratchToRemove))
{ {
objectsToMakeAuto.AddRange(maniaBeatmap.HitObjects.Where(h => h is ManiaHitObject maniaHitObject && scratchToRemove.Contains(maniaHitObject.Column))); objectsToMakeAuto.AddRange(maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject &&
scratchToRemove.Contains(maniaHitObject.Column)));
} }
if (NoPanel.Value && NoPanelTemplate.TryGetValue(keys, out var panelToRemove)) if (NoPanel.Value && NoPanelTemplate.TryGetValue(keys, out var panelToRemove))
{ {
objectsToMakeAuto.AddRange(maniaBeatmap.HitObjects.Where(h => h is ManiaHitObject maniaHitObject && panelToRemove.Contains(maniaHitObject.Column))); objectsToMakeAuto.AddRange(maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject &&
panelToRemove.Contains(maniaHitObject.Column)));
} }
} }

View File

@@ -35,12 +35,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindTo(scrollingInfo.Direction); direction.BindTo(scrollingInfo.Direction);
direction.BindValueChanged(onDirectionChanged, true); direction.BindValueChanged(onDirectionChanged, true);
bool globalHitPositionValue = ezSkinConfig.GetBindable<bool>(EzSkinSetting.GlobalHitPosition).Value; globalHitPosition = ezSkinConfig.GetBindable<bool>(EzSkinSetting.GlobalHitPosition);
double hitPositionValue = ezSkinConfig.GetBindable<double>(EzSkinSetting.HitPosition).Value; hitPositonBindable = ezSkinConfig.GetBindable<double>(EzSkinSetting.HitPosition);
hitPosition = globalHitPositionValue hitPosition = globalHitPosition.Value
? (float)hitPositionValue ? (float)hitPositonBindable.Value
: skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? (float)hitPositionValue; : skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? (float)hitPositonBindable.Value;
} }
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction) private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)

View File

@@ -48,16 +48,11 @@ namespace osu.Game.Rulesets.Mania.UI.Components
protected virtual void UpdateHitPosition() protected virtual void UpdateHitPosition()
{ {
float hitPosition; float hitPosition = globalHitPosition.Value
? (float)hitPositonBindable.Value
if (globalHitPosition.Value) : skin.GetConfig<ManiaSkinConfigurationLookup, float>(
hitPosition = (float)hitPositonBindable.Value; new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value
else ?? (float)hitPositonBindable.Value;
{
hitPosition = skin.GetConfig<ManiaSkinConfigurationLookup, float>(
new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value
?? (float)hitPositonBindable.Value;
}
Padding = Direction.Value == ScrollingDirection.Up Padding = Direction.Value == ScrollingDirection.Up
? new MarginPadding { Top = hitPosition } ? new MarginPadding { Top = hitPosition }

View File

@@ -10,6 +10,7 @@ using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
@@ -38,7 +39,7 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.UI namespace osu.Game.Rulesets.Mania.UI
{ {
public partial class DrawableManiaRuleset : DrawableScrollingRuleset<ManiaHitObject> public partial class DrawableManiaRuleset : DrawableScrollingRuleset<ManiaHitObject>, IPlayfieldDimensionProvider
{ {
/// <summary> /// <summary>
/// The minimum time range. This occurs at a <see cref="ManiaRulesetSetting.ScrollSpeed"/> of 40. /// The minimum time range. This occurs at a <see cref="ManiaRulesetSetting.ScrollSpeed"/> of 40.
@@ -63,16 +64,17 @@ namespace osu.Game.Rulesets.Mania.UI
protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config;
private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>(); private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>();
private readonly BindableDouble configScrollSpeed = new BindableDouble();
private readonly Bindable<ManiaMobileLayout> mobileLayout = new Bindable<ManiaMobileLayout>();
private readonly Bindable<bool> touchOverlay = new Bindable<bool>();
//自定义判定系统
private readonly Bindable<EzManiaScrollingStyle> scrollingStyle = new Bindable<EzManiaScrollingStyle>(); private readonly Bindable<EzManiaScrollingStyle> scrollingStyle = new Bindable<EzManiaScrollingStyle>();
private readonly BindableDouble configBaseMs = new BindableDouble(); private readonly BindableDouble configBaseMs = new BindableDouble();
private readonly BindableDouble configTimePerSpeed = new BindableDouble(); private readonly BindableDouble configTimePerSpeed = new BindableDouble();
private readonly BindableDouble configScrollSpeed = new BindableDouble();
// private readonly ManiaHitModeConvertor hitModeConvertor; // private readonly ManiaHitModeConvertor hitModeConvertor;
// private readonly Bindable<MUGHitMode> hitMode = new Bindable<MUGHitMode>(); // private readonly Bindable<MUGHitMode> hitMode = new Bindable<MUGHitMode>();
private readonly Bindable<ManiaMobileLayout> mobileLayout = new Bindable<ManiaMobileLayout>();
private readonly Bindable<bool> touchOverlay = new Bindable<bool>();
public double TargetTimeRange { get; protected set; } public double TargetTimeRange { get; protected set; }
@@ -92,6 +94,15 @@ namespace osu.Game.Rulesets.Mania.UI
private Bindable<double> hitPositonBindable = new Bindable<double>(); private Bindable<double> hitPositonBindable = new Bindable<double>();
private readonly Bindable<bool> globalHitPosition = new Bindable<bool>(); private readonly Bindable<bool> globalHitPosition = new Bindable<bool>();
// 背景虚化管理器
private readonly ManiaBackgroundBlurManager blurManager = null!;
public void SetGameplayBackgroundSource(IGameplayBackgroundSource source)
{
// 可能在Load前或后调用blurManager在load()里创建因此先判断null
blurManager.SetSource(source);
}
public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
: base(ruleset, beatmap, mods) : base(ruleset, beatmap, mods)
{ {
@@ -158,7 +169,7 @@ namespace osu.Game.Rulesets.Mania.UI
{ {
base.LoadComplete(); base.LoadComplete();
// 启动独立的异步任务 // 启动独立的异步任务预加载EzPro皮肤中会用到的贴图
Schedule(() => Schedule(() =>
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -186,13 +197,13 @@ namespace osu.Game.Rulesets.Mania.UI
private void updateMobileLayout() private void updateMobileLayout()
{ {
if (touchOverlay.Value) if (touchOverlay.Value)
KeyBindingInputManager.Add(touchInputArea = new ManiaTouchInputArea(this)); KeyBindingInputManager.Add(touchInputArea = new ManiaTouchInputArea(this));
else else
{ {
if (touchInputArea != null) if (touchInputArea != null)
KeyBindingInputManager.Remove(touchInputArea, true); KeyBindingInputManager.Remove(touchInputArea, true);
touchInputArea = null; touchInputArea = null;
} }
} }
@@ -291,5 +302,19 @@ namespace osu.Game.Rulesets.Mania.UI
if (currentSkin.IsNotNull()) if (currentSkin.IsNotNull())
currentSkin.SourceChanged -= onSkinChange; currentSkin.SourceChanged -= onSkinChange;
} }
public bool SupportsBackgroundBlurMasking => true;
public RectangleF? GetPlayfieldBounds()
{
try
{
return Playfield.ScreenSpaceDrawQuad.AABBFloat;
}
catch
{
return null;
}
}
} }
} }

View File

@@ -0,0 +1,118 @@
// filepath: osu.Game/Screens/Play/GameplayBackgroundSource.cs
// Implementation of IGameplayBackgroundSource combining beatmap background + storyboard overlay layers.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Screens.Play;
using osu.Game.Storyboards;
namespace osu.Game.Rulesets.Mania.UI
{
public class GameplayBackgroundSource : IGameplayBackgroundSource
{
private readonly Player player;
public GameplayBackgroundSource(Player player)
{
this.player = player;
}
public Drawable CreateCompositeProxy()
{
var composite = new Container
{
RelativeSizeAxes = Axes.Both
};
try
{
player.ApplyToBackground(b =>
{
var bgProxy = b.CreateBackdropProxy();
if (bgProxy != null)
composite.Add(bgProxy);
});
}
catch
{
// ignore if background not available.
}
if (player.DimmableStoryboard != null)
{
composite.Add(player.DimmableStoryboard.CreateProxy());
}
return composite;
}
public Drawable? CreateBackgroundOnlyProxy()
{
Drawable? result = null;
try
{
player.ApplyToBackground(b =>
{
result = b.CreateBackdropProxy();
});
}
catch { }
return result;
}
public Drawable? CreateBackgroundWithVideoProxy()
{
// Use real background (non-proxy) to avoid dim / black issues from proxy state.
var container = new Container { RelativeSizeAxes = Axes.Both };
try
{
var bg = CreateStandaloneBackground();
if (bg != null)
container.Add(bg);
}
catch { }
try
{
var storyboard = player.GameplayState?.Storyboard;
if (storyboard != null)
{
foreach (var layer in storyboard.Layers)
{
if (layer is StoryboardVideoLayer videoLayer)
{
var drawableLayer = videoLayer.CreateDrawable();
if (drawableLayer != null)
{
drawableLayer.RelativeSizeAxes = Axes.Both;
container.Add(drawableLayer);
}
break;
}
}
}
}
catch { }
return container;
}
public Drawable CreateStandaloneBackground()
{
// Prefer the working beatmap from GameplayState if available.
var working = player.Beatmap.Value; // WorkingBeatmap
var background = new BeatmapBackground(working)
{
RelativeSizeAxes = Axes.Both
};
return background;
}
}
}

View File

@@ -0,0 +1,36 @@
// filepath: osu.Game/Screens/Play/IGameplayBackgroundSource.cs
// Provides a proxy-able composite of gameplay background-related visual content (background + storyboard layers if desired)
// to allow rulesets (e.g. Mania) to apply localised effects (blur, dim, masking) without re-implementing loading logic
// or performing full-screen framebuffer captures.
using osu.Framework.Graphics;
namespace osu.Game.Rulesets.Mania.UI
{
public interface IGameplayBackgroundSource
{
/// <summary>
/// Creates a proxy drawable containing the background-related composite (eg. storyboard, background imagery).
/// Returned drawable is expected to be RelativeSizeAxes = Both (or otherwise scalable by consumer) and safe to proxy repeatedly.
/// </summary>
Drawable CreateCompositeProxy();
/// <summary>
/// Creates a proxy drawable containing only the background (without storyboard layers).
/// Returned drawable is expected to be RelativeSizeAxes = Both (or otherwise scalable by consumer) and safe to proxy repeatedly.
/// </summary>
Drawable? CreateBackgroundOnlyProxy();
/// <summary>
/// Creates a proxy drawable containing the background and video (if available).
/// Returned drawable is expected to be RelativeSizeAxes = Both (or otherwise scalable by consumer) and safe to proxy repeatedly.
/// </summary>
Drawable? CreateBackgroundWithVideoProxy();
/// <summary>
/// Creates a standalone drawable containing the background.
/// Returned drawable is expected to be RelativeSizeAxes = Both (or otherwise scalable by consumer) and safe to proxy repeatedly.
/// </summary>
Drawable CreateStandaloneBackground();
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Primitives;
namespace osu.Game.Rulesets.Mania.UI
{
/// <summary>
/// 用于为背景效果提供游戏区域尺寸信息的规则集接口。
/// </summary>
public interface IPlayfieldDimensionProvider
{
/// <summary>
/// 获取游戏区域在屏幕空间中的边界。
/// </summary>
/// <returns>游戏区域在屏幕坐标中的边界,如果不可用则为 null。</returns>
RectangleF? GetPlayfieldBounds();
/// <summary>
/// 此规则集是否支持背景模糊遮罩。
/// </summary>
bool SupportsBackgroundBlurMasking { get; }
/// <summary>
/// 背景图像是否应在游戏区域外模糊。
/// </summary>
bool ShouldBlurBackground => true;
/// <summary>
/// 故事板是否应在游戏区域外模糊。
/// </summary>
bool ShouldBlurStoryboard => true;
/// <summary>
/// 故事板中的视频是否应在游戏区域外模糊。
/// </summary>
bool ShouldBlurVideo => true;
}
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Screens;
using osu.Game.Screens.Backgrounds;
namespace osu.Game.Rulesets.Mania.UI
{
/// <summary>
/// 专门用于处理Mania模式背景虚化的组件
/// </summary>
public partial class ManiaBackgroundBlurManager : CompositeDrawable
{
// Removed ruleset reference; will be placed inside ManiaPlayfield below columns.
private IGameplayBackgroundSource? backgroundSource;
private Container? proxyHost;
[Resolved]
private EzSkinSettingsManager ezSkinConfig { get; set; } = null!;
private Bindable<double> columnBlur = new BindableDouble();
private BufferedContainer? blurContainer;
private Drawable? compositeProxy;
public bool HasSource => backgroundSource != null;
public ManiaBackgroundBlurManager()
{
RelativeSizeAxes = Axes.Both;
Depth = -10; // draw behind columns (columns default depth 0)
}
[BackgroundDependencyLoader]
private void load()
{
// 绑定ColumnBlur设置
columnBlur = ezSkinConfig.GetBindable<double>(EzSkinSetting.ColumnBlur);
columnBlur.BindValueChanged(_ => updateBlur(), true);
}
protected override void LoadComplete()
{
base.LoadComplete();
if (HasSource)
Schedule(initializeBlur);
}
private void initializeBlur()
{
if (!HasSource)
{
Console.WriteLine("[ManiaBlur] initializeBlur called without source");
return;
}
// Prefer background+video, fallback to background only.
compositeProxy ??= backgroundSource!.CreateBackgroundWithVideoProxy() ?? backgroundSource.CreateBackgroundOnlyProxy();
if (compositeProxy == null)
{
Console.WriteLine("[ManiaBlur] No proxy content (null). Disabling blur.");
Alpha = 0;
return;
}
Console.WriteLine($"[ManiaBlur] Source acquired (video-only attempt). Proxy type={compositeProxy.GetType().Name}");
if (blurContainer == null)
setupBlurContainer();
updateBlur(true);
}
private void setupBlurContainer()
{
if (blurContainer != null) return;
Console.WriteLine("[ManiaBlur] Setting up blur container");
blurContainer = new BufferedContainer(cachedFrameBuffer: true)
{
RelativeSizeAxes = Axes.Both,
Name = "ManiaPlayfieldBlur",
RedrawOnScale = false,
FrameBufferScale = new osuTK.Vector2(0.5f, 0.5f),
Children = new Drawable[]
{
proxyHost = new Container
{
RelativeSizeAxes = Axes.Both,
Name = "ProxyHost",
Child = compositeProxy ?? new Container { Name = "ProxyPlaceholder" }
}
}
};
AddInternal(blurContainer);
}
private void updateBlur(bool force = false)
{
if (blurContainer == null)
return;
if (compositeProxy == null)
{
if (force)
{
// One retry scheduled if first force update finds no proxy.
Schedule(() =>
{
compositeProxy = backgroundSource?.CreateBackgroundOnlyProxy();
if (compositeProxy != null && proxyHost != null)
{
proxyHost.Clear();
proxyHost.Child = compositeProxy;
Console.WriteLine("[ManiaBlur] Late proxy resolved.");
}
});
}
blurContainer.Alpha = 0;
return;
}
float raw = (float)columnBlur.Value;
float blurAmount = raw * BackgroundScreenBeatmap.USER_BLUR_FACTOR;
if (raw > 0 && blurAmount < 2)
blurAmount = 2;
bool enable = blurAmount > 0.001f;
if (!enable)
{
blurContainer.BlurSigma = osuTK.Vector2.Zero;
blurContainer.Alpha = 0;
return;
}
blurContainer.Alpha = 1;
var target = new osuTK.Vector2(blurAmount * 0.6f);
if (force || blurContainer.BlurSigma != target)
blurContainer.BlurSigma = target;
}
public void SetSource(IGameplayBackgroundSource source)
{
backgroundSource = source;
Console.WriteLine("[ManiaBlur] SetSource invoked");
if (IsLoaded)
{
compositeProxy = backgroundSource.CreateBackgroundWithVideoProxy() ?? backgroundSource.CreateBackgroundOnlyProxy();
if (compositeProxy == null) Console.WriteLine("[ManiaBlur] SetSource produced null proxy, will retry later.");
if (proxyHost != null)
{
proxyHost.Clear();
if (compositeProxy != null)
proxyHost.Child = compositeProxy;
}
initializeBlur();
}
}
protected override void Update()
{
base.Update();
updateBlur();
}
}
}

View File

@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.7331304290522747d, 239, "diffcalc-test")] [TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")] [TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")] [TestCase(0.43333836671191595d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")] [TestCase(0.13841532030395723d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(9.6779746353001634d, 239, "diffcalc-test")] [TestCase(9.6491691624112761d, 239, "diffcalc-test")]
[TestCase(1.7691451263718989d, 54, "zero-length-sliders")] [TestCase(1.756936832498702d, 54, "zero-length-sliders")]
[TestCase(0.55785578988249407d, 4, "very-fast-slider")] [TestCase(0.57771197086735004d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7331304290522747d, 239, "diffcalc-test")] [TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")] [TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")] [TestCase(0.43333836671191595d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

View File

@@ -191,6 +191,7 @@ namespace osu.Game.Rulesets.Osu.Tests
} }
[Test] [Test]
[FlakyTest]
public void TestRewind() public void TestRewind()
{ {
AddStep("set manual clock", () => manualClock = new ManualClock AddStep("set manual clock", () => manualClock = new ManualClock

View File

@@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static class AimEvaluator public static class AimEvaluator
{ {
private const double wide_angle_multiplier = 1.5; private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.6; private const double acute_angle_multiplier = 2.55;
private const double slider_multiplier = 1.35; private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75; private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02; private const double wiggle_multiplier = 1.02;
@@ -34,12 +34,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuCurrObj = (OsuDifficultyHitObject)current; var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; 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. // 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.StrainTime; 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. // 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) if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
@@ -51,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
} }
// As above, do the same for the previous hitobject. // As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime;
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{ {
@@ -69,59 +70,77 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double aimStrain = currVelocity; // Start strain with regular velocity. double aimStrain = currVelocity; // Start strain with regular velocity.
if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{ {
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.
{ {
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition. // Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus * acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
}
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle wideAngleBonus = calcWideAngleBonus(currAngle);
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus // Penalize angle repetition.
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter) wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) // Apply full wide angle bonus for distance more than one diameter
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); // 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) 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. // 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.StrainTime; prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime;
// Scale with ratio of difference compared to 0.5 * max dist. // Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); 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. // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio; velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes. // Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
} }
if (osuLastObj.BaseObject is Slider) if (osuLastObj.BaseObject is Slider)
@@ -131,9 +150,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
} }
aimStrain += wiggleBonus * wiggle_multiplier; aimStrain += wiggleBonus * wiggle_multiplier;
aimStrain += velocityChangeBonus * velocity_change_multiplier;
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. // Add in acute angle bonus or wide angle bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); 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. // Add in additional slider velocity bonus.
if (withSliderTravelDistance) if (withSliderTravelDistance)

View File

@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var currentObj = (OsuDifficultyHitObject)current.Previous(i); var currentObj = (OsuDifficultyHitObject)current.Previous(i);
var currentHitObject = (OsuHitObject)(currentObj.BaseObject); var currentHitObject = (OsuHitObject)(currentObj.BaseObject);
cumulativeStrainTime += lastObj.StrainTime; cumulativeStrainTime += lastObj.AdjustedDeltaTime;
if (!(currentObj.BaseObject is Spinner)) if (!(currentObj.BaseObject is Spinner))
{ {
@@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (osuCurrent.BaseObject is Slider osuSlider) if (osuCurrent.BaseObject is Slider osuSlider)
{ {
// Invert the scaling factor to determine the true travel distance independent of circle size. // Invert the scaling factor to determine the true travel distance independent of circle size.
double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor;
// Reward sliders based on velocity. // Reward sliders based on velocity.
sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5);

View File

@@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{ {
private const int history_time_max = 5 * 1000; // 5 seconds private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32; private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95; private const double rhythm_overall_multiplier = 1.0;
private const double rhythm_ratio_multiplier = 12.0; private const double rhythm_ratio_multiplier = 15.0;
/// <summary> /// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>. /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner) if (current.BaseObject is Spinner)
return 0; return 0;
var currentOsuObject = (OsuDifficultyHitObject)current;
double rhythmComplexitySum = 0; double rhythmComplexitySum = 0;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
@@ -62,22 +64,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
double currDelta = currObj.StrainTime; // Use custom cap value to ensure that that at this point delta time is actually zero
double prevDelta = prevObj.StrainTime; double currDelta = Math.Max(currObj.DeltaTime, 1e-7);
double lastDelta = lastObj.StrainTime; double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7);
double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7);
// calculate how much current delta difference deserves a rhythm bonus // 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) // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));
// 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 // reduce ratio bonus if delta difference is too big
double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0);
double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = windowPenalty * currRatio * fractionMultiplier; double effectiveRatio = windowPenalty * currRatio * differenceMultiplier;
if (firstDeltaSwitch) if (firstDeltaSwitch)
{ {
@@ -170,7 +176,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
prevObj = currObj; prevObj = currObj;
} }
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) 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 rhythmDifficulty;
} }
private class Island : IEquatable<Island> private class Island : IEquatable<Island>

View File

@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers private const double 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 min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40; private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.9; private const double distance_multiplier = 0.8;
/// <summary> /// <summary>
/// Evaluates the difficulty of tapping the current object, based on: /// Evaluates the difficulty of tapping the current object, based on:
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuCurrObj = (OsuDifficultyHitObject)current; var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double strainTime = osuCurrObj.StrainTime; double strainTime = osuCurrObj.AdjustedDeltaTime;
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow. // Cap deltatime to the OD 300 hitwindow.
@@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Max distance bonus is 1 * `distance_multiplier` at 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; 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()) if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0; distanceBonus = 0;

View File

@@ -53,12 +53,37 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("slider_factor")] [JsonProperty("slider_factor")]
public double SliderFactor { get; set; } public double SliderFactor { get; set; }
/// <summary>
/// Describes how much of <see cref="AimDifficultStrainCount"/> is contributed to by hitcircles or sliders
/// A value closer to 0.0 indicates most of <see cref="AimDifficultStrainCount"/> is contributed by hitcircles
/// A value closer to Infinity indicates most of <see cref="AimDifficultStrainCount"/> is contributed by sliders
/// </summary>
[JsonProperty("aim_top_weighted_slider_factor")]
public double AimTopWeightedSliderFactor { get; set; }
/// <summary>
/// Describes how much of <see cref="SpeedDifficultStrainCount"/> is contributed to by hitcircles or sliders
/// A value closer to 0.0 indicates most of <see cref="SpeedDifficultStrainCount"/> is contributed by hitcircles
/// A value closer to Infinity indicates most of <see cref="SpeedDifficultStrainCount"/> is contributed by sliders
/// </summary>
[JsonProperty("speed_top_weighted_slider_factor")]
public double SpeedTopWeightedSliderFactor { get; set; }
[JsonProperty("aim_difficult_strain_count")] [JsonProperty("aim_difficult_strain_count")]
public double AimDifficultStrainCount { get; set; } public double AimDifficultStrainCount { get; set; }
[JsonProperty("speed_difficult_strain_count")] [JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; } public double SpeedDifficultStrainCount { get; set; }
[JsonProperty("nested_score_per_object")]
public double NestedScorePerObject { get; set; }
[JsonProperty("legacy_score_base_multiplier")]
public double LegacyScoreBaseMultiplier { get; set; }
[JsonProperty("maximum_legacy_combo_score")]
public double MaximumLegacyComboScore { get; set; }
/// <summary> /// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary> /// </summary>
@@ -97,6 +122,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);
yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor);
yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor);
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);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -112,6 +142,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR];
SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR];
NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT];
LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER];
MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE];
DrainRate = onlineInfo.DrainRate; DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount; HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount; SliderCount = onlineInfo.SliderCount;

View File

@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -13,14 +11,17 @@ using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuDifficultyCalculator : DifficultyCalculator public class OsuDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 0.0675; private const double star_rating_multiplier = 0.0265;
public override int Version => 20250306; public override int Version => 20250306;
@@ -29,53 +30,84 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
} }
public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate)
{
HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(overallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return (79.5 - hitWindowGreat) / 6;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new OsuDifficultyAttributes { Mods = mods }; return new OsuDifficultyAttributes { Mods = mods };
var aim = skills.OfType<Aim>().Single(a => a.IncludeSliders); var aim = skills.OfType<Aim>().Single(a => a.IncludeSliders);
double aimRating = Math.Sqrt(aim.DifficultyValue()) * difficulty_multiplier; var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders);
double aimDifficultyStrainCount = aim.CountTopWeightedStrains(); var speed = skills.OfType<Speed>().Single();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault();
double speedNotes = speed.RelevantNoteCount();
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();
double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount);
double difficultSliders = aim.GetDifficultSliders(); double difficultSliders = aim.GetDifficultSliders();
var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders); double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate);
double aimRatingNoSliders = Math.Sqrt(aimWithoutSliders.DifficultyValue()) * difficulty_multiplier; double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate);
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
var speed = skills.OfType<Speed>().Single(); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle);
double speedRating = Math.Sqrt(speed.DifficultyValue()) * difficulty_multiplier; int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
double speedNotes = speed.RelevantNoteCount(); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
double speedDifficultyStrainCount = speed.CountTopWeightedStrains();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault(); int totalHits = beatmap.HitObjects.Count;
double flashlightRating = flashlight == null ? 0.0 : Math.Sqrt(flashlight.DifficultyValue()) * difficulty_multiplier;
if (mods.Any(m => m is OsuModTouchDevice)) double drainRate = beatmap.Difficulty.DrainRate;
{
aimRating = Math.Pow(aimRating, 0.8);
flashlightRating = Math.Pow(flashlightRating, 0.8);
}
if (mods.Any(h => h is OsuModRelax)) double aimDifficultyValue = aim.DifficultyValue();
{ double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
aimRating *= 0.9; double speedDifficultyValue = speed.DifficultyValue();
speedRating = 0.0;
flashlightRating *= 0.7; double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
} double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1;
else if (mods.Any(h => h is OsuModAutopilot))
{ var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor);
speedRating *= 0.5;
aimRating = 0.0; double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue);
flashlightRating *= 0.4; double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue);
}
double flashlightRating = 0.0;
if (flashlight is not null)
flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue());
double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap);
var simulator = new OsuLegacyScoreSimulator();
var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap);
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
double baseFlashlightPerformance = 0.0; double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
if (mods.Any(h => h is OsuModFlashlight))
baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double basePerformance = double basePerformance =
Math.Pow( Math.Pow(
@@ -84,15 +116,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
); );
double starRating = basePerformance > 0.00001 double starRating = calculateStarRating(basePerformance);
? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4)
: 0;
double drainRate = beatmap.Difficulty.DrainRate;
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{ {
@@ -104,18 +128,41 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedNoteCount = speedNotes, SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating, FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor, SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultyStrainCount, AimDifficultStrainCount = aimDifficultStrainCount,
SpeedDifficultStrainCount = speedDifficultyStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount,
AimTopWeightedSliderFactor = aimTopWeightedSliderFactor,
SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor,
DrainRate = drainRate, DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount, HitCircleCount = hitCircleCount,
SliderCount = sliderCount, SliderCount = sliderCount,
SpinnerCount = spinnerCount, SpinnerCount = spinnerCount,
NestedScorePerObject = sliderNestedScorePerObject,
LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier,
MaximumLegacyComboScore = scoreAttributes.ComboScore
}; };
return attributes; return attributes;
} }
private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue)
{
double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue));
double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue));
double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1);
return calculateStarRating(totalValue);
}
private double calculateStarRating(double basePerformance)
{
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, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{ {
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
@@ -124,8 +171,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// If the map has less than two OsuHitObjects, the enumerator will not return anything. // If the map has less than two OsuHitObjects, the enumerator will not return anything.
for (int i = 1; i < beatmap.HitObjects.Count; i++) for (int i = 1; i < beatmap.HitObjects.Count; i++)
{ {
var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count));
objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count));
} }
return objects; return objects;
@@ -154,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
new OsuModEasy(), new OsuModEasy(),
new OsuModHardRock(), new OsuModHardRock(),
new OsuModFlashlight(), new OsuModFlashlight(),
new MultiMod(new OsuModFlashlight(), new OsuModHidden()) new OsuModHidden(),
}; };
} }
} }

View File

@@ -0,0 +1,200 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuLegacyScoreMissCalculator
{
private readonly ScoreInfo score;
private readonly OsuDifficultyAttributes attributes;
public OsuLegacyScoreMissCalculator(ScoreInfo scoreInfo, OsuDifficultyAttributes attributes)
{
score = scoreInfo;
this.attributes = attributes;
}
public double Calculate()
{
if (attributes.MaxCombo == 0 || score.LegacyTotalScore == null)
return 0;
double scoreV1Multiplier = attributes.LegacyScoreBaseMultiplier * getLegacyScoreMultiplier();
double relevantComboPerObject = calculateRelevantScoreComboPerObject();
double maximumMissCount = calculateMaximumComboBasedMissCount();
double scoreObtainedDuringMaxCombo = calculateScoreAtCombo(score.MaxCombo, relevantComboPerObject, scoreV1Multiplier);
double remainingScore = score.LegacyTotalScore.Value - scoreObtainedDuringMaxCombo;
if (remainingScore <= 0)
return maximumMissCount;
double remainingCombo = attributes.MaxCombo - score.MaxCombo;
double expectedRemainingScore = calculateScoreAtCombo(remainingCombo, relevantComboPerObject, scoreV1Multiplier);
double scoreBasedMissCount = expectedRemainingScore / remainingScore;
// If there's less then one miss detected - let combo-based miss count decide if this is FC or not
scoreBasedMissCount = Math.Max(scoreBasedMissCount, 1);
// Cap result by very harsh version of combo-based miss count
return Math.Min(scoreBasedMissCount, maximumMissCount);
}
/// <summary>
/// Calculates the amount of score that would be achieved at a given combo.
/// </summary>
private double calculateScoreAtCombo(double combo, double relevantComboPerObject, double scoreV1Multiplier)
{
int countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
int totalHits = countGreat + countOk + countMeh + countMiss;
double estimatedObjects = combo / relevantComboPerObject - 1;
// The combo portion of ScoreV1 follows arithmetic progression
// Therefore, we calculate the combo portion of score using the combo per object and our current combo.
double comboScore = relevantComboPerObject > 0 ? (2 * (relevantComboPerObject - 1) + (estimatedObjects - 1) * relevantComboPerObject) * estimatedObjects / 2 : 0;
// We then apply the accuracy and ScoreV1 multipliers to the resulting score.
comboScore *= score.Accuracy * 300 / 25 * scoreV1Multiplier;
double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo;
// Score also has a non-combo portion we need to create the final score value.
double nonComboScore = (300 + attributes.NestedScorePerObject) * score.Accuracy * objectsHit;
return comboScore + nonComboScore;
}
/// <summary>
/// Calculates the relevant combo per object for legacy score.
/// This assumes a uniform distribution for circles and sliders.
/// This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model.
/// </summary>
private double calculateRelevantScoreComboPerObject()
{
double comboScore = attributes.MaximumLegacyComboScore;
// We then reverse apply the ScoreV1 multipliers to get the raw value.
comboScore /= 300.0 / 25.0 * attributes.LegacyScoreBaseMultiplier;
// Reverse the arithmetic progression to work out the amount of combo per object based on the score.
double result = (attributes.MaxCombo - 2) * attributes.MaxCombo;
result /= Math.Max(attributes.MaxCombo + 2 * (comboScore - 1), 1);
return result;
}
/// <summary>
/// This function is a harsher version of current combo-based miss count, used to provide reasonable value for cases where score-based miss count can't do this.
/// </summary>
private double calculateMaximumComboBasedMissCount()
{
int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
if (attributes.SliderCount <= 0)
return countMiss;
int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
int totalImperfectHits = countOk + countMeh + countMiss;
double missCount = 0;
// 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 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);
// In classic scores there can't be more misses than a sum of all non-perfect judgements
missCount = Math.Min(missCount, totalImperfectHits);
// Every slider has *at least* 2 combo attributed in classic mechanics.
// If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end)
// Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break.
// It must have been a slider end.
int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - score.MaxCombo) / 2);
int scoreMissCount = score.Statistics.GetValueOrDefault(HitResult.Miss);
double sliderBreaks = missCount - scoreMissCount;
if (sliderBreaks > maxPossibleSliderBreaks)
missCount = scoreMissCount + maxPossibleSliderBreaks;
return missCount;
}
/// <remarks>
/// Logic copied from <see cref="OsuLegacyScoreSimulator.GetLegacyScoreMultiplier"/>.
/// </remarks>
private double getLegacyScoreMultiplier()
{
bool scoreV2 = score.Mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in score.Mods)
{
switch (mod)
{
case OsuModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case OsuModEasy:
multiplier *= 0.5;
break;
case OsuModHalfTime:
case OsuModDaycore:
multiplier *= 0.3;
break;
case OsuModHidden:
multiplier *= 1.06;
break;
case OsuModHardRock:
multiplier *= scoreV2 ? 1.10 : 1.06;
break;
case OsuModDoubleTime:
case OsuModNightcore:
multiplier *= scoreV2 ? 1.20 : 1.12;
break;
case OsuModFlashlight:
multiplier *= 1.12;
break;
case OsuModSpunOut:
multiplier *= 0.9;
break;
case OsuModRelax:
case OsuModAutopilot:
return 0;
}
}
return multiplier;
}
}
}

View File

@@ -27,6 +27,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_deviation")] [JsonProperty("speed_deviation")]
public double? SpeedDeviation { get; set; } public double? SpeedDeviation { get; set; }
[JsonProperty("combo_based_estimated_miss_count")]
public double ComboBasedEstimatedMissCount { get; set; }
[JsonProperty("score_based_estimated_miss_count")]
public double? ScoreBasedEstimatedMissCount { get; set; }
[JsonProperty("aim_estimated_slider_breaks")]
public double AimEstimatedSliderBreaks { get; set; }
[JsonProperty("speed_estimated_slider_breaks")]
public double SpeedEstimatedSliderBreaks { get; set; }
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay() public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{ {
foreach (var attribute in base.GetAttributesForDisplay()) foreach (var attribute in base.GetAttributesForDisplay())

View File

@@ -4,25 +4,25 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuPerformanceCalculator : PerformanceCalculator public class OsuPerformanceCalculator : PerformanceCalculator
{ {
public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. 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 usingClassicSliderAccuracy;
private bool usingScoreV2;
private double accuracy; private double accuracy;
private int scoreMaxCombo; private int scoreMaxCombo;
@@ -55,6 +55,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double? speedDeviation; private double? speedDeviation;
private double aimEstimatedSliderBreaks;
private double speedEstimatedSliderBreaks;
public OsuPerformanceCalculator() public OsuPerformanceCalculator()
: base(new OsuRuleset()) : base(new OsuRuleset())
{ {
@@ -65,6 +68,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
var osuAttributes = (OsuDifficultyAttributes)attributes; var osuAttributes = (OsuDifficultyAttributes)attributes;
usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value); usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value);
usingScoreV2 = score.Mods.Any(m => m is ModScoreV2);
accuracy = score.Accuracy; accuracy = score.Accuracy;
scoreMaxCombo = score.MaxCombo; scoreMaxCombo = score.MaxCombo;
@@ -80,9 +84,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty)); score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
var track = new TrackVirtual(10000); clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
clockRate = track.Rate;
HitWindows hitWindows = new OsuHitWindows(); HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(difficulty.OverallDifficulty); hitWindows.SetDifficulty(difficulty.OverallDifficulty);
@@ -91,35 +93,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate;
mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate;
double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate);
overallDifficulty = (79.5 - greatHitWindow) / 6; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; double? scoreBasedEstimatedMissCount = null;
if (osuAttributes.SliderCount > 0) if (usingClassicSliderAccuracy && score.LegacyTotalScore != null)
{ {
if (usingClassicSliderAccuracy) var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
{ scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
// 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 to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold) effectiveMissCount = scoreBasedEstimatedMissCount.Value;
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); }
else
// In classic scores there can't be more misses than a sum of all non-perfect judgements {
effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); // Use combo-based miss count if this isn't a legacy score
} effectiveMissCount = comboBasedEstimatedMissCount;
else
{
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
if (scoreMaxCombo < fullComboThreshold)
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// Combine regular misses with tick misses since tick misses break combo as well
effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss);
}
} }
effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
@@ -135,10 +125,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax)) if (score.Mods.Any(h => h is OsuModRelax))
{ {
// https://www.desmos.com/calculator/bc9eybdthb // https://www.desmos.com/calculator/vspzsop6td
// we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
// this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
double okMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 1.8) : 1.0); double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0);
double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0); double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0);
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
@@ -167,6 +157,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Accuracy = accuracyValue, Accuracy = accuracyValue,
Flashlight = flashlightValue, Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount, EffectiveMissCount = effectiveMissCount,
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
AimEstimatedSliderBreaks = aimEstimatedSliderBreaks,
SpeedEstimatedSliderBreaks = speedEstimatedSliderBreaks,
SpeedDeviation = speedDeviation, SpeedDeviation = speedDeviation,
Total = totalValue Total = totalValue
}; };
@@ -207,30 +201,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimValue *= lengthBonus; aimValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); {
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
double approachRateFactor = 0.0; double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (score.Mods.Any(h => h is OsuModRelax)) aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
approachRateFactor = 0.0; }
aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
// 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)) if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.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 OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor);
aimValue *= 1.0 + 0.04 * (12.0 - approachRate);
} }
aimValue *= accuracy; aimValue *= accuracy;
// It is important to consider accuracy difficulty when scaling with accuracy.
aimValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return aimValue; return aimValue;
} }
@@ -247,26 +234,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedValue *= lengthBonus; speedValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); {
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
double approachRateFactor = 0.0; double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (score.Mods.Any(h => h is OsuModAutopilot)) speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
approachRateFactor = 0.0; }
speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
// 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)) 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. // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
speedValue *= 1.12; speedValue *= 1.12;
} }
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate);
speedValue *= 1.0 + 0.04 * (12.0 - approachRate);
} }
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
@@ -280,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD. // Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2);
return speedValue; return speedValue;
} }
@@ -293,7 +277,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
double betterAccuracyPercentage; double betterAccuracyPercentage;
int amountHitObjectsWithAccuracy = attributes.HitCircleCount; int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
if (!usingClassicSliderAccuracy) if (!usingClassicSliderAccuracy || usingScoreV2)
amountHitObjectsWithAccuracy += attributes.SliderCount; amountHitObjectsWithAccuracy += attributes.SliderCount;
if (amountHitObjectsWithAccuracy > 0) if (amountHitObjectsWithAccuracy > 0)
@@ -316,7 +300,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
accuracyValue *= 1.14; accuracyValue *= 1.14;
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable))
accuracyValue *= 1.08; {
// Decrease bonus for AR > 10
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
}
if (score.Mods.Any(m => m is OsuModFlashlight)) if (score.Mods.Any(m => m is OsuModFlashlight))
accuracyValue *= 1.02; accuracyValue *= 1.02;
@@ -337,18 +324,73 @@ namespace osu.Game.Rulesets.Osu.Difficulty
flashlightValue *= getComboScalingFactor(attributes); flashlightValue *= getComboScalingFactor(attributes);
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// Scale the flashlight value with accuracy _slightly_. // Scale the flashlight value with accuracy _slightly_.
flashlightValue *= 0.5 + accuracy / 2.0; flashlightValue *= 0.5 + accuracy / 2.0;
// It is important to also consider accuracy difficulty when doing that.
flashlightValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return flashlightValue; return flashlightValue;
} }
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
{
if (attributes.SliderCount <= 0)
return countMiss;
double missCount = countMiss;
if (usingClassicSliderAccuracy)
{
// 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 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);
// In classic scores there can't be more misses than a sum of all non-perfect judgements
missCount = Math.Min(missCount, totalImperfectHits);
// Every slider has *at least* 2 combo attributed in classic mechanics.
// If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end)
// Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break.
// It must have been a slider end.
int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - scoreMaxCombo) / 2);
double sliderBreaks = missCount - countMiss;
if (sliderBreaks > maxPossibleSliderBreaks)
missCount = countMiss + maxPossibleSliderBreaks;
}
else
{
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// Combine regular misses with tick misses since tick misses break combo as well
missCount = Math.Min(missCount, countSliderTickMiss + countMiss);
}
return missCount;
}
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
{
if (!usingClassicSliderAccuracy || countOk == 0)
return 0;
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor);
// 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 * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
}
/// <summary> /// <summary>
/// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case. /// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case.
/// Treats all speed notes as hit circles. /// Treats all speed notes as hit circles.
@@ -368,7 +410,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh); double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh);
double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk); double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk);
return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss); return calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh);
} }
/// <summary> /// <summary>
@@ -377,45 +419,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty
/// will always return the same deviation. Misses are ignored because they are usually due to misaiming. /// will always return the same deviation. Misses are ignored because they are usually due to misaiming.
/// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution. /// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
/// </summary> /// </summary>
private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss) private double? calculateDeviation(double relevantCountGreat, double relevantCountOk, double relevantCountMeh)
{ {
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
return null; return null;
double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss; // The sample proportion of successful hits.
double n = Math.Max(1, relevantCountGreat + relevantCountOk);
// The probability that a player hits a circle is unknown, but we can estimate it to be
// the number of greats on circles divided by the number of circles, and then add one
// to the number of circles as a bias correction.
double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh);
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
// Proportion of greats hit on circles, ignoring misses and 50s.
double p = relevantCountGreat / n; double p = relevantCountGreat / n;
// We can be 99% confident that p is at least this value. // 99% critical value for the normal distribution (one-tailed).
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); const double z = 2.32634787404;
// Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed. // We can be 99% confident that the population proportion is at least this value.
// Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than: double pLowerBound = Math.Min(p, (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4));
double deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound));
double randomValue = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) double deviation;
/ (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation)));
deviation *= Math.Sqrt(1 - randomValue); // Tested max precision for the deviation calculation.
if (pLowerBound > 0.01)
{
// Compute deviation assuming greats and oks are normally distributed.
deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound));
// Value deviation approach as greatCount approaches 0 // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above.
double limitValue = okHitWindow / Math.Sqrt(3); // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow.
double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2))
/ (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation)));
// If precision is not enough to compute true deviation - use limit value deviation *= Math.Sqrt(1 - okHitWindowTailAmount);
if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue) }
deviation = limitValue; else
{
// A tested limit value for the case of a score only containing oks.
deviation = okHitWindow / Math.Sqrt(3);
}
// Then compute the variance for mehs. // Compute and add the variance for mehs, assuming that they are uniformly distributed.
double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3;
// Find the total deviation.
deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh));
return deviation; return deviation;

View File

@@ -0,0 +1,213 @@
// 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 osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuRatingCalculator
{
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(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)
{
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;
return aimRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeSpeedRating(double speedDifficultyValue)
{
if (mods.Any(m => m is OsuModRelax))
return 0;
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;
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);
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.
ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
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;
}
}

View File

@@ -28,11 +28,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject;
protected new OsuHitObject LastObject => (OsuHitObject)base.LastObject;
/// <summary> /// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms. /// <see cref="DifficultyHitObject.DeltaTime"/> capped to a minimum of <see cref="MIN_DELTA_TIME"/>ms.
/// </summary> /// </summary>
public readonly double StrainTime; public readonly double AdjustedDeltaTime;
/// <summary> /// <summary>
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>. /// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
@@ -75,6 +76,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public double TravelTime { get; private set; } public double TravelTime { get; private set; }
/// <summary>
/// The position of the cursor at the point of completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public Vector2? LazyEndPosition { get; private set; }
/// <summary>
/// The distance travelled by the cursor upon completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public double LazyTravelDistance { get; private set; }
/// <summary>
/// The time taken by the cursor upon completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public double LazyTravelTime { get; private set; }
/// <summary> /// <summary>
/// Angle the player has to take to hit this <see cref="OsuDifficultyHitObject"/>. /// Angle the player has to take to hit this <see cref="OsuDifficultyHitObject"/>.
/// Calculated as the angle between the circles (current-2, current-1, current). /// Calculated as the angle between the circles (current-2, current-1, current).
@@ -86,17 +105,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public double HitWindowGreat { get; private set; } public double HitWindowGreat { get; private set; }
private readonly OsuHitObject? lastLastObject; /// <summary>
private readonly OsuHitObject lastObject; /// Selective bonus for maps with higher circle size.
/// </summary>
public double SmallCircleBonus { get; private set; }
public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject? lastLastObject, double clockRate, List<DifficultyHitObject> objects, int index) private readonly OsuDifficultyHitObject? lastLastDifficultyObject;
private readonly OsuDifficultyHitObject? lastDifficultyObject;
public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, int index)
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
this.lastLastObject = lastLastObject as OsuHitObject; lastLastDifficultyObject = index > 1 ? (OsuDifficultyHitObject)objects[index - 2] : null;
this.lastObject = (OsuHitObject)lastObject; lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40);
if (BaseObject is Slider sliderObject) if (BaseObject is Slider sliderObject)
{ {
@@ -107,6 +133,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate;
} }
computeSliderCursorPosition();
setDistances(clockRate); setDistances(clockRate);
} }
@@ -161,35 +188,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{ {
if (BaseObject is Slider currentSlider) if (BaseObject is Slider currentSlider)
{ {
computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved. // Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME);
} }
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner // 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) if (BaseObject is Spinner || LastObject is Spinner)
return; return;
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius;
if (BaseObject.Radius < 30) Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition;
{
float smallCircleBonus = Math.Min(30 - (float)BaseObject.Radius, 5) / 50;
scalingFactor *= 1 + smallCircleBonus;
}
Vector2 lastCursorPosition = getEndCursorPosition(lastObject);
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
MinimumJumpTime = StrainTime; MinimumJumpTime = AdjustedDeltaTime;
MinimumJumpDistance = LazyJumpDistance; MinimumJumpDistance = LazyJumpDistance;
if (lastObject is Slider lastSlider) if (LastObject is Slider lastSlider && lastDifficultyObject != null)
{ {
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); double lastTravelTime = Math.Max(lastDifficultyObject.LazyTravelTime / clockRate, MIN_DELTA_TIME);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); MinimumJumpTime = Math.Max(AdjustedDeltaTime - lastTravelTime, MIN_DELTA_TIME);
// //
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
@@ -217,11 +237,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius));
} }
if (lastLastObject != null && !(lastLastObject is Spinner)) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner)
{ {
Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastObject); Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject);
Vector2 v1 = lastLastCursorPosition - lastObject.StackedPosition; Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition;
Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition;
float dot = Vector2.Dot(v1, v2); float dot = Vector2.Dot(v1, v2);
@@ -231,9 +251,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
} }
} }
private void computeSliderCursorPosition(Slider slider) private void computeSliderCursorPosition()
{ {
if (slider.LazyEndPosition != null) if (BaseObject is not Slider slider)
return;
if (LazyEndPosition != null)
return; return;
// TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from
@@ -280,15 +303,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
nestedObjects = reordered; nestedObjects = reordered;
} }
slider.LazyTravelTime = trackingEndTime - slider.StartTime; LazyTravelTime = trackingEndTime - slider.StartTime;
double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; double endTimeMin = LazyTravelTime / slider.SpanDuration;
if (endTimeMin % 2 >= 1) if (endTimeMin % 2 >= 1)
endTimeMin = 1 - endTimeMin % 1; endTimeMin = 1 - endTimeMin % 1;
else else
endTimeMin %= 1; endTimeMin %= 1;
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
Vector2 currCursorPosition = slider.StackedPosition; Vector2 currCursorPosition = slider.StackedPosition;
@@ -310,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
// For sliders that are circular, the lazy end position may actually be farther away than the sliders true end. // For sliders that are circular, the lazy end position may actually be farther away than the sliders true end.
// This code is designed to prevent buffing situations where lazy end is actually a less efficient movement. // This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
Vector2 lazyMovement = Vector2.Subtract((Vector2)slider.LazyEndPosition, currCursorPosition); Vector2 lazyMovement = Vector2.Subtract((Vector2)LazyEndPosition, currCursorPosition);
if (lazyMovement.Length < currMovement.Length) if (lazyMovement.Length < currMovement.Length)
currMovement = lazyMovement; currMovement = lazyMovement;
@@ -328,25 +351,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance. // this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength))); currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength)));
currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength; currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength;
slider.LazyTravelDistance += (float)currMovementLength; LazyTravelDistance += currMovementLength;
} }
if (i == nestedObjects.Count - 1) if (i == nestedObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition; LazyEndPosition = currCursorPosition;
} }
} }
private Vector2 getEndCursorPosition(OsuHitObject hitObject) private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject)
{ {
Vector2 pos = hitObject.StackedPosition; return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition;
if (hitObject is Slider slider)
{
computeSliderCursorPosition(slider);
pos = slider.LazyEndPosition ?? pos;
}
return pos;
} }
} }
} }

View File

@@ -7,6 +7,7 @@ using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -26,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain; private double currentStrain;
private double skillMultiplier => 25.6; private double skillMultiplier => 26;
private double strainDecayBase => 0.15; private double strainDecayBase => 0.15;
private readonly List<double> sliderStrains = new List<double>(); private readonly List<double> sliderStrains = new List<double>();
@@ -41,9 +42,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier;
if (current.BaseObject is Slider) if (current.BaseObject is Slider)
{
sliderStrains.Add(currentStrain); sliderStrains.Add(currentStrain);
}
return currentStrain; return currentStrain;
} }
@@ -54,10 +53,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return 0; return 0;
double maxSliderStrain = sliderStrains.Max(); double maxSliderStrain = sliderStrains.Max();
if (maxSliderStrain == 0) if (maxSliderStrain == 0)
return 0; return 0;
return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0))));
} }
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
} }
} }

View File

@@ -2,11 +2,14 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using System.Linq; using System.Linq;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{ {
@@ -15,12 +18,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary> /// </summary>
public class Speed : OsuStrainSkill public class Speed : OsuStrainSkill
{ {
private double skillMultiplier => 1.46; private double skillMultiplier => 1.47;
private double strainDecayBase => 0.3; private double strainDecayBase => 0.3;
private double currentStrain; private double currentStrain;
private double currentRhythm; private double currentRhythm;
private readonly List<double> sliderStrains = new List<double>();
protected override int ReducedSectionCount => 5; protected override int ReducedSectionCount => 5;
public Speed(Mod[] mods) public Speed(Mod[] mods)
@@ -34,13 +39,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current) protected override double StrainValueAt(DifficultyHitObject current)
{ {
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm; double totalStrain = currentStrain * currentRhythm;
if (current.BaseObject is Slider)
sliderStrains.Add(totalStrain);
return totalStrain; return totalStrain;
} }
@@ -55,5 +63,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return ObjectStrains.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() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
} }
} }

View File

@@ -0,0 +1,101 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Utils
{
public static class LegacyScoreUtils
{
/// <summary>
/// Calculates the average amount of score per object that is caused by nested judgements such as slider-ticks and spinners.
/// </summary>
public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectCount)
{
const double big_tick_score = 30;
const double small_tick_score = 10;
var sliders = beatmap.HitObjects.OfType<Slider>().ToArray();
// 1 for head, 1 for tail
int amountOfBigTicks = sliders.Length * 2;
// Add slider repeats
amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum();
int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum();
double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score;
double spinnerScore = 0;
foreach (var spinner in beatmap.HitObjects.OfType<Spinner>())
{
spinnerScore += calculateSpinnerScore(spinner);
}
return (sliderScore + spinnerScore) / objectCount;
}
/// <remarks>
/// Logic borrowed from <see cref="OsuLegacyScoreSimulator.simulateHit"/> for basic score calculations.
/// </remarks>
private static double calculateSpinnerScore(Spinner spinner)
{
const int spin_score = 100;
const int bonus_spin_score = 1000;
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
// As we're primarily concerned with computing the maximum theoretical final score,
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
const double minimum_rotations_per_second = 3;
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
long score = 0;
int fullSpins = (totalHalfSpinsPossible / 2);
// Normal spin score
score += spin_score * fullSpins;
int bonusSpins = (totalHalfSpinsPossible - halfSpinsRequiredBeforeBonus) / 2;
// Reduce amount of bonus spins because we want to represent the more average case, rather than the best one.
bonusSpins = Math.Max(0, bonusSpins - fullSpins / 2);
score += bonus_spin_score * bonusSpins;
return score;
}
public static int CalculateDifficultyPeppyStars(IBeatmap beatmap)
{
int objectCount = beatmap.HitObjects.Count;
int drainLength = 0;
if (objectCount > 0)
{
int breakLength = beatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(beatmap.HitObjects[^1].StartTime) - (int)Math.Round(beatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
return LegacyRulesetExtensions.CalculateDifficultyPeppyStars(beatmap.Difficulty, objectCount, drainLength);
}
}
}

View File

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

View File

@@ -469,9 +469,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
} }
else else
{ {
SnapResult result = positionSnapProvider?.TrySnapToPositionGrid(Parent!.ToScreenSpace(e.MousePosition)); Vector2 newControlPointPosition = Parent!.ToScreenSpace(e.MousePosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; // Snapping inherited B-spline control points to nearby objects would be unintuitive, because snapping them does not equate to snapping the interpolated slider path.
bool shouldSnapToNearbyObjects = dragPathTypes[draggedControlPointIndex] is not null ||
dragPathTypes[..draggedControlPointIndex].LastOrDefault(t => t is not null)?.Type != SplineType.BSpline;
SnapResult result = null;
if (shouldSnapToNearbyObjects)
result = positionSnapProvider?.TrySnapToNearbyObjects(newControlPointPosition, oldStartTime);
if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newControlPointPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(newControlPointPosition, oldStartTime);
Vector2 movementDelta = Parent!.ToLocalSpace(result.ScreenSpacePosition) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i) for (int i = 0; i < controlPoints.Count; ++i)
{ {

View File

@@ -626,10 +626,38 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset) public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset)
?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); ?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation);
protected override Vector2[] ScreenSpaceAdditionalNodes => new[] protected override Vector2[] ScreenSpaceAdditionalNodes => getScreenSpaceControlPointNodes().Prepend(
{
DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation) DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation)
}; ).ToArray();
private IEnumerable<Vector2> getScreenSpaceControlPointNodes()
{
// Returns the positions of control points that produce visible kinks on the slider's path
// This excludes inherited control points from Bezier, B-Spline, Perfect, and Catmull curves
if (DrawableObject.SliderBody == null)
yield break;
PathType? currentPathType = null;
// Skip the last control point because its always either not on the slider path or exactly on the slider end
for (int i = 0; i < DrawableObject.HitObject.Path.ControlPoints.Count - 1; i++)
{
var controlPoint = DrawableObject.HitObject.Path.ControlPoints[i];
if (controlPoint.Type is not null)
currentPathType = controlPoint.Type;
// Skip the first control point because it is already covered by the slider head
if (i == 0)
continue;
if (controlPoint.Type is null && currentPathType != PathType.LINEAR)
continue;
var screenSpacePosition = DrawableObject.SliderBody.ToScreenSpace(DrawableObject.SliderBody.PathOffset + controlPoint.Position);
yield return screenSpacePosition;
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{ {

View File

@@ -253,7 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
var hitObjects = selectedMovableObjects; var hitObjects = selectedMovableObjects;
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects); Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects, true);
Vector2 delta = Vector2.Zero; Vector2 delta = Vector2.Zero;

View File

@@ -81,12 +81,8 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange(); changeHandler?.BeginChange();
objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho));
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider OriginalSurroundingQuad = GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) originalConvexHull = GeometryUtils.GetConvexHull(objectsInScale.Keys);
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
} }
@@ -312,7 +308,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void moveSelectionInBounds() private void moveSelectionInBounds()
{ {
Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys); Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys, true);
Vector2 delta = Vector2.Zero; Vector2 delta = Vector2.Zero;

View File

@@ -68,24 +68,6 @@ namespace osu.Game.Rulesets.Osu.Objects
} }
} }
/// <summary>
/// The position of the cursor at the point of completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal Vector2? LazyEndPosition;
/// <summary>
/// The distance travelled by the cursor upon completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal float LazyTravelDistance;
/// <summary>
/// The time taken by the cursor upon completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal double LazyTravelTime;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>(); public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
[JsonIgnore] [JsonIgnore]

View File

@@ -62,24 +62,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]
{ {
spin = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-spin"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 335,
},
clear = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-clear"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 115,
},
bonusCounter = new LegacySpriteText(LegacyFont.Score) bonusCounter = new LegacySpriteText(LegacyFont.Score)
{ {
Alpha = 0, Alpha = 0,
@@ -103,6 +85,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Scale = new Vector2(SPRITE_SCALE * 0.9f), Scale = new Vector2(SPRITE_SCALE * 0.9f),
Position = new Vector2(80, 448 + spm_hide_offset), Position = new Vector2(80, 448 + spm_hide_offset),
}, },
spin = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-spin"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 335,
},
clear = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-clear"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 115,
},
} }
}); });
} }

View File

@@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Play.PlayerSettings;
@@ -13,19 +14,19 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
private readonly OsuRulesetConfigManager config; private readonly OsuRulesetConfigManager config;
[SettingSource("Show click markers", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowClickMarkers), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowClickMarkers { get; } = new BindableBool(); public BindableBool ShowClickMarkers { get; } = new BindableBool();
[SettingSource("Show frame markers", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowFrameMarkers), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowAimMarkers { get; } = new BindableBool(); public BindableBool ShowAimMarkers { get; } = new BindableBool();
[SettingSource("Show cursor path", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowCursorPath), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowCursorPath { get; } = new BindableBool(); public BindableBool ShowCursorPath { get; } = new BindableBool();
[SettingSource("Hide gameplay cursor", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.HideGameplayCursor), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool HideSkinCursor { get; } = new BindableBool(); public BindableBool HideSkinCursor { get; } = new BindableBool();
[SettingSource("Display length", SettingControlType = typeof(PlayerSliderBar<int>))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.DisplayLength), SettingControlType = typeof(PlayerSliderBar<int>))]
public BindableInt DisplayLength { get; } = new BindableInt public BindableInt DisplayLength { get; } = new BindableInt
{ {
MinValue = 200, MinValue = 200,
@@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.UI
}; };
public ReplayAnalysisSettings(OsuRulesetConfigManager config) public ReplayAnalysisSettings(OsuRulesetConfigManager config)
: base("Analysis Settings") : base(PlayerSettingsOverlayStrings.AnalysisSettingsTitle)
{ {
this.config = config; this.config = config;
} }

View File

@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
[TestCase(3.305554470092722d, 200, "diffcalc-test")] [TestCase(3.3190848563395079d, 200, "diffcalc-test")]
[TestCase(3.305554470092722d, 200, "diffcalc-test-strong")] [TestCase(3.3190848563395079d, 200, "diffcalc-test-strong")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(4.4472572672057815d, 200, "diffcalc-test")] [TestCase(4.4551414906554987d, 200, "diffcalc-test")]
[TestCase(4.4472572672057815d, 200, "diffcalc-test-strong")] [TestCase(4.4551414906554987d, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());

View File

@@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
@@ -24,7 +26,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
int consistentRatioCount = 0; int consistentRatioCount = 0;
double totalRatioCount = 0.0; double totalRatioCount = 0.0;
List<double> recentRatios = new List<double>();
TaikoDifficultyHitObject current = hitObject; TaikoDifficultyHitObject current = hitObject;
var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1);
for (int i = 0; i < maxObjectsToCheck; i++) for (int i = 0; i < maxObjectsToCheck; i++)
{ {
@@ -32,11 +36,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
if (current.Index <= 1) if (current.Index <= 1)
break; break;
var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1);
double currentRatio = current.RhythmData.Ratio; double currentRatio = current.RhythmData.Ratio;
double previousRatio = previousHitObject.RhythmData.Ratio; double previousRatio = previousHitObject.RhythmData.Ratio;
recentRatios.Add(currentRatio);
// A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. // A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error.
if (Math.Abs(1 - currentRatio / previousRatio) <= threshold) if (Math.Abs(1 - currentRatio / previousRatio) <= threshold)
{ {
@@ -45,14 +49,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
break; break;
} }
// Move to the previous object
current = previousHitObject; current = previousHitObject;
} }
// Ensure no division by zero // Ensure no division by zero
double ratioPenalty = 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80; if (consistentRatioCount > 0)
return 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80;
return ratioPenalty; if (recentRatios.Count <= 1) return 1.0;
// As a fallback, calculate the maximum deviation from the average of the recent ratios to ensure slightly off-snapped objects don't bypass the penalty.
double maxRatioDeviation = recentRatios.Max(r => Math.Abs(r - recentRatios.Average()));
double consistentRatioPenalty = 0.7 + 0.3 * DifficultyCalculationUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0);
return consistentRatioPenalty;
} }
/// <summary> /// <summary>

View File

@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Utils; using osu.Game.Rulesets.Taiko.Difficulty.Utils;
@@ -18,6 +20,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
public readonly SameRhythmHitObjectGrouping? Previous; public readonly SameRhythmHitObjectGrouping? Previous;
private const double snap_tolerance = IntervalGroupingUtils.MARGIN_OF_ERROR;
/// <summary> /// <summary>
/// <see cref="DifficultyHitObject.StartTime"/> of the first hit object. /// <see cref="DifficultyHitObject.StartTime"/> of the first hit object.
/// </summary> /// </summary>
@@ -29,35 +33,60 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime; public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime;
/// <summary> /// <summary>
/// The interval in ms of each hit object in this <see cref="SameRhythmHitObjectGrouping"/>. This is only defined if there is /// The normalised interval in ms of each hit object in this <see cref="SameRhythmHitObjectGrouping"/>. This is only defined if there is
/// more than two hit objects in this <see cref="SameRhythmHitObjectGrouping"/>. /// more than two hit objects in this <see cref="SameRhythmHitObjectGrouping"/>.
/// </summary> /// </summary>
public readonly double? HitObjectInterval; public readonly double? HitObjectInterval;
/// <summary> /// <summary>
/// The ratio of <see cref="HitObjectInterval"/> between this and the previous <see cref="SameRhythmHitObjectGrouping"/>. In the /// The normalised ratio of <see cref="HitObjectInterval"/> between this and the previous <see cref="SameRhythmHitObjectGrouping"/>. In the
/// case where one or both of the <see cref="HitObjectInterval"/> is undefined, this will have a value of 1. /// case where one or both of the <see cref="HitObjectInterval"/> is undefined, this will have a value of 1.
/// </summary> /// </summary>
public readonly double HitObjectIntervalRatio; public readonly double HitObjectIntervalRatio;
/// <inheritdoc/> /// <inheritdoc/>
public double Interval { get; } public double Interval { get; } = double.PositiveInfinity;
public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List<TaikoDifficultyHitObject> hitObjects) public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List<TaikoDifficultyHitObject> hitObjects)
{ {
Previous = previous; Previous = previous;
HitObjects = hitObjects; HitObjects = hitObjects;
// Calculate the average interval between hitobjects, or null if there are fewer than two // Cluster and normalise each hitobjects delta-time.
HitObjectInterval = HitObjects.Count < 2 ? null : Duration / (HitObjects.Count - 1); var normaliseHitObjects = DeltaTimeNormaliser.Normalise(hitObjects, snap_tolerance);
var normalisedHitObjectDeltaTime = hitObjects
.Skip(1)
.Select(hitObject => normaliseHitObjects[hitObject])
.ToList();
// Secondary check to ensure there isn't any 'noise' or outliers by taking the modal delta time.
double modalDelta = normalisedHitObjectDeltaTime.Count > 0
? Math.Round(normalisedHitObjectDeltaTime[0])
: 0;
// Calculate the average interval between hitobjects.
if (normalisedHitObjectDeltaTime.Count > 0)
{
if (previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance)
HitObjectInterval = previousDelta;
else
HitObjectInterval = modalDelta;
}
// Calculate the ratio between this group's interval and the previous group's interval // Calculate the ratio between this group's interval and the previous group's interval
HitObjectIntervalRatio = Previous?.HitObjectInterval != null && HitObjectInterval != null HitObjectIntervalRatio = previous?.HitObjectInterval is double previousInterval && HitObjectInterval is double currentInterval
? HitObjectInterval.Value / Previous.HitObjectInterval.Value ? currentInterval / previousInterval
: 1; : 1.0;
// Calculate the interval from the previous group's start time // Calculate the interval from the previous group's start time
Interval = Previous != null ? StartTime - Previous.StartTime : double.PositiveInfinity; if (previous != null)
{
if (Math.Abs(StartTime - previous.StartTime) <= snap_tolerance)
Interval = 0;
else
Interval = StartTime - previous.StartTime;
}
} }
} }
} }

View File

@@ -42,20 +42,28 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current) protected override double StrainValueAt(DifficultyHitObject current)
{ {
currentStrain *= strainDecay(current.DeltaTime); currentStrain *= strainDecay(current.DeltaTime);
currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
// Safely prevents previous strains from shifting as new notes are added. // Safely prevents previous strains from shifting as new notes are added.
var currentObject = current as TaikoDifficultyHitObject; var currentObject = current as TaikoDifficultyHitObject;
int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
double monolengthBonus = isConvert ? 1 : 1 + Math.Min(Math.Max((index - 5) / 50.0, 0), 0.30); double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20);
if (SingleColourStamina) // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns.
return DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain); if (!SingleColourStamina)
staminaDifficulty *= monoLengthBonus;
return currentStrain * monolengthBonus; currentStrain += staminaDifficulty;
// For converted maps, difficulty often comes entirely from long mono streams with no colour variation.
// To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10.
return SingleColourStamina ? DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain;
} }
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => SingleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) =>
SingleColourStamina
? 0
: currentStrain * strainDecay(time - current.Previous(0).StartTime);
} }
} }

View File

@@ -10,9 +10,16 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
public class TaikoDifficultyAttributes : DifficultyAttributes public class TaikoDifficultyAttributes : DifficultyAttributes
{ {
/// <summary>
/// The difficulty corresponding to the mechanical skills in osu!taiko.
/// This includes colour and stamina combined.
/// </summary>
public double MechanicalDifficulty { get; set; }
/// <summary> /// <summary>
/// The difficulty corresponding to the rhythm skill. /// The difficulty corresponding to the rhythm skill.
/// </summary> /// </summary>
[JsonProperty("rhythm_difficulty")]
public double RhythmDifficulty { get; set; } public double RhythmDifficulty { get; set; }
/// <summary> /// <summary>
@@ -36,9 +43,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("mono_stamina_factor")] [JsonProperty("mono_stamina_factor")]
public double MonoStaminaFactor { get; set; } public double MonoStaminaFactor { get; set; }
public double RhythmTopStrains { get; set; } /// <summary>
/// The factor corresponding to the consistency of a map.
public double ColourTopStrains { get; set; } /// </summary>
[JsonProperty("consistency_factor")]
public double ConsistencyFactor { get; set; }
public double StaminaTopStrains { get; set; } public double StaminaTopStrains { get; set; }
@@ -48,7 +57,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return v; yield return v;
yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_RHYTHM_DIFFICULTY, RhythmDifficulty);
yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor);
yield return (ATTRIB_ID_CONSISTENCY_FACTOR, ConsistencyFactor);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -56,7 +67,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
base.FromDatabaseAttributes(values, onlineInfo); base.FromDatabaseAttributes(values, onlineInfo);
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
RhythmDifficulty = values[ATTRIB_ID_RHYTHM_DIFFICULTY];
MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR];
ConsistencyFactor = values[ATTRIB_ID_CONSISTENCY_FACTOR];
} }
} }
} }

View File

@@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator public class TaikoDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 0.084375; private const double difficulty_multiplier = 0.084375;
private const double rhythm_skill_multiplier = 0.65 * difficulty_multiplier; private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier;
private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; private const double reading_skill_multiplier = 0.100 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier;
@@ -31,6 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double strainLengthBonus; private double strainLengthBonus;
private double patternMultiplier; private double patternMultiplier;
private bool isRelax;
private bool isConvert; private bool isConvert;
public override int Version => 20250306; public override int Version => 20250306;
@@ -46,6 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0;
isRelax = mods.Any(h => h is TaikoModRelax);
return new Skill[] return new Skill[]
{ {
@@ -100,47 +102,50 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new TaikoDifficultyAttributes { Mods = mods }; return new TaikoDifficultyAttributes { Mods = mods };
bool isRelax = mods.Any(h => h is TaikoModRelax);
var rhythm = skills.OfType<Rhythm>().Single(); var rhythm = skills.OfType<Rhythm>().Single();
var reading = skills.OfType<Reading>().Single(); var reading = skills.OfType<Reading>().Single();
var colour = skills.OfType<Colour>().Single(); var colour = skills.OfType<Colour>().Single();
var stamina = skills.OfType<Stamina>().Single(s => !s.SingleColourStamina); var stamina = skills.OfType<Stamina>().Single(s => !s.SingleColourStamina);
var singleColourStamina = skills.OfType<Stamina>().Single(s => s.SingleColourStamina); var singleColourStamina = skills.OfType<Stamina>().Single(s => s.SingleColourStamina);
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double readingRating = reading.DifficultyValue() * reading_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier;
double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5);
double colourDifficultStrains = colour.CountTopWeightedStrains();
double rhythmDifficultStrains = rhythm.CountTopWeightedStrains();
double staminaDifficultStrains = stamina.CountTopWeightedStrains(); double staminaDifficultStrains = stamina.CountTopWeightedStrains();
// As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm.
patternMultiplier = Math.Pow(staminaRating * colourRating, 0.10); patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10);
strainLengthBonus = 1 strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555);
+ Math.Min(Math.Max((staminaDifficultStrains - 1000) / 3700, 0), 0.15)
+ Math.Min(Math.Max((staminaRating - 7.0) / 1.0, 0), 0.05);
double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor);
double starRating = rescale(combinedRating * 1.4); double starRating = rescale(combinedRating * 1.4);
// Calculate proportional contribution of each skill to the combinedRating.
double skillRating = starRating / (rhythmSkill + readingSkill + colourSkill + staminaSkill);
double rhythmDifficulty = rhythmSkill * skillRating;
double readingDifficulty = readingSkill * skillRating;
double colourDifficulty = colourSkill * skillRating;
double staminaDifficulty = staminaSkill * skillRating;
double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties.
TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes
{ {
StarRating = starRating, StarRating = starRating,
Mods = mods, Mods = mods,
RhythmDifficulty = rhythmRating, MechanicalDifficulty = mechanicalDifficulty,
ReadingDifficulty = readingRating, RhythmDifficulty = rhythmDifficulty,
ColourDifficulty = colourRating, ReadingDifficulty = readingDifficulty,
StaminaDifficulty = staminaRating, ColourDifficulty = colourDifficulty,
StaminaDifficulty = staminaDifficulty,
MonoStaminaFactor = monoStaminaFactor, MonoStaminaFactor = monoStaminaFactor,
RhythmTopStrains = rhythmDifficultStrains,
ColourTopStrains = colourDifficultStrains,
StaminaTopStrains = staminaDifficultStrains, StaminaTopStrains = staminaDifficultStrains,
ConsistencyFactor = consistencyFactor,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
}; };
@@ -154,14 +159,59 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
/// </remarks> /// </remarks>
private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax, bool isConvert) private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, out double consistencyFactor)
{ {
List<double> peaks = new List<double>(); List<double> peaks = combinePeaks(
rhythm.GetCurrentStrainPeaks().ToList(),
reading.GetCurrentStrainPeaks().ToList(),
colour.GetCurrentStrainPeaks().ToList(),
stamina.GetCurrentStrainPeaks().ToList()
);
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); if (peaks.Count == 0)
var readingPeaks = reading.GetCurrentStrainPeaks().ToList(); {
var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); consistencyFactor = 0;
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); return 0;
}
double difficulty = 0;
double weight = 1;
foreach (double strain in peaks.OrderDescending())
{
difficulty += strain * weight;
weight *= 0.9;
}
List<double> hitObjectStrainPeaks = combinePeaks(
rhythm.GetObjectStrains().ToList(),
reading.GetObjectStrains().ToList(),
colour.GetObjectStrains().ToList(),
stamina.GetObjectStrains().ToList()
);
if (hitObjectStrainPeaks.Count == 0)
{
consistencyFactor = 0;
return 0;
}
// The average of the top 5% of strain peaks from hit objects.
double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average();
// Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest.
// The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor.
consistencyFactor = hitObjectStrainPeaks.Sum() / (topAverageHitObjectStrain * hitObjectStrainPeaks.Count);
return 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(List<double> rhythmPeaks, List<double> readingPeaks, List<double> colourPeaks, List<double> staminaPeaks)
{
var combinedPeaks = new List<double>();
for (int i = 0; i < colourPeaks.Count; i++) for (int i = 0; i < colourPeaks.Count; i++)
{ {
@@ -176,19 +226,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // 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. // These sections will not contribute to the difficulty.
if (peak > 0) if (peak > 0)
peaks.Add(peak); combinedPeaks.Add(peak);
} }
double difficulty = 0; return combinedPeaks;
double weight = 1;
foreach (double strain in peaks.OrderDescending())
{
difficulty += strain * weight;
weight *= 0.9;
}
return difficulty;
} }
/// <summary> /// <summary>

View File

@@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("accuracy")] [JsonProperty("accuracy")]
public double Accuracy { get; set; } public double Accuracy { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
[JsonProperty("estimated_unstable_rate")] [JsonProperty("estimated_unstable_rate")]
public double? EstimatedUnstableRate { get; set; } public double? EstimatedUnstableRate { get; set; }

View File

@@ -4,7 +4,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
@@ -13,6 +12,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double clockRate; private double clockRate;
private double greatHitWindow; private double greatHitWindow;
private double effectiveMissCount; private double totalDifficultHits;
public TaikoPerformanceCalculator() public TaikoPerformanceCalculator()
: base(new TaikoRuleset()) : base(new TaikoRuleset())
@@ -43,9 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
var track = new TrackVirtual(10000); clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
clockRate = track.Rate;
var difficulty = score.BeatmapInfo!.Difficulty.Clone(); var difficulty = score.BeatmapInfo!.Difficulty.Clone();
@@ -56,70 +54,92 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate; greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate;
estimatedUnstableRate = computeDeviationUpperBound() * 10; estimatedUnstableRate = (countGreat == 0 || greatHitWindow <= 0)
? null
: computeDeviationUpperBound(countGreat / (double)totalHits) * 10;
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. // Total difficult hits measures the total difficulty of a map based on its consistency factor.
if (totalSuccessfulHits > 0) totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor;
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. // Converts and the classic mod are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation.
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
bool isClassic = score.Mods.Any(m => m is ModClassic);
double multiplier = 1.13; double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert, isClassic) * 1.08;
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert) * 1.1;
if (score.Mods.Any(m => m is ModHidden) && !isConvert)
multiplier *= 1.075;
if (score.Mods.Any(m => m is ModEasy))
multiplier *= 0.950;
double difficultyValue = computeDifficultyValue(score, taikoAttributes);
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert);
double totalValue =
Math.Pow(
Math.Pow(difficultyValue, 1.1) +
Math.Pow(accuracyValue, 1.1), 1.0 / 1.1
) * multiplier;
return new TaikoPerformanceAttributes return new TaikoPerformanceAttributes
{ {
Difficulty = difficultyValue, Difficulty = difficultyValue,
Accuracy = accuracyValue, Accuracy = accuracyValue,
EffectiveMissCount = effectiveMissCount,
EstimatedUnstableRate = estimatedUnstableRate, EstimatedUnstableRate = estimatedUnstableRate,
Total = totalValue Total = difficultyValue + accuracyValue
}; };
} }
private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert, bool isClassic)
{ {
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.110) - 4.0; if (estimatedUnstableRate == null || totalDifficultHits == 0)
return 0;
// The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully.
double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10;
// The unstable rate at which it can be assumed all rhythm difficulty has been ignored.
// 0.8 represents 80% of total hits being greats, or 90% accuracy in-game
double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10;
// The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating.
double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4);
// A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate.
double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic(
estimatedUnstableRate.Value,
midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2,
multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate),
maxValue: 0.25 * Math.Pow(rhythmFactor, 3)
);
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0;
double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0);
difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10);
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); // Applies a bonus to maps with more total difficulty.
double lengthBonus = 1 + 0.25 * totalDifficultHits / (totalDifficultHits + 4000);
difficultyValue *= lengthBonus; difficultyValue *= lengthBonus;
difficultyValue *= Math.Pow(0.986, effectiveMissCount); // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty.
double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500);
if (score.Mods.Any(m => m is ModEasy)) difficultyValue *= Math.Pow(missPenalty, countMiss);
difficultyValue *= 0.90;
if (score.Mods.Any(m => m is ModHidden)) if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025; {
double hiddenBonus = isConvert ? 0.025 : 0.1;
// Hidden+flashlight plays are excluded from reading-based penalties to hidden.
if (!score.Mods.Any(m => m is ModFlashlight))
{
// A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier.
if (!isClassic)
hiddenBonus *= 0.2;
// A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier.
if (score.Mods.Any(m => m is ModEasy) && isClassic)
hiddenBonus *= 0.5;
}
difficultyValue *= 1 + hiddenBonus;
}
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>)) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
if (estimatedUnstableRate == null)
return 0;
// Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
double accScalingExponent = 2 + attributes.MonoStaminaFactor; double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor;
double accScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3);
return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent);
} }
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
@@ -127,13 +147,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (greatHitWindow <= 0 || estimatedUnstableRate == null) if (greatHitWindow <= 0 || estimatedUnstableRate == null)
return 0; return 0;
double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; double accuracyValue = 470 * Math.Pow(0.9885, estimatedUnstableRate.Value);
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); // Scales up the bonus for lower unstable rate as star rating increases.
accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600;
if (score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= 1.075;
// Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor.
accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000);
// Applies a bonus to maps with more total memory required with HDFL.
double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus);
return accuracyValue; return accuracyValue;
} }
@@ -143,17 +172,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that
/// two SS scores on the same map with the same settings will always return the same deviation. /// two SS scores on the same map with the same settings will always return the same deviation.
/// </summary> /// </summary>
private double? computeDeviationUpperBound() private double computeDeviationUpperBound(double accuracy)
{ {
if (countGreat == 0 || greatHitWindow <= 0)
return null;
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
double n = totalHits; double n = totalHits;
// Proportion of greats hit. // Proportion of greats hit.
double p = countGreat / n; double p = accuracy;
// We can be 99% confident that p is at least this value. // We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);

View File

@@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
{
/// <summary>
/// Normalises deltaTime values for TaikoDifficultyHitObjects.
/// </summary>
public static class DeltaTimeNormaliser
{
/// <summary>
/// Combines deltaTime values that differ by at most <paramref name="marginOfError"/>
/// and replaces each value with the median of its range. This is used to reduce timing noise
/// and improve rhythm grouping consistency, especially for maps with inconsistent or 'off-snapped' timing.
/// </summary>
public static Dictionary<TaikoDifficultyHitObject, double> Normalise(
IReadOnlyList<TaikoDifficultyHitObject> hitObjects,
double marginOfError)
{
var deltaTimes = hitObjects.Select(h => h.DeltaTime).Distinct().OrderBy(d => d).ToList();
var sets = new List<List<double>>();
List<double>? current = null;
foreach (double value in deltaTimes)
{
// Add to the current group if within margin of error
if (current != null && Math.Abs(value - current[0]) <= marginOfError)
{
current.Add(value);
continue;
}
// Otherwise begin a new group
current = new List<double> { value };
sets.Add(current);
}
// Compute median for each group
var medianLookup = new Dictionary<double, double>();
foreach (var set in sets)
{
set.Sort();
int mid = set.Count / 2;
double median = set.Count % 2 == 1
? set[mid]
: (set[mid - 1] + set[mid]) / 2;
foreach (double v in set)
medianLookup[v] = median;
}
// Assign each hitobjects deltaTime the corresponding median value
return hitObjects.ToDictionary(
h => h,
h => medianLookup.TryGetValue(h.DeltaTime, out double median) ? median : h.DeltaTime
);
}
}
}

View File

@@ -8,6 +8,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
{ {
public static class IntervalGroupingUtils public static class IntervalGroupingUtils
{ {
// The margin of error when comparing intervals for grouping, or snapping intervals to a common value.
public const double MARGIN_OF_ERROR = 5.0;
public static List<List<T>> GroupByInterval<T>(IReadOnlyList<T> objects) where T : IHasInterval public static List<List<T>> GroupByInterval<T>(IReadOnlyList<T> objects) where T : IHasInterval
{ {
var groups = new List<List<T>>(); var groups = new List<List<T>>();
@@ -21,8 +24,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
private static List<T> createNextGroup<T>(IReadOnlyList<T> objects, ref int i) where T : IHasInterval private static List<T> createNextGroup<T>(IReadOnlyList<T> objects, ref int i) where T : IHasInterval
{ {
const double margin_of_error = 5;
// This never compares the first two elements in the group. // This never compares the first two elements in the group.
// This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329) // This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329)
var groupedObjects = new List<T> { objects[i] }; var groupedObjects = new List<T> { objects[i] };
@@ -30,11 +31,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
for (; i < objects.Count - 1; i++) for (; i < objects.Count - 1; i++)
{ {
if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, margin_of_error)) if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MARGIN_OF_ERROR))
{ {
// When an interval change occurs, include the object with the differing interval in the case it increased // When an interval change occurs, include the object with the differing interval in the case it increased
// See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. // See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale.
if (objects[i + 1].Interval > objects[i].Interval + margin_of_error) if (objects[i + 1].Interval > objects[i].Interval + MARGIN_OF_ERROR)
{ {
groupedObjects.Add(objects[i]); groupedObjects.Add(objects[i]);
i++; i++;
@@ -49,7 +50,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
// Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. // Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error.
// If true, add the current object to the group and increment the index to process the next object. // If true, add the current object to the group and increment the index to process the next object.
if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, margin_of_error)) if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MARGIN_OF_ERROR))
{ {
groupedObjects.Add(objects[i]); groupedObjects.Add(objects[i]);
i++; i++;

View File

@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets.AvailableRulesets.Count());
Assert.AreEqual(4, realm.Realm.All<RulesetInfo>().Count()); Assert.AreEqual(4, realm.Realm.All<RulesetInfo>().Count());
@@ -36,8 +36,8 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
var rulesets2 = new RealmRulesetStore(realm, storage); using var rulesets2 = new RealmRulesetStore(realm, storage);
Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets.AvailableRulesets.Count());
Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets2.AvailableRulesets.Count());
@@ -52,7 +52,7 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged);
Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged);
@@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
_ = new RealmRulesetStore(realm, storage); using var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
}); });
@@ -104,13 +104,13 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
_ = new RealmRulesetStore(realm, storage); using var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
// Simulate the ruleset getting updated // Simulate the ruleset getting updated
LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION;
_ = new RealmRulesetStore(realm, storage); using var __ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
}); });

View File

@@ -46,9 +46,12 @@ namespace osu.Game.Tests.Extensions
[Test] [Test]
[SetCulture("fr-FR")] [SetCulture("fr-FR")]
public void TestCultureInsensitivity() [TestCase(0.4, true, 2, ExpectedResult = "40%")]
[TestCase(1e-6, false, 6, ExpectedResult = "0.000001")]
[TestCase(0.48333, true, 4, ExpectedResult = "48.33%")]
public string TestCultureInsensitivity(double input, bool percent, int decimalDigits)
{ {
Assert.That(0.4.ToStandardFormattedString(maxDecimalDigits: 2, asPercentage: true), Is.EqualTo("40%")); return input.ToStandardFormattedString(decimalDigits, percent);
} }
} }
} }

View File

@@ -299,6 +299,23 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value); Assert.AreEqual(filtered, carouselItem.Filtered.Value);
} }
[Test]
[TestCase("artist")]
[TestCase("unicode")]
public void TestCriteriaNotMatchingArtist(string excludedTerm)
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = excludedTerm, ExcludeTerm = true }
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.True(carouselItem.Filtered.Value);
}
[TestCase("simple", false)] [TestCase("simple", false)]
[TestCase("\"style/clean\"", false)] [TestCase("\"style/clean\"", false)]
[TestCase("\"style/clean\"!", false)] [TestCase("\"style/clean\"!", false)]
@@ -350,6 +367,41 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(true, carouselItem.Filtered.Value); Assert.AreEqual(true, carouselItem.Filtered.Value);
} }
[Test]
public void TestCriteriaMatchingTagExcluded()
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
UserTags =
[
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!", ExcludeTerm = true },
]
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.AreEqual(true, carouselItem.Filtered.Value);
}
[Test]
public void TestCriteriaOneTagIncludedAndOneTagExcluded()
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
UserTags =
[
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" },
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/clean\"!", ExcludeTerm = true }
]
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.AreEqual(true, carouselItem.Filtered.Value);
}
[Test] [Test]
public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive() public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive()
{ {

View File

@@ -68,6 +68,25 @@ namespace osu.Game.Tests.Visual.Editing
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f)); progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f));
}, 0, true); }, 0, true);
}); });
AddStep("increase progress slowly then fail", () =>
{
incrementingProgress = 0;
ScheduledDelegate? task = null;
task = Scheduler.AddDelayed(() =>
{
if (incrementingProgress >= 1)
{
progress.SetFailed("nope");
// ReSharper disable once AccessToModifiedClosure
task?.Cancel();
return;
}
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.001f));
}, 0, true);
});
AddUntilStep("wait for completed", () => incrementingProgress >= 1); AddUntilStep("wait for completed", () => incrementingProgress >= 1);
AddStep("completed", () => progress.SetCompleted()); AddStep("completed", () => progress.SetCompleted());

View File

@@ -257,6 +257,14 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null)); AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private class CustomRuleset : OsuRuleset, ILegacyRuleset private class CustomRuleset : OsuRuleset, ILegacyRuleset
{ {
public override string Description => "custom"; public override string Description => "custom";

View File

@@ -316,6 +316,26 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("ensure no submission", () => Player.SubmittedScore == null); AddAssert("ensure no submission", () => Player.SubmittedScore == null);
} }
[Test]
public void TestNoSubmissionOnLocallyModifiedBeatmapWithOnlineId()
{
prepareTestAPI(true);
createPlayerTest(false, r =>
{
var beatmap = createTestBeatmap(r);
beatmap.BeatmapInfo.Status = BeatmapOnlineStatus.LocallyModified;
return beatmap;
});
AddUntilStep("wait for token request", () => Player.TokenCreationRequested);
addFakeHit();
AddStep("exit", () => Player.Exit());
AddAssert("ensure no submission", () => Player.SubmittedScore == null);
}
[TestCase(null)] [TestCase(null)]
[TestCase(10)] [TestCase(10)]
public void TestNoSubmissionOnCustomRuleset(int? rulesetId) public void TestNoSubmissionOnCustomRuleset(int? rulesetId)

View File

@@ -1,28 +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 osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapPanel : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add beatmap panel", () =>
{
Child = new BeatmapPanel(CreateAPIBeatmap())
{
Size = new Vector2(300, 70),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
});
}
}
}

View File

@@ -10,18 +10,20 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay;
using osuTK; using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneBeatmapSelectionGrid : OnlinePlayTestScene public partial class TestSceneBeatmapSelectGrid : OnlinePlayTestScene
{ {
private MultiplayerPlaylistItem[] items = null!; private MultiplayerPlaylistItem[] items = null!;
private BeatmapSelectionGrid grid = null!; private BeatmapSelectGrid grid = null!;
[Resolved] [Resolved]
private BeatmapManager beatmapManager { get; set; } = null!; private BeatmapManager beatmapManager { get; set; } = null!;
@@ -58,7 +60,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("add grid", () => Child = grid = new BeatmapSelectionGrid AddStep("add grid", () => Child = grid = new BeatmapSelectGrid
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
@@ -75,6 +77,31 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddWaitStep("wait for panels", 3); AddWaitStep("wait for panels", 3);
} }
[Test]
public void TestBasic()
{
AddStep("do nothing", () =>
{
// test scene is weird.
});
AddStep("add selection 1", () => grid.ChildrenOfType<BeatmapSelectPanel>().First().AddUser(new APIUser
{
Id = DummyAPIAccess.DUMMY_USER_ID,
Username = "Maarvin",
}));
AddStep("add selection 2", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(5).First().AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}));
AddStep("add selection 3", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(10).First().AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}));
}
[Test] [Test]
public void TestCompleteRollAnimation() public void TestCompleteRollAnimation()
{ {
@@ -145,7 +172,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
var (candidateItems, _) = pickRandomItems(count); var (candidateItems, _) = pickRandomItems(count);
grid.TransferCandidatePanelsToRollContainer(candidateItems); grid.TransferCandidatePanelsToRollContainer(candidateItems);
grid.Delay(BeatmapSelectionGrid.ARRANGE_DELAY) grid.Delay(BeatmapSelectGrid.ARRANGE_DELAY)
.Schedule(() => grid.ArrangeItemsForRollAnimation()); .Schedule(() => grid.ArrangeItemsForRollAnimation());
}); });
@@ -153,7 +180,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddStep("display roll order", () => AddStep("display roll order", () =>
{ {
var panels = grid.ChildrenOfType<BeatmapSelectionPanel>().ToArray(); var panels = grid.ChildrenOfType<BeatmapSelectPanel>().ToArray();
for (int i = 0; i < panels.Length; i++) for (int i = 0; i < panels.Length; i++)
{ {

View File

@@ -4,15 +4,16 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneBeatmapSelectionPanel : MultiplayerTestScene public partial class TestSceneBeatmapSelectPanel : MultiplayerTestScene
{ {
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
@@ -20,9 +21,9 @@ namespace osu.Game.Tests.Visual.Matchmaking
[Test] [Test]
public void TestBeatmapPanel() public void TestBeatmapPanel()
{ {
BeatmapSelectionPanel? panel = null; BeatmapSelectPanel? panel = null;
AddStep("add panel", () => Child = panel = new BeatmapSelectionPanel(new MultiplayerPlaylistItem()) AddStep("add panel", () => Child = panel = new BeatmapSelectPanel(new MultiplayerPlaylistItem())
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@@ -30,9 +31,9 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddStep("add maarvin", () => panel!.AddUser(new APIUser AddStep("add maarvin", () => panel!.AddUser(new APIUser
{ {
Id = 6411631, Id = DummyAPIAccess.DUMMY_USER_ID,
Username = "Maarvin", Username = "Maarvin",
}, isOwnUser: true)); }));
AddStep("add peppy", () => panel!.AddUser(new APIUser AddStep("add peppy", () => panel!.AddUser(new APIUser
{ {
Id = 2, Id = 2,

View File

@@ -1,68 +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 NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectionOverlay : OsuTestScene
{
private BeatmapSelectionOverlay selectionOverlay = null!;
[SetUpSteps]
public void SetupSteps()
{
AddStep("add drawable", () => Child = new Container
{
Width = 100,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(2),
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0.1f,
},
selectionOverlay = new BeatmapSelectionOverlay
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
}
}
});
}
[Test]
public void TestSelectionOverlay()
{
AddStep("add maarvin", () => selectionOverlay.AddUser(new APIUser
{
Id = 6411631,
Username = "Maarvin",
}, isOwnUser: true));
AddStep("add peppy", () => selectionOverlay.AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}, false));
AddStep("add smogipoo", () => selectionOverlay.AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}, false));
AddStep("remove smogipoo", () => selectionOverlay.RemoveUser(1040328));
AddStep("remove peppy", () => selectionOverlay.RemoveUser(2));
AddStep("remove maarvin", () => selectionOverlay.RemoveUser(6411631));
}
}
}

View File

@@ -1,89 +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.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneIdleScreen : MultiplayerTestScene
{
private const int user_count = 8;
private (MultiplayerRoomUser user, int score)[] userScores = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("add list", () =>
{
userScores = Enumerable.Range(1, user_count).Select(i =>
{
var user = new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
};
return (user, 0);
}).ToArray();
Child = new ScreenStack(new IdleScreen())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
AddStep("join users", () =>
{
foreach (var (user, _) in userScores)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestRandomChanges()
{
AddStep("apply random changes", () =>
{
int[] deltas = Enumerable.Range(1, userScores.Length).ToArray();
new Random().Shuffle(deltas);
for (int i = 0; i < userScores.Length; i++)
userScores[i] = (userScores[i].user, userScores[i].score + deltas[i]);
userScores = userScores.OrderByDescending(u => u.score).ToArray();
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary = userScores.Select((tuple, i) => new MatchmakingUser
{
UserId = tuple.user.UserID,
Points = tuple.score,
Placement = i + 1
}).ToDictionary(s => s.UserId)
}
}).WaitSafely();
});
}
}
}

View File

@@ -6,20 +6,20 @@ using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneMatchmakingCloud : OsuTestScene public partial class TestSceneMatchmakingCloud : OsuTestScene
{ {
private MatchmakingCloud cloud = null!; private CloudVisualisation cloud = null!;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
Child = cloud = new MatchmakingCloud Child = cloud = new CloudVisualisation
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
}; };

View File

@@ -3,7 +3,7 @@
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Online.Matchmaking; using osu.Game.Online.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
@@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("add selector", () => Child = new MatchmakingPoolSelector AddStep("add selector", () => Child = new PoolSelector
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,

View File

@@ -7,8 +7,8 @@ using osu.Framework.Allocation;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Intro;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users; using osu.Game.Users;
@@ -17,16 +17,16 @@ namespace osu.Game.Tests.Visual.Matchmaking
public partial class TestSceneMatchmakingQueueScreen : MultiplayerTestScene public partial class TestSceneMatchmakingQueueScreen : MultiplayerTestScene
{ {
[Cached] [Cached]
private readonly MatchmakingController controller = new MatchmakingController(); private readonly QueueController controller = new QueueController();
private MatchmakingQueueScreen? queueScreen => Stack.CurrentScreen as MatchmakingQueueScreen; private ScreenQueue? queueScreen => Stack.CurrentScreen as ScreenQueue;
[SetUpSteps] [SetUpSteps]
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("load screen", () => LoadScreen(new MatchmakingIntroScreen())); AddStep("load screen", () => LoadScreen(new IntroScreen()));
} }
[Test] [Test]
@@ -44,15 +44,15 @@ namespace osu.Game.Tests.Visual.Matchmaking
}).ToArray(); }).ToArray();
}); });
AddStep("change state to idle", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.Idle)); AddStep("change state to idle", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Idle));
AddStep("change state to queueing", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.Queueing)); AddStep("change state to queueing", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Queueing));
AddStep("change state to found match", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.PendingAccept)); AddStep("change state to found match", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.PendingAccept));
AddStep("change state to waiting for room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.AcceptedWaitingForRoom)); AddStep("change state to waiting for room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom));
AddStep("change state to in room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.InRoom)); AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom));
} }
} }
} }

View File

@@ -6,9 +6,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
@@ -18,11 +16,8 @@ using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
@@ -32,7 +27,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
private const int beatmap_count = 50; private const int beatmap_count = 50;
private MultiplayerRoomUser[] users = null!; private MultiplayerRoomUser[] users = null!;
private MatchmakingScreen screen = null!; private ScreenMatchmaking screen = null!;
public override void SetUpSteps() public override void SetUpSteps()
{ {
@@ -40,7 +35,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddStep("join room", () => AddStep("join room", () =>
{ {
var room = CreateDefaultRoom(); var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{ {
ID = i, ID = i,
@@ -53,6 +48,20 @@ namespace osu.Game.Tests.Visual.Matchmaking
WaitForJoined(); WaitForJoined();
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
setupRequestHandler(); setupRequestHandler();
AddStep("load match", () => AddStep("load match", () =>
@@ -71,7 +80,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
StarRating = i / 10.0 StarRating = i / 10.0
}).ToArray(); }).ToArray();
LoadScreen(screen = new MatchmakingScreen(new MultiplayerRoom(0) LoadScreen(screen = new ScreenMatchmaking(new MultiplayerRoom(0)
{ {
Users = users, Users = users,
Playlist = beatmaps Playlist = beatmaps
@@ -83,104 +92,51 @@ namespace osu.Game.Tests.Visual.Matchmaking
[Test] [Test]
public void TestGameplayFlow() public void TestGameplayFlow()
{ {
// Initial "ready" status of the room". for (int round = 1; round <= 3; round++)
AddWaitStep("wait", 5);
AddStep("round start", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{ {
Stage = MatchmakingStage.RoundWarmupTime AddLabel($"Round {round}");
}).WaitSafely());
// Next round starts with picks. int r = round;
AddWaitStep("wait", 5); changeStage(MatchmakingStage.RoundWarmupTime, state => state.CurrentRound = r);
changeStage(MatchmakingStage.UserBeatmapSelect);
AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState changeStage(MatchmakingStage.ServerBeatmapFinalised, state =>
{
Stage = MatchmakingStage.UserBeatmapSelect
}).WaitSafely());
// Make some selections
AddWaitStep("wait", 5);
for (int i = 0; i < 3; i++)
{
int j = i * 2;
AddStep("click a beatmap", () =>
{ {
Quad panelQuad = this.ChildrenOfType<BeatmapPanel>().ElementAt(j).ScreenSpaceDrawQuad; MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 8).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
InputManager.MoveMouseTo(new Vector2(panelQuad.Centre.X, panelQuad.TopLeft.Y + 5)); state.CandidateItems = beatmaps.Select(b => b.ID).ToArray();
InputManager.Click(MouseButton.Left); state.CandidateItem = beatmaps[0].ID;
}); }, waitTime: 35);
AddWaitStep("wait", 2); changeStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
changeStage(MatchmakingStage.GameplayWarmupTime);
changeStage(MatchmakingStage.Gameplay);
changeStage(MatchmakingStage.ResultsDisplaying);
} }
// Lock in the gameplay beatmap changeStage(MatchmakingStage.Ended, state =>
AddStep("selection", () =>
{ {
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem int i = 1;
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState foreach (var user in MultiplayerClient.ServerRoom!.Users.OrderBy(_ => RNG.Next()))
{ {
Stage = MatchmakingStage.ServerBeatmapFinalised, state.Users[user.UserID].Placement = i++;
CandidateItems = beatmaps.Select(b => b.ID).ToArray(), state.Users[user.UserID].Points = (8 - i) * 7;
CandidateItem = beatmaps[0].ID state.Users[user.UserID].Rounds[1].Placement = 1;
}).WaitSafely(); state.Users[user.UserID].Rounds[1].TotalScore = 1;
state.Users[user.UserID].Rounds[1].Statistics[HitResult.LargeBonus] = 1;
}
}); });
}
// Prepare gameplay. private void changeStage(MatchmakingStage stage, Action<MatchmakingRoomState>? prepare = null, int waitTime = 5)
AddWaitStep("wait", 25); {
AddStep($"stage: {stage}", () => MultiplayerClient.MatchmakingChangeStage(stage, prepare).WaitSafely());
AddStep("prepare gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState AddWaitStep("wait", waitTime);
{
Stage = MatchmakingStage.GameplayWarmupTime
}).WaitSafely());
// Start gameplay.
AddWaitStep("wait", 5);
AddStep("gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.Gameplay
}).WaitSafely());
AddStep("start gameplay", () => MultiplayerClient.StartMatch().WaitSafely());
// AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true);
// Finish gameplay.
AddWaitStep("wait", 5);
AddStep("round end", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.ResultsDisplaying
}).WaitSafely());
AddWaitStep("wait", 10);
AddStep("room end", () =>
{
MatchmakingRoomState state = new MatchmakingRoomState
{
CurrentRound = 1,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Rounds[1].Placement = 1;
state.Users[localUserId].Rounds[1].TotalScore = 1;
state.Users[localUserId].Rounds[1].Statistics[HitResult.LargeBonus] = 1;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
} }
private void setupRequestHandler() private void setupRequestHandler()

View File

@@ -1,119 +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.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingScreenStack : MultiplayerTestScene
{
private const int user_count = 8;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom();
room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
JoinRoom(room);
});
WaitForJoined();
AddStep("add carousel", () =>
{
Child = new MatchmakingScreenStack
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
};
});
AddStep("join users", () =>
{
var users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
}).ToArray();
foreach (var user in users)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestStatus()
{
AddWaitStep("wait for scroll", 5);
AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.UserBeatmapSelect
}).WaitSafely());
AddWaitStep("wait for scroll", 5);
AddStep("selection", () =>
{
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
beatmaps = Random.Shared.GetItems(beatmaps, 8);
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.ServerBeatmapFinalised,
CandidateItems = beatmaps.Select(b => b.ID).ToArray(),
CandidateItem = beatmaps[0].ID
}).WaitSafely();
});
AddWaitStep("wait for scroll", 35);
AddStep("room end", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 1,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Rounds[1].Placement = 1;
state.Users[localUserId].Rounds[1].TotalScore = 1;
state.Users[localUserId].Rounds[1].Statistics[HitResult.LargeBonus] = 1;
state.Users[1].Placement = 2;
state.Users[1].Rounds[1].Placement = 2;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}

View File

@@ -2,18 +2,18 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneRoomStatisticPanel : MultiplayerTestScene public partial class TestScenePanelRoomAward : MultiplayerTestScene
{ {
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("add statistic", () => Child = new RoomStatisticPanel("Statistic description", 1) AddStep("add award", () => Child = new PanelRoomAward("Award name", "Description of what this award means", 1)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre Origin = Anchor.Centre

View File

@@ -5,11 +5,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
@@ -58,7 +59,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddStep("join room", () => AddStep("join room", () =>
{ {
var room = CreateDefaultRoom(); var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = items; room.Playlist = items;
JoinRoom(room); JoinRoom(room);
@@ -78,9 +79,9 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
var selectedItems = new List<long>(); var selectedItems = new List<long>();
PickScreen screen = null!; SubScreenBeatmapSelect screen = null!;
AddStep("add screen", () => LoadScreen(screen = new PickScreen())); AddStep("add screen", () => Child = new ScreenStack(screen = new SubScreenBeatmapSelect()));
AddStep("select maps", () => AddStep("select maps", () =>
{ {

View File

@@ -1,13 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle; using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users; using osu.Game.Users;
@@ -21,7 +24,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom())); AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined(); WaitForJoined();
AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(1) AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(1)
@@ -64,7 +67,10 @@ namespace osu.Game.Tests.Visual.Matchmaking
} }
}).WaitSafely()); }).WaitSafely());
AddToggleStep("toggle horizontal", h => panel.Horizontal = h); foreach (var layout in Enum.GetValues<PlayerPanelDisplayMode>())
{
AddStep($"set layout to {layout}", () => panel.DisplayMode = layout);
}
} }
[Test] [Test]
@@ -90,5 +96,11 @@ namespace osu.Game.Tests.Visual.Matchmaking
} }
}).WaitSafely()); }).WaitSafely());
} }
[Test]
public void TestJump()
{
AddStep("jump", () => MultiplayerClient.SendUserMatchRequest(1, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely());
}
} }
} }

View File

@@ -0,0 +1,159 @@
// 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.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePlayerPanelOverlay : MultiplayerTestScene
{
private PlayerPanelOverlay list = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add list", () => Child = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Child = list = new PlayerPanelOverlay()
});
}
[Test]
public void TestChangeDisplayMode()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden);
}
[Test]
public void AddPanelsGrid()
{
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void AddPanelsSplit()
{
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void RemovePanels()
{
AddStep("join another user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(1)
{
User = new APIUser
{
Username = "User 1"
}
});
});
AddUntilStep("two panels displayed", () => this.ChildrenOfType<PlayerPanel>().Count(), () => Is.EqualTo(2));
AddStep("remove a user", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 }));
AddUntilStep("one panel displayed", () => this.ChildrenOfType<PlayerPanel>().Count(), () => Is.EqualTo(1));
}
[Test]
public void ChangeRankings()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("set random placements", () =>
{
MultiplayerRoom room = MultiplayerClient.ServerRoom!;
int[] placements = Enumerable.Range(1, room.Users.Count).ToArray();
Random.Shared.Shuffle(placements);
MatchmakingRoomState state = new MatchmakingRoomState();
for (int i = 0; i < room.Users.Count; i++)
state.Users[room.Users[i].UserID].Placement = placements[i];
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}

View File

@@ -8,8 +8,9 @@ using osu.Framework.Screens;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osuTK; using osuTK;
@@ -17,25 +18,71 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneResultsScreen : MultiplayerTestScene public partial class TestSceneResultsScreen : MultiplayerTestScene
{ {
private const int invalid_user_id = 1;
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom())); AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined(); WaitForJoined();
AddStep("set initial results", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 6,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
// Overall state.
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Points = 8;
for (int round = 1; round <= state.CurrentRound; round++)
state.Users[localUserId].Rounds[round].Placement = round;
// Highest score.
state.Users[localUserId].Rounds[1].TotalScore = 1000;
// Highest accuracy.
state.Users[localUserId].Rounds[2].Accuracy = 0.9995;
// Highest combo.
state.Users[localUserId].Rounds[3].MaxCombo = 100;
// Most bonus score.
state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50;
// Smallest score difference.
state.Users[localUserId].Rounds[5].TotalScore = 1000;
// Largest score difference.
state.Users[localUserId].Rounds[6].TotalScore = 1000;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
AddStep("add results screen", () => AddStep("add results screen", () =>
{ {
Child = new ScreenStack(new ResultsScreen()) Child = new ScreenStack(new SubScreenResults())
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Size = new Vector2(0.8f) Size = new Vector2(0.8f)
}; };
}); });
}
[Test]
public void TestBasic()
{
AddStep("do nothing", () => { });
}
[Test]
public void TestInvalidUser()
{
const int invalid_user_id = 1;
AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id) AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id)
{ {
User = new APIUser User = new APIUser
@@ -44,11 +91,7 @@ namespace osu.Game.Tests.Visual.Matchmaking
Username = "Invalid user" Username = "Invalid user"
} }
})); }));
}
[Test]
public void TestResults()
{
AddStep("set results stage", () => AddStep("set results stage", () =>
{ {
var state = new MatchmakingRoomState var state = new MatchmakingRoomState

View File

@@ -13,7 +13,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osuTK; using osuTK;
@@ -25,14 +25,14 @@ namespace osu.Game.Tests.Visual.Matchmaking
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom())); AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined(); WaitForJoined();
setupRequestHandler(); setupRequestHandler();
AddStep("load screen", () => AddStep("load screen", () =>
{ {
Child = new ScreenStack(new RoundResultsScreen()) Child = new ScreenStack(new SubScreenRoundResults())
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,

View File

@@ -1,49 +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 NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageBubble : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("add bubble", () => Child = new StageBubble(MatchmakingStage.RoundWarmupTime, "Next Round")
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 100
});
}
[Test]
public void TestStartStopCountdown()
{
MultiplayerCountdown countdown = null!;
AddStep("start countdown", () => MultiplayerClient.StartCountdown(countdown = new MatchmakingStageCountdown
{
Stage = MatchmakingStage.RoundWarmupTime,
TimeRemaining = TimeSpan.FromSeconds(5)
}).WaitSafely());
AddWaitStep("wait a bit", 10);
AddStep("stop countdown", () => MultiplayerClient.StopCountdown(countdown).WaitSafely());
}
}
}

View File

@@ -1,56 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking namespace osu.Game.Tests.Visual.Matchmaking
{ {
public partial class TestSceneStageDisplay : MultiplayerTestScene public partial class TestSceneStageDisplay : MultiplayerTestScene
{ {
[Cached]
protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum);
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom())); AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined(); WaitForJoined();
AddStep("add bubble", () => Child = new StageDisplay AddStep("add display", () => Child = new StageDisplay
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Width = 0.5f,
}); });
} }
[Test] [Test]
public void TestStartCountdown() public void TestChangeStage()
{ {
foreach (var status in Enum.GetValues<MatchmakingStage>()) addStage(MatchmakingStage.WaitingForClientsJoin);
for (int i = 1; i <= 5; i++)
{ {
AddStep($"{status}", () => addStage(MatchmakingStage.RoundWarmupTime);
{ addStage(MatchmakingStage.UserBeatmapSelect);
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState addStage(MatchmakingStage.ServerBeatmapFinalised);
{ addStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
Stage = status addStage(MatchmakingStage.GameplayWarmupTime);
}).WaitSafely(); addStage(MatchmakingStage.Gameplay);
addStage(MatchmakingStage.ResultsDisplaying);
MultiplayerClient.StartCountdown(new MatchmakingStageCountdown
{
Stage = status,
TimeRemaining = TimeSpan.FromSeconds(5)
}).WaitSafely();
});
AddWaitStep("wait a bit", 10);
} }
addStage(MatchmakingStage.Ended);
}
private void addStage(MatchmakingStage stage)
{
AddStep($"{stage}", () => MultiplayerClient.MatchmakingChangeStage(stage).WaitSafely());
AddWaitStep("wait a bit", 10);
} }
} }
} }

View File

@@ -1,43 +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 NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageText : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("create display", () => Child = new StageText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[TestCase(MatchmakingStage.WaitingForClientsJoin)]
[TestCase(MatchmakingStage.RoundWarmupTime)]
[TestCase(MatchmakingStage.UserBeatmapSelect)]
[TestCase(MatchmakingStage.ServerBeatmapFinalised)]
[TestCase(MatchmakingStage.WaitingForClientsBeatmapDownload)]
[TestCase(MatchmakingStage.GameplayWarmupTime)]
[TestCase(MatchmakingStage.Gameplay)]
[TestCase(MatchmakingStage.ResultsDisplaying)]
[TestCase(MatchmakingStage.Ended)]
public void TestStatus(MatchmakingStage status)
{
AddStep("set status", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState { Stage = status }).WaitSafely());
}
}
}

View File

@@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
@@ -36,6 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private RulesetStore rulesets = null!;
private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerComponents multiplayerComponents = null!;
@@ -46,7 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
BeatmapStore beatmapStore; BeatmapStore beatmapStore;
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore());
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
@@ -115,5 +117,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded); AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded);
AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent());
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -8,6 +8,7 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
@@ -37,13 +38,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene
{ {
private RulesetStore rulesets = null!;
private TestPlaylist playlist = null!; private TestPlaylist playlist = null!;
private BeatmapManager manager = null!; private BeatmapManager manager = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
} }
@@ -436,6 +438,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private partial class TestPlaylist : DrawableRoomPlaylist private partial class TestPlaylist : DrawableRoomPlaylist
{ {
public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap; public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap;

View File

@@ -51,6 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneMultiplayer : ScreenTestScene public partial class TestSceneMultiplayer : ScreenTestScene
{ {
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private BeatmapSetInfo importedSet2 = null!; private BeatmapSetInfo importedSet2 = null!;
@@ -67,7 +68,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
BeatmapStore beatmapStore; BeatmapStore beatmapStore;
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default));
Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore());
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
@@ -1247,5 +1248,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined);
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Screens; using osu.Framework.Screens;
@@ -170,6 +171,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
.All(b => b.Mod.GetType() != type)); .All(b => b.Mod.GetType() != type));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private partial class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect private partial class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect
{ {
public new Bindable<IReadOnlyList<Mod>> Mods => base.Mods; public new Bindable<IReadOnlyList<Mod>> Mods => base.Mods;

View File

@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform; using osu.Framework.Platform;
@@ -44,6 +45,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public partial class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene public partial class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene
{ {
private MultiplayerMatchSubScreen screen = null!; private MultiplayerMatchSubScreen screen = null!;
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private Room room = null!; private Room room = null!;
@@ -51,7 +53,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
Dependencies.CacheAs<BeatmapStore>(new RealmDetachedBeatmapStore()); Dependencies.CacheAs<BeatmapStore>(new RealmDetachedBeatmapStore());
@@ -462,6 +464,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("settings still open", () => this.ChildrenOfType<MultiplayerMatchSettingsOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); AddAssert("settings still open", () => this.ChildrenOfType<MultiplayerMatchSettingsOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Visible));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen
{ {
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]

View File

@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
@@ -28,6 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene
{ {
private MultiplayerPlaylist list = null!; private MultiplayerPlaylist list = null!;
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private BeatmapInfo importedBeatmap = null!; private BeatmapInfo importedBeatmap = null!;
@@ -35,7 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
} }
@@ -290,5 +292,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
.Single() .Single()
.Items.Any(i => i.ID == playlistItemId); .Items.Any(i => i.ID == playlistItemId);
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Platform; using osu.Framework.Platform;
@@ -26,6 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public partial class TestSceneMultiplayerQueueList : MultiplayerTestScene public partial class TestSceneMultiplayerQueueList : MultiplayerTestScene
{ {
private MultiplayerQueueList playlist = null!; private MultiplayerQueueList playlist = null!;
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private BeatmapInfo importedBeatmap = null!; private BeatmapInfo importedBeatmap = null!;
@@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
} }
@@ -168,5 +170,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
var button = playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAtOrDefault(index); var button = playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAtOrDefault(index);
return (button?.Alpha > 0) == visible; return (button?.Alpha > 0) == visible;
}); });
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
@@ -32,12 +33,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
private Room room = null!; private Room room = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
@@ -162,5 +164,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void assertReadyButtonEnablement(bool shouldBeEnabled) private void assertReadyButtonEnablement(bool shouldBeEnabled)
=> AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => startControl.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value == shouldBeEnabled); => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => startControl.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value == shouldBeEnabled);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Platform; using osu.Framework.Platform;
@@ -31,6 +32,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene
{ {
private RulesetStore rulesets = null!;
private BeatmapManager manager = null!; private BeatmapManager manager = null!;
private TestPlaylistsSongSelect songSelect = null!; private TestPlaylistsSongSelect songSelect = null!;
private Room room = null!; private Room room = null!;
@@ -40,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
BeatmapStore beatmapStore; BeatmapStore beatmapStore;
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore());
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
@@ -189,6 +191,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("mod select visible", () => this.ChildrenOfType<FreeModSelectOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); AddAssert("mod select visible", () => this.ChildrenOfType<FreeModSelectOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Visible));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private partial class TestPlaylistsSongSelect : PlaylistsSongSelect private partial class TestPlaylistsSongSelect : PlaylistsSongSelect
{ {
public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails;

View File

@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@@ -27,6 +28,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneTeamVersus : ScreenTestScene public partial class TestSceneTeamVersus : ScreenTestScene
{ {
private RulesetStore rulesets = null!;
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
@@ -37,7 +39,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
} }
@@ -182,5 +184,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined);
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -472,7 +472,8 @@ namespace osu.Game.Tests.Visual.Navigation
{ {
AddUntilStep($"config value is {configValue}", () => getConfigManager().Get<double>(ManiaRulesetSetting.ScrollSpeed), () => Is.EqualTo(configValue)); AddUntilStep($"config value is {configValue}", () => getConfigManager().Get<double>(ManiaRulesetSetting.ScrollSpeed), () => Is.EqualTo(configValue));
AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType<DrawableManiaRuleset>().Single().TargetTimeRange, AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType<DrawableManiaRuleset>().Single().TargetTimeRange,
() => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue, 2000, 1.0 / 60.0))); () => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue, 200, 1)));
// 按自定义的方式计算
} }
ManiaRulesetConfigManager getConfigManager() => ((ManiaRulesetConfigManager)Game.Dependencies.Get<IRulesetConfigCache>().GetConfigFor(new ManiaRuleset())!); ManiaRulesetConfigManager getConfigManager() => ((ManiaRulesetConfigManager)Game.Dependencies.Get<IRulesetConfigCache>().GetConfigFor(new ManiaRuleset())!);

View File

@@ -278,6 +278,8 @@ namespace osu.Game.Tests.Visual.Navigation
{ {
advanceToSongSelect(); advanceToSongSelect();
openSkinEditor(); openSkinEditor();
AddUntilStep("skin editor visible", () => skinEditor.State.Value == Visibility.Visible);
AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() }); AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() });
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
@@ -290,8 +292,9 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0));
toggleSkinEditor(); toggleSkinEditor();
AddUntilStep("skin editor hidden", () => skinEditor.State.Value == Visibility.Hidden);
AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(1))); AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(2)));
AddUntilStep("settings visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.GreaterThan(0)); AddUntilStep("settings visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.GreaterThan(0));
AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0))); AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0)));

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