mirror of
https://github.com/ppy/osu.git
synced 2025-01-12 17:23:22 +08:00
Merge pull request #9294 from smoogipoo/morth-taiko-changes
This commit is contained in:
commit
7f2ce14f36
@ -13,8 +13,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
|
||||
|
||||
[TestCase(2.9811338051242915d, "diffcalc-test")]
|
||||
[TestCase(2.9811338051242915d, "diffcalc-test-strong")]
|
||||
[TestCase(2.2867022617692685d, "diffcalc-test")]
|
||||
[TestCase(2.2867022617692685d, "diffcalc-test-strong")]
|
||||
public void Test(double expected, string name)
|
||||
=> base.Test(expected, name);
|
||||
|
||||
|
@ -0,0 +1,140 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects special hit object patterns which are easier to hit using special techniques
|
||||
/// than normally assumed in the fully-alternating play style.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This component detects two basic types of patterns, leveraged by the following techniques:
|
||||
/// <list>
|
||||
/// <item>Rolling allows hitting patterns with quickly and regularly alternating notes with a single hand.</item>
|
||||
/// <item>TL tapping makes hitting longer sequences of consecutive same-colour notes with little to no colour changes in-between.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public class StaminaCheeseDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// The minimum number of consecutive objects with repeating patterns that can be classified as hittable using a roll.
|
||||
/// </summary>
|
||||
private const int roll_min_repetitions = 12;
|
||||
|
||||
/// <summary>
|
||||
/// The minimum number of consecutive objects with repeating patterns that can be classified as hittable using a TL tap.
|
||||
/// </summary>
|
||||
private const int tl_min_repetitions = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The list of all <see cref="TaikoDifficultyHitObject"/>s in the map.
|
||||
/// </summary>
|
||||
private readonly List<TaikoDifficultyHitObject> hitObjects;
|
||||
|
||||
public StaminaCheeseDetector(List<TaikoDifficultyHitObject> hitObjects)
|
||||
{
|
||||
this.hitObjects = hitObjects;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds and marks all objects in <see cref="hitObjects"/> that special difficulty-reducing techiques apply to
|
||||
/// with the <see cref="TaikoDifficultyHitObject.StaminaCheese"/> flag.
|
||||
/// </summary>
|
||||
public void FindCheese()
|
||||
{
|
||||
findRolls(3);
|
||||
findRolls(4);
|
||||
|
||||
findTlTap(0, HitType.Rim);
|
||||
findTlTap(1, HitType.Rim);
|
||||
findTlTap(0, HitType.Centre);
|
||||
findTlTap(1, HitType.Centre);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds and marks all sequences hittable using a roll.
|
||||
/// </summary>
|
||||
/// <param name="patternLength">The length of a single repeating pattern to consider (triplets/quadruplets).</param>
|
||||
private void findRolls(int patternLength)
|
||||
{
|
||||
var history = new LimitedCapacityQueue<TaikoDifficultyHitObject>(2 * patternLength);
|
||||
|
||||
// for convenience, we're tracking the index of the item *before* our suspected repeat's start,
|
||||
// as that index can be simply subtracted from the current index to get the number of elements in between
|
||||
// without off-by-one errors
|
||||
int indexBeforeLastRepeat = -1;
|
||||
|
||||
for (int i = 0; i < hitObjects.Count; i++)
|
||||
{
|
||||
history.Enqueue(hitObjects[i]);
|
||||
if (!history.Full)
|
||||
continue;
|
||||
|
||||
if (!containsPatternRepeat(history, patternLength))
|
||||
{
|
||||
// we're setting this up for the next iteration, hence the +1.
|
||||
// right here this index will point at the queue's front (oldest item),
|
||||
// but that item is about to be popped next loop with an enqueue.
|
||||
indexBeforeLastRepeat = i - history.Count + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
int repeatedLength = i - indexBeforeLastRepeat;
|
||||
if (repeatedLength < roll_min_repetitions)
|
||||
continue;
|
||||
|
||||
markObjectsAsCheese(i, repeatedLength);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the objects stored in <paramref name="history"/> contain a repetition of a pattern of length <paramref name="patternLength"/>.
|
||||
/// </summary>
|
||||
private static bool containsPatternRepeat(LimitedCapacityQueue<TaikoDifficultyHitObject> history, int patternLength)
|
||||
{
|
||||
for (int j = 0; j < patternLength; j++)
|
||||
{
|
||||
if (history[j].HitType != history[j + patternLength].HitType)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds and marks all sequences hittable using a TL tap.
|
||||
/// </summary>
|
||||
/// <param name="parity">Whether sequences starting with an odd- (1) or even-indexed (0) hit object should be checked.</param>
|
||||
/// <param name="type">The type of hit to check for TL taps.</param>
|
||||
private void findTlTap(int parity, HitType type)
|
||||
{
|
||||
int tlLength = -2;
|
||||
|
||||
for (int i = parity; i < hitObjects.Count; i += 2)
|
||||
{
|
||||
if (hitObjects[i].HitType == type)
|
||||
tlLength += 2;
|
||||
else
|
||||
tlLength = -2;
|
||||
|
||||
if (tlLength < tl_min_repetitions)
|
||||
continue;
|
||||
|
||||
markObjectsAsCheese(i, tlLength);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks <paramref name="count"/> elements counting backwards from <paramref name="end"/> as <see cref="TaikoDifficultyHitObject.StaminaCheese"/>.
|
||||
/// </summary>
|
||||
private void markObjectsAsCheese(int end, int count)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
hitObjects[end - i].StaminaCheese = true;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,20 +1,94 @@
|
||||
// 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.Preprocessing;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single hit object in taiko difficulty calculation.
|
||||
/// </summary>
|
||||
public class TaikoDifficultyHitObject : DifficultyHitObject
|
||||
{
|
||||
public readonly bool HasTypeChange;
|
||||
/// <summary>
|
||||
/// The rhythm required to hit this hit object.
|
||||
/// </summary>
|
||||
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
|
||||
|
||||
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate)
|
||||
/// <summary>
|
||||
/// The hit type of this hit object.
|
||||
/// </summary>
|
||||
public readonly HitType? HitType;
|
||||
|
||||
/// <summary>
|
||||
/// The index of the object in the beatmap.
|
||||
/// </summary>
|
||||
public readonly int ObjectIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the object should carry a penalty due to being hittable using special techniques
|
||||
/// making it easier to do so.
|
||||
/// </summary>
|
||||
public bool StaminaCheese;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new difficulty hit object.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The gameplay <see cref="HitObject"/> associated with this difficulty object.</param>
|
||||
/// <param name="lastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="hitObject"/>.</param>
|
||||
/// <param name="lastLastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="lastObject"/>.</param>
|
||||
/// <param name="clockRate">The rate of the gameplay clock. Modified by speed-changing mods.</param>
|
||||
/// <param name="objectIndex">The index of the object in the beatmap.</param>
|
||||
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, int objectIndex)
|
||||
: base(hitObject, lastObject, clockRate)
|
||||
{
|
||||
HasTypeChange = (lastObject as Hit)?.Type != (hitObject as Hit)?.Type;
|
||||
var currentHit = hitObject as Hit;
|
||||
|
||||
Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
|
||||
HitType = currentHit?.Type;
|
||||
|
||||
ObjectIndex = objectIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of most common rhythm changes in taiko maps.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The general guidelines for the values are:
|
||||
/// <list type="bullet">
|
||||
/// <item>rhythm changes with ratio closer to 1 (that are <i>not</i> 1) are harder to play,</item>
|
||||
/// <item>speeding up is <i>generally</i> harder than slowing down (with exceptions of rhythm changes requiring a hand switch).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms =
|
||||
{
|
||||
new TaikoDifficultyHitObjectRhythm(1, 1, 0.0),
|
||||
new TaikoDifficultyHitObjectRhythm(2, 1, 0.3),
|
||||
new TaikoDifficultyHitObjectRhythm(1, 2, 0.5),
|
||||
new TaikoDifficultyHitObjectRhythm(3, 1, 0.3),
|
||||
new TaikoDifficultyHitObjectRhythm(1, 3, 0.35),
|
||||
new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style)
|
||||
new TaikoDifficultyHitObjectRhythm(2, 3, 0.4),
|
||||
new TaikoDifficultyHitObjectRhythm(5, 4, 0.5),
|
||||
new TaikoDifficultyHitObjectRhythm(4, 5, 0.7)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the closest rhythm change from <see cref="common_rhythms"/> required to hit this object.
|
||||
/// </summary>
|
||||
/// <param name="lastObject">The gameplay <see cref="HitObject"/> preceding this one.</param>
|
||||
/// <param name="lastLastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="lastObject"/>.</param>
|
||||
/// <param name="clockRate">The rate of the gameplay clock.</param>
|
||||
private TaikoDifficultyHitObjectRhythm getClosestRhythm(HitObject lastObject, HitObject lastLastObject, double clockRate)
|
||||
{
|
||||
double prevLength = (lastObject.StartTime - lastLastObject.StartTime) / clockRate;
|
||||
double ratio = DeltaTime / prevLength;
|
||||
|
||||
return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,35 @@
|
||||
// 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.
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a rhythm change in a taiko map.
|
||||
/// </summary>
|
||||
public class TaikoDifficultyHitObjectRhythm
|
||||
{
|
||||
/// <summary>
|
||||
/// The difficulty multiplier associated with this rhythm change.
|
||||
/// </summary>
|
||||
public readonly double Difficulty;
|
||||
|
||||
/// <summary>
|
||||
/// The ratio of current <see cref="osu.Game.Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/>
|
||||
/// to previous <see cref="osu.Game.Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/> for the rhythm change.
|
||||
/// A <see cref="Ratio"/> above 1 indicates a slow-down; a <see cref="Ratio"/> below 1 indicates a speed-up.
|
||||
/// </summary>
|
||||
public readonly double Ratio;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an object representing a rhythm change.
|
||||
/// </summary>
|
||||
/// <param name="numerator">The numerator for <see cref="Ratio"/>.</param>
|
||||
/// <param name="denominator">The denominator for <see cref="Ratio"/></param>
|
||||
/// <param name="difficulty">The difficulty multiplier associated with this rhythm change.</param>
|
||||
public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty)
|
||||
{
|
||||
Ratio = numerator / (double)denominator;
|
||||
Difficulty = difficulty;
|
||||
}
|
||||
}
|
||||
}
|
135
osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs
Normal file
135
osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs
Normal file
@ -0,0 +1,135 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the colour coefficient of taiko difficulty.
|
||||
/// </summary>
|
||||
public class Colour : Skill
|
||||
{
|
||||
protected override double SkillMultiplier => 1;
|
||||
protected override double StrainDecayBase => 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of entries to keep in <see cref="monoHistory"/>.
|
||||
/// </summary>
|
||||
private const int mono_history_max_length = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Queue with the lengths of the last <see cref="mono_history_max_length"/> most recent mono (single-colour) patterns,
|
||||
/// with the most recent value at the end of the queue.
|
||||
/// </summary>
|
||||
private readonly LimitedCapacityQueue<int> monoHistory = new LimitedCapacityQueue<int>(mono_history_max_length);
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="HitType"/> of the last object hit before the one being considered.
|
||||
/// </summary>
|
||||
private HitType? previousHitType;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the current mono pattern.
|
||||
/// </summary>
|
||||
private int currentMonoLength;
|
||||
|
||||
protected override double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
// changing from/to a drum roll or a swell does not constitute a colour change.
|
||||
// hits spaced more than a second apart are also exempt from colour strain.
|
||||
if (!(current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000))
|
||||
{
|
||||
monoHistory.Clear();
|
||||
|
||||
var currentHit = current.BaseObject as Hit;
|
||||
currentMonoLength = currentHit != null ? 1 : 0;
|
||||
previousHitType = currentHit?.Type;
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
var taikoCurrent = (TaikoDifficultyHitObject)current;
|
||||
|
||||
double objectStrain = 0.0;
|
||||
|
||||
if (previousHitType != null && taikoCurrent.HitType != previousHitType)
|
||||
{
|
||||
// The colour has changed.
|
||||
objectStrain = 1.0;
|
||||
|
||||
if (monoHistory.Count < 2)
|
||||
{
|
||||
// There needs to be at least two streaks to determine a strain.
|
||||
objectStrain = 0.0;
|
||||
}
|
||||
else if ((monoHistory[^1] + currentMonoLength) % 2 == 0)
|
||||
{
|
||||
// The last streak in the history is guaranteed to be a different type to the current streak.
|
||||
// If the total number of notes in the two streaks is even, nullify this object's strain.
|
||||
objectStrain = 0.0;
|
||||
}
|
||||
|
||||
objectStrain *= repetitionPenalties();
|
||||
currentMonoLength = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentMonoLength += 1;
|
||||
}
|
||||
|
||||
previousHitType = taikoCurrent.HitType;
|
||||
return objectStrain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The penalty to apply due to the length of repetition in colour streaks.
|
||||
/// </summary>
|
||||
private double repetitionPenalties()
|
||||
{
|
||||
const int most_recent_patterns_to_compare = 2;
|
||||
double penalty = 1.0;
|
||||
|
||||
monoHistory.Enqueue(currentMonoLength);
|
||||
|
||||
for (int start = monoHistory.Count - most_recent_patterns_to_compare - 1; start >= 0; start--)
|
||||
{
|
||||
if (!isSamePattern(start, most_recent_patterns_to_compare))
|
||||
continue;
|
||||
|
||||
int notesSince = 0;
|
||||
for (int i = start; i < monoHistory.Count; i++) notesSince += monoHistory[i];
|
||||
penalty *= repetitionPenalty(notesSince);
|
||||
break;
|
||||
}
|
||||
|
||||
return penalty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the last <paramref name="mostRecentPatternsToCompare"/> patterns have repeated in the history
|
||||
/// of single-colour note sequences, starting from <paramref name="start"/>.
|
||||
/// </summary>
|
||||
private bool isSamePattern(int start, int mostRecentPatternsToCompare)
|
||||
{
|
||||
for (int i = 0; i < mostRecentPatternsToCompare; i++)
|
||||
{
|
||||
if (monoHistory[start + i] != monoHistory[monoHistory.Count - mostRecentPatternsToCompare + i])
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the strain penalty for a colour pattern repetition.
|
||||
/// </summary>
|
||||
/// <param name="notesSince">The number of notes since the last repetition of the pattern.</param>
|
||||
private double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
|
||||
}
|
||||
}
|
167
osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
Normal file
167
osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
Normal file
@ -0,0 +1,167 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the rhythm coefficient of taiko difficulty.
|
||||
/// </summary>
|
||||
public class Rhythm : Skill
|
||||
{
|
||||
protected override double SkillMultiplier => 10;
|
||||
protected override double StrainDecayBase => 0;
|
||||
|
||||
/// <summary>
|
||||
/// The note-based decay for rhythm strain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="StrainDecayBase"/> is not used here, as it's time- and not note-based.
|
||||
/// </remarks>
|
||||
private const double strain_decay = 0.96;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of entries in <see cref="rhythmHistory"/>.
|
||||
/// </summary>
|
||||
private const int rhythm_history_max_length = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the last <see cref="rhythm_history_max_length"/> changes in note sequence rhythms.
|
||||
/// </summary>
|
||||
private readonly LimitedCapacityQueue<TaikoDifficultyHitObject> rhythmHistory = new LimitedCapacityQueue<TaikoDifficultyHitObject>(rhythm_history_max_length);
|
||||
|
||||
/// <summary>
|
||||
/// Contains the rolling rhythm strain.
|
||||
/// Used to apply per-note decay.
|
||||
/// </summary>
|
||||
private double currentStrain;
|
||||
|
||||
/// <summary>
|
||||
/// Number of notes since the last rhythm change has taken place.
|
||||
/// </summary>
|
||||
private int notesSinceRhythmChange;
|
||||
|
||||
protected override double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
// drum rolls and swells are exempt.
|
||||
if (!(current.BaseObject is Hit))
|
||||
{
|
||||
resetRhythmAndStrain();
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
currentStrain *= strain_decay;
|
||||
|
||||
TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
|
||||
notesSinceRhythmChange += 1;
|
||||
|
||||
// rhythm difficulty zero (due to rhythm not changing) => no rhythm strain.
|
||||
if (hitObject.Rhythm.Difficulty == 0.0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double objectStrain = hitObject.Rhythm.Difficulty;
|
||||
|
||||
objectStrain *= repetitionPenalties(hitObject);
|
||||
objectStrain *= patternLengthPenalty(notesSinceRhythmChange);
|
||||
objectStrain *= speedPenalty(hitObject.DeltaTime);
|
||||
|
||||
// careful - needs to be done here since calls above read this value
|
||||
notesSinceRhythmChange = 0;
|
||||
|
||||
currentStrain += objectStrain;
|
||||
return currentStrain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a penalty to apply to the current hit object caused by repeating rhythm changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Repetitions of more recent patterns are associated with a higher penalty.
|
||||
/// </remarks>
|
||||
/// <param name="hitObject">The current hit object being considered.</param>
|
||||
private double repetitionPenalties(TaikoDifficultyHitObject hitObject)
|
||||
{
|
||||
double penalty = 1;
|
||||
|
||||
rhythmHistory.Enqueue(hitObject);
|
||||
|
||||
for (int mostRecentPatternsToCompare = 2; mostRecentPatternsToCompare <= rhythm_history_max_length / 2; mostRecentPatternsToCompare++)
|
||||
{
|
||||
for (int start = rhythmHistory.Count - mostRecentPatternsToCompare - 1; start >= 0; start--)
|
||||
{
|
||||
if (!samePattern(start, mostRecentPatternsToCompare))
|
||||
continue;
|
||||
|
||||
int notesSince = hitObject.ObjectIndex - rhythmHistory[start].ObjectIndex;
|
||||
penalty *= repetitionPenalty(notesSince);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return penalty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the rhythm change pattern starting at <paramref name="start"/> is a repeat of any of the
|
||||
/// <paramref name="mostRecentPatternsToCompare"/>.
|
||||
/// </summary>
|
||||
private bool samePattern(int start, int mostRecentPatternsToCompare)
|
||||
{
|
||||
for (int i = 0; i < mostRecentPatternsToCompare; i++)
|
||||
{
|
||||
if (rhythmHistory[start + i].Rhythm != rhythmHistory[rhythmHistory.Count - mostRecentPatternsToCompare + i].Rhythm)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a single rhythm repetition penalty.
|
||||
/// </summary>
|
||||
/// <param name="notesSince">Number of notes since the last repetition of a rhythm change.</param>
|
||||
private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a penalty based on the number of notes since the last rhythm change.
|
||||
/// Both rare and frequent rhythm changes are penalised.
|
||||
/// </summary>
|
||||
/// <param name="patternLength">Number of notes since the last rhythm change.</param>
|
||||
private static double patternLengthPenalty(int patternLength)
|
||||
{
|
||||
double shortPatternPenalty = Math.Min(0.15 * patternLength, 1.0);
|
||||
double longPatternPenalty = Math.Clamp(2.5 - 0.15 * patternLength, 0.0, 1.0);
|
||||
return Math.Min(shortPatternPenalty, longPatternPenalty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a penalty for objects that do not require alternating hands.
|
||||
/// </summary>
|
||||
/// <param name="deltaTime">Time (in milliseconds) since the last hit object.</param>
|
||||
private double speedPenalty(double deltaTime)
|
||||
{
|
||||
if (deltaTime < 80) return 1;
|
||||
if (deltaTime < 210) return Math.Max(0, 1.4 - 0.005 * deltaTime);
|
||||
|
||||
resetRhythmAndStrain();
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the rolling strain value and <see cref="notesSinceRhythmChange"/> counter.
|
||||
/// </summary>
|
||||
private void resetRhythmAndStrain()
|
||||
{
|
||||
currentStrain = 0.0;
|
||||
notesSinceRhythmChange = 0;
|
||||
}
|
||||
}
|
||||
}
|
113
osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
Normal file
113
osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
Normal file
@ -0,0 +1,113 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Linq;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the stamina coefficient of taiko difficulty.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reference play style chosen uses two hands, with full alternating (the hand changes after every hit).
|
||||
/// </remarks>
|
||||
public class Stamina : Skill
|
||||
{
|
||||
protected override double SkillMultiplier => 1;
|
||||
protected override double StrainDecayBase => 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of entries to keep in <see cref="notePairDurationHistory"/>.
|
||||
/// </summary>
|
||||
private const int max_history_length = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The index of the hand this <see cref="Stamina"/> instance is associated with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value of 0 indicates the left hand (full alternating gameplay starting with left hand is assumed).
|
||||
/// This naturally translates onto index offsets of the objects in the map.
|
||||
/// </remarks>
|
||||
private readonly int hand;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the last <see cref="max_history_length"/> durations between notes hit with the hand indicated by <see cref="hand"/>.
|
||||
/// </summary>
|
||||
private readonly LimitedCapacityQueue<double> notePairDurationHistory = new LimitedCapacityQueue<double>(max_history_length);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the <see cref="DifficultyHitObject.DeltaTime"/> of the last object that was hit by the <i>other</i> hand.
|
||||
/// </summary>
|
||||
private double offhandObjectDuration = double.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Stamina"/> skill.
|
||||
/// </summary>
|
||||
/// <param name="rightHand">Whether this instance is performing calculations for the right hand.</param>
|
||||
public Stamina(bool rightHand)
|
||||
{
|
||||
hand = rightHand ? 1 : 0;
|
||||
}
|
||||
|
||||
protected override double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
if (!(current.BaseObject is Hit))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
|
||||
|
||||
if (hitObject.ObjectIndex % 2 == hand)
|
||||
{
|
||||
double objectStrain = 1;
|
||||
|
||||
if (hitObject.ObjectIndex == 1)
|
||||
return 1;
|
||||
|
||||
notePairDurationHistory.Enqueue(hitObject.DeltaTime + offhandObjectDuration);
|
||||
|
||||
double shortestRecentNote = notePairDurationHistory.Min();
|
||||
objectStrain += speedBonus(shortestRecentNote);
|
||||
|
||||
if (hitObject.StaminaCheese)
|
||||
objectStrain *= cheesePenalty(hitObject.DeltaTime + offhandObjectDuration);
|
||||
|
||||
return objectStrain;
|
||||
}
|
||||
|
||||
offhandObjectDuration = hitObject.DeltaTime;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a penalty for hit objects marked with <see cref="TaikoDifficultyHitObject.StaminaCheese"/>.
|
||||
/// </summary>
|
||||
/// <param name="notePairDuration">The duration between the current and previous note hit using the hand indicated by <see cref="hand"/>.</param>
|
||||
private double cheesePenalty(double notePairDuration)
|
||||
{
|
||||
if (notePairDuration > 125) return 1;
|
||||
if (notePairDuration < 100) return 0.6;
|
||||
|
||||
return 0.6 + (notePairDuration - 100) * 0.016;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a speed bonus dependent on the time since the last hit performed using this hand.
|
||||
/// </summary>
|
||||
/// <param name="notePairDuration">The duration between the current and previous note hit using the hand indicated by <see cref="hand"/>.</param>
|
||||
private double speedBonus(double notePairDuration)
|
||||
{
|
||||
if (notePairDuration >= 200) return 0;
|
||||
|
||||
double bonus = 200 - notePairDuration;
|
||||
bonus *= bonus;
|
||||
return bonus / 100000;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
public class Strain : Skill
|
||||
{
|
||||
private const double rhythm_change_base_threshold = 0.2;
|
||||
private const double rhythm_change_base = 2.0;
|
||||
|
||||
protected override double SkillMultiplier => 1;
|
||||
protected override double StrainDecayBase => 0.3;
|
||||
|
||||
private ColourSwitch lastColourSwitch = ColourSwitch.None;
|
||||
|
||||
private int sameColourCount = 1;
|
||||
|
||||
protected override double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
double addition = 1;
|
||||
|
||||
// We get an extra addition if we are not a slider or spinner
|
||||
if (current.LastObject is Hit && current.BaseObject is Hit && current.BaseObject.StartTime - current.LastObject.StartTime < 1000)
|
||||
{
|
||||
if (hasColourChange(current))
|
||||
addition += 0.75;
|
||||
|
||||
if (hasRhythmChange(current))
|
||||
addition += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastColourSwitch = ColourSwitch.None;
|
||||
sameColourCount = 1;
|
||||
}
|
||||
|
||||
double additionFactor = 1;
|
||||
|
||||
// Scale the addition factor linearly from 0.4 to 1 for DeltaTime from 0 to 50
|
||||
if (current.DeltaTime < 50)
|
||||
additionFactor = 0.4 + 0.6 * current.DeltaTime / 50;
|
||||
|
||||
return additionFactor * addition;
|
||||
}
|
||||
|
||||
private bool hasRhythmChange(DifficultyHitObject current)
|
||||
{
|
||||
// We don't want a division by zero if some random mapper decides to put two HitObjects at the same time.
|
||||
if (current.DeltaTime == 0 || Previous.Count == 0 || Previous[0].DeltaTime == 0)
|
||||
return false;
|
||||
|
||||
double timeElapsedRatio = Math.Max(Previous[0].DeltaTime / current.DeltaTime, current.DeltaTime / Previous[0].DeltaTime);
|
||||
|
||||
if (timeElapsedRatio >= 8)
|
||||
return false;
|
||||
|
||||
double difference = Math.Log(timeElapsedRatio, rhythm_change_base) % 1.0;
|
||||
|
||||
return difference > rhythm_change_base_threshold && difference < 1 - rhythm_change_base_threshold;
|
||||
}
|
||||
|
||||
private bool hasColourChange(DifficultyHitObject current)
|
||||
{
|
||||
var taikoCurrent = (TaikoDifficultyHitObject)current;
|
||||
|
||||
if (!taikoCurrent.HasTypeChange)
|
||||
{
|
||||
sameColourCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
var oldColourSwitch = lastColourSwitch;
|
||||
var newColourSwitch = sameColourCount % 2 == 0 ? ColourSwitch.Even : ColourSwitch.Odd;
|
||||
|
||||
lastColourSwitch = newColourSwitch;
|
||||
sameColourCount = 1;
|
||||
|
||||
// We only want a bonus if the parity of the color switch changes
|
||||
return oldColourSwitch != ColourSwitch.None && oldColourSwitch != newColourSwitch;
|
||||
}
|
||||
|
||||
private enum ColourSwitch
|
||||
{
|
||||
None,
|
||||
Even,
|
||||
Odd
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
public class TaikoDifficultyAttributes : DifficultyAttributes
|
||||
{
|
||||
public double StaminaStrain;
|
||||
public double RhythmStrain;
|
||||
public double ColourStrain;
|
||||
public double ApproachRate;
|
||||
public double GreatHitWindow;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Beatmaps;
|
||||
@ -19,39 +20,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
public class TaikoDifficultyCalculator : DifficultyCalculator
|
||||
{
|
||||
private const double star_scaling_factor = 0.04125;
|
||||
private const double rhythm_skill_multiplier = 0.014;
|
||||
private const double colour_skill_multiplier = 0.01;
|
||||
private const double stamina_skill_multiplier = 0.02;
|
||||
|
||||
public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
|
||||
: base(ruleset, beatmap)
|
||||
{
|
||||
}
|
||||
|
||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||
protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[]
|
||||
{
|
||||
if (beatmap.HitObjects.Count == 0)
|
||||
return new TaikoDifficultyAttributes { Mods = mods, Skills = skills };
|
||||
|
||||
HitWindows hitWindows = new TaikoHitWindows();
|
||||
hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty);
|
||||
|
||||
return new TaikoDifficultyAttributes
|
||||
{
|
||||
StarRating = skills.Single().DifficultyValue() * star_scaling_factor,
|
||||
Mods = mods,
|
||||
// Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future
|
||||
GreatHitWindow = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate,
|
||||
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
||||
Skills = skills
|
||||
};
|
||||
}
|
||||
|
||||
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
|
||||
{
|
||||
for (int i = 1; i < beatmap.HitObjects.Count; i++)
|
||||
yield return new TaikoDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate);
|
||||
}
|
||||
|
||||
protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { new Strain() };
|
||||
new Colour(),
|
||||
new Rhythm(),
|
||||
new Stamina(true),
|
||||
new Stamina(false),
|
||||
};
|
||||
|
||||
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
|
||||
{
|
||||
@ -60,5 +44,124 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
new TaikoModEasy(),
|
||||
new TaikoModHardRock(),
|
||||
};
|
||||
|
||||
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
|
||||
{
|
||||
List<TaikoDifficultyHitObject> taikoDifficultyHitObjects = new List<TaikoDifficultyHitObject>();
|
||||
|
||||
for (int i = 2; i < beatmap.HitObjects.Count; i++)
|
||||
{
|
||||
taikoDifficultyHitObjects.Add(
|
||||
new TaikoDifficultyHitObject(
|
||||
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, i
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
new StaminaCheeseDetector(taikoDifficultyHitObjects).FindCheese();
|
||||
return taikoDifficultyHitObjects;
|
||||
}
|
||||
|
||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||
{
|
||||
if (beatmap.HitObjects.Count == 0)
|
||||
return new TaikoDifficultyAttributes { Mods = mods, Skills = skills };
|
||||
|
||||
var colour = (Colour)skills[0];
|
||||
var rhythm = (Rhythm)skills[1];
|
||||
var staminaRight = (Stamina)skills[2];
|
||||
var staminaLeft = (Stamina)skills[3];
|
||||
|
||||
double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
|
||||
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
|
||||
double staminaRating = (staminaRight.DifficultyValue() + staminaLeft.DifficultyValue()) * stamina_skill_multiplier;
|
||||
|
||||
double staminaPenalty = simpleColourPenalty(staminaRating, colourRating);
|
||||
staminaRating *= staminaPenalty;
|
||||
|
||||
double combinedRating = locallyCombinedDifficulty(colour, rhythm, staminaRight, staminaLeft, staminaPenalty);
|
||||
double separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating);
|
||||
double starRating = 1.4 * separatedRating + 0.5 * combinedRating;
|
||||
starRating = rescale(starRating);
|
||||
|
||||
HitWindows hitWindows = new TaikoHitWindows();
|
||||
hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty);
|
||||
|
||||
return new TaikoDifficultyAttributes
|
||||
{
|
||||
StarRating = starRating,
|
||||
Mods = mods,
|
||||
StaminaStrain = staminaRating,
|
||||
RhythmStrain = rhythmRating,
|
||||
ColourStrain = colourRating,
|
||||
// Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future
|
||||
GreatHitWindow = (int)hitWindows.WindowFor(HitResult.Great) / clockRate,
|
||||
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
||||
Skills = skills
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the penalty for the stamina skill for maps with low colour difficulty.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Some maps (especially converts) can be easy to read despite a high note density.
|
||||
/// This penalty aims to reduce the star rating of such maps by factoring in colour difficulty to the stamina skill.
|
||||
/// </remarks>
|
||||
private double simpleColourPenalty(double staminaDifficulty, double colorDifficulty)
|
||||
{
|
||||
if (colorDifficulty <= 0) return 0.79 - 0.25;
|
||||
|
||||
return 0.79 - Math.Atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <i>p</i>-norm of an <i>n</i>-dimensional vector.
|
||||
/// </summary>
|
||||
/// <param name="p">The value of <i>p</i> to calculate the norm for.</param>
|
||||
/// <param name="values">The coefficients of the vector.</param>
|
||||
private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the partial star rating of the beatmap, calculated using peak strains from all sections of the map.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
private double locallyCombinedDifficulty(Colour colour, Rhythm rhythm, Stamina staminaRight, Stamina staminaLeft, double staminaPenalty)
|
||||
{
|
||||
List<double> peaks = new List<double>();
|
||||
|
||||
for (int i = 0; i < colour.StrainPeaks.Count; i++)
|
||||
{
|
||||
double colourPeak = colour.StrainPeaks[i] * colour_skill_multiplier;
|
||||
double rhythmPeak = rhythm.StrainPeaks[i] * rhythm_skill_multiplier;
|
||||
double staminaPeak = (staminaRight.StrainPeaks[i] + staminaLeft.StrainPeaks[i]) * stamina_skill_multiplier * staminaPenalty;
|
||||
peaks.Add(norm(2, colourPeak, rhythmPeak, staminaPeak));
|
||||
}
|
||||
|
||||
double difficulty = 0;
|
||||
double weight = 1;
|
||||
|
||||
foreach (double strain in peaks.OrderByDescending(d => d))
|
||||
{
|
||||
difficulty += strain * weight;
|
||||
weight *= 0.9;
|
||||
}
|
||||
|
||||
return difficulty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a final re-scaling of the star rating to bring maps with recorded full combos below 9.5 stars.
|
||||
/// </summary>
|
||||
/// <param name="sr">The raw star rating value before re-scaling.</param>
|
||||
private double rescale(double sr)
|
||||
{
|
||||
if (sr < 0) return sr;
|
||||
|
||||
return 10.43 * Math.Log(sr / 8 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -78,10 +78,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
// Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available
|
||||
strainValue *= Math.Pow(0.985, countMiss);
|
||||
|
||||
// Combo scaling
|
||||
if (Attributes.MaxCombo > 0)
|
||||
strainValue *= Math.Min(Math.Pow(Score.MaxCombo, 0.5) / Math.Pow(Attributes.MaxCombo, 0.5), 1.0);
|
||||
|
||||
if (mods.Any(m => m is ModHidden))
|
||||
strainValue *= 1.025;
|
||||
|
||||
|
119
osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs
Normal file
119
osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs
Normal file
@ -0,0 +1,119 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
|
||||
namespace osu.Game.Tests.NonVisual
|
||||
{
|
||||
[TestFixture]
|
||||
public class LimitedCapacityQueueTest
|
||||
{
|
||||
private const int capacity = 3;
|
||||
|
||||
private LimitedCapacityQueue<int> queue;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
queue = new LimitedCapacityQueue<int>(capacity);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEmptyQueue()
|
||||
{
|
||||
Assert.AreEqual(0, queue.Count);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = queue[0]);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => _ = queue.Dequeue());
|
||||
|
||||
int count = 0;
|
||||
foreach (var _ in queue)
|
||||
count++;
|
||||
|
||||
Assert.AreEqual(0, count);
|
||||
}
|
||||
|
||||
[TestCase(1)]
|
||||
[TestCase(2)]
|
||||
[TestCase(3)]
|
||||
public void TestBelowCapacity(int count)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
queue.Enqueue(i);
|
||||
|
||||
Assert.AreEqual(count, queue.Count);
|
||||
|
||||
for (int i = 0; i < count; ++i)
|
||||
Assert.AreEqual(i, queue[i]);
|
||||
|
||||
int j = 0;
|
||||
foreach (var item in queue)
|
||||
Assert.AreEqual(j++, item);
|
||||
|
||||
for (int i = queue.Count; i < queue.Count + capacity; i++)
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = queue[i]);
|
||||
}
|
||||
|
||||
[TestCase(4)]
|
||||
[TestCase(5)]
|
||||
[TestCase(6)]
|
||||
public void TestEnqueueAtFullCapacity(int count)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
queue.Enqueue(i);
|
||||
|
||||
Assert.AreEqual(capacity, queue.Count);
|
||||
|
||||
for (int i = 0; i < queue.Count; ++i)
|
||||
Assert.AreEqual(count - capacity + i, queue[i]);
|
||||
|
||||
int j = count - capacity;
|
||||
foreach (var item in queue)
|
||||
Assert.AreEqual(j++, item);
|
||||
|
||||
for (int i = queue.Count; i < queue.Count + capacity; i++)
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = queue[i]);
|
||||
}
|
||||
|
||||
[TestCase(4)]
|
||||
[TestCase(5)]
|
||||
[TestCase(6)]
|
||||
public void TestDequeueAtFullCapacity(int count)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
queue.Enqueue(i);
|
||||
|
||||
for (int i = 0; i < capacity; ++i)
|
||||
{
|
||||
Assert.AreEqual(count - capacity + i, queue.Dequeue());
|
||||
Assert.AreEqual(2 - i, queue.Count);
|
||||
}
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => queue.Dequeue());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClearQueue()
|
||||
{
|
||||
queue.Enqueue(3);
|
||||
queue.Enqueue(5);
|
||||
Assert.AreEqual(2, queue.Count);
|
||||
|
||||
queue.Clear();
|
||||
Assert.AreEqual(0, queue.Count);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = queue[0]);
|
||||
|
||||
queue.Enqueue(7);
|
||||
Assert.AreEqual(1, queue.Count);
|
||||
Assert.AreEqual(7, queue[0]);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = queue[1]);
|
||||
|
||||
queue.Enqueue(9);
|
||||
Assert.AreEqual(2, queue.Count);
|
||||
Assert.AreEqual(9, queue[1]);
|
||||
}
|
||||
}
|
||||
}
|
123
osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs
Normal file
123
osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs
Normal file
@ -0,0 +1,123 @@
|
||||
// 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;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace osu.Game.Rulesets.Difficulty.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// An indexed queue with limited capacity.
|
||||
/// Respects first-in-first-out insertion order.
|
||||
/// </summary>
|
||||
public class LimitedCapacityQueue<T> : IEnumerable<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The number of elements in the queue.
|
||||
/// </summary>
|
||||
public int Count { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the queue is full (adding any new items will cause removing existing ones).
|
||||
/// </summary>
|
||||
public bool Full => Count == capacity;
|
||||
|
||||
private readonly T[] array;
|
||||
private readonly int capacity;
|
||||
|
||||
// Markers tracking the queue's first and last element.
|
||||
private int start, end;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new <see cref="LimitedCapacityQueue{T}"/>
|
||||
/// </summary>
|
||||
/// <param name="capacity">The number of items the queue can hold.</param>
|
||||
public LimitedCapacityQueue(int capacity)
|
||||
{
|
||||
if (capacity < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
|
||||
this.capacity = capacity;
|
||||
array = new T[capacity];
|
||||
Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all elements from the <see cref="LimitedCapacityQueue{T}"/>.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
start = 0;
|
||||
end = -1;
|
||||
Count = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the front of the <see cref="LimitedCapacityQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <returns>The item removed from the front of the queue.</returns>
|
||||
public T Dequeue()
|
||||
{
|
||||
if (Count == 0)
|
||||
throw new InvalidOperationException("Queue is empty.");
|
||||
|
||||
var result = array[start];
|
||||
start = (start + 1) % capacity;
|
||||
Count--;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an item to the back of the <see cref="LimitedCapacityQueue{T}"/>.
|
||||
/// If the queue is holding <see cref="Count"/> elements at the point of addition,
|
||||
/// the item at the front of the queue will be removed.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to be added to the back of the queue.</param>
|
||||
public void Enqueue(T item)
|
||||
{
|
||||
end = (end + 1) % capacity;
|
||||
if (Count == capacity)
|
||||
start = (start + 1) % capacity;
|
||||
else
|
||||
Count++;
|
||||
array[end] = item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the item at the given index in the queue.
|
||||
/// </summary>
|
||||
/// <param name="index">
|
||||
/// The index of the item to retrieve.
|
||||
/// The item with index 0 is at the front of the queue
|
||||
/// (it was added the earliest).
|
||||
/// </param>
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
if (index < 0 || index >= Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
return array[(start + index) % capacity];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the queue from its start to its end.
|
||||
/// </summary>
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
{
|
||||
if (Count == 0)
|
||||
yield break;
|
||||
|
||||
for (int i = 0; i < Count; i++)
|
||||
yield return array[(start + i) % capacity];
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user