1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 03:15:45 +08:00

Merge branch 'master' into better-skin-hashing

This commit is contained in:
Dean Herbert 2020-09-16 00:25:33 +09:00 committed by GitHub
commit 53f9381ad9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1609 additions and 389 deletions

View File

@ -52,6 +52,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2020.910.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2020.911.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
{ {
base.InitialiseDefaults(); base.InitialiseDefaults();
Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 1); Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5);
Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
} }

View File

@ -34,7 +34,8 @@ namespace osu.Game.Rulesets.Mania
new SettingsSlider<double, TimeSlider> new SettingsSlider<double, TimeSlider>
{ {
LabelText = "Scroll speed", LabelText = "Scroll speed",
Bindable = config.GetBindable<double>(ManiaRulesetSetting.ScrollTime) Bindable = config.GetBindable<double>(ManiaRulesetSetting.ScrollTime),
KeyboardStep = 5
}, },
}; };
} }

View File

@ -13,8 +13,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
[TestCase(2.9811338051242915d, "diffcalc-test")] [TestCase(2.2867022617692685d, "diffcalc-test")]
[TestCase(2.9811338051242915d, "diffcalc-test-strong")] [TestCase(2.2867022617692685d, "diffcalc-test-strong")]
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);

View File

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

View File

@ -1,20 +1,94 @@
// 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.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
{ {
/// <summary>
/// Represents a single hit object in taiko difficulty calculation.
/// </summary>
public class TaikoDifficultyHitObject : DifficultyHitObject 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) : 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();
} }
} }
} }

View File

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

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

View 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;
}
}
}

View 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;
}
}
}

View File

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

View File

@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
public class TaikoDifficultyAttributes : DifficultyAttributes public class TaikoDifficultyAttributes : DifficultyAttributes
{ {
public double StaminaStrain;
public double RhythmStrain;
public double ColourStrain;
public double ApproachRate;
public double GreatHitWindow; public double GreatHitWindow;
} }
} }

View File

@ -1,6 +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 System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@ -19,39 +20,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
public class TaikoDifficultyCalculator : DifficultyCalculator 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) public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
: base(ruleset, 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) new Colour(),
return new TaikoDifficultyAttributes { Mods = mods, Skills = skills }; new Rhythm(),
new Stamina(true),
HitWindows hitWindows = new TaikoHitWindows(); new Stamina(false),
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() };
protected override Mod[] DifficultyAdjustmentMods => new Mod[] protected override Mod[] DifficultyAdjustmentMods => new Mod[]
{ {
@ -60,5 +44,124 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
new TaikoModEasy(), new TaikoModEasy(),
new TaikoModHardRock(), 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);
}
} }
} }

View File

@ -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 // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available
strainValue *= Math.Pow(0.985, countMiss); 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)) if (mods.Any(m => m is ModHidden))
strainValue *= 1.025; strainValue *= 1.025;

View File

@ -23,15 +23,19 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestHitObjectAddEvent() public void TestHitObjectAddEvent()
{ {
var editorBeatmap = new EditorBeatmap(new OsuBeatmap());
HitObject addedObject = null;
editorBeatmap.HitObjectAdded += h => addedObject = h;
var hitCircle = new HitCircle(); var hitCircle = new HitCircle();
editorBeatmap.Add(hitCircle); HitObject addedObject = null;
Assert.That(addedObject, Is.EqualTo(hitCircle)); EditorBeatmap editorBeatmap = null;
AddStep("add beatmap", () =>
{
Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap());
editorBeatmap.HitObjectAdded += h => addedObject = h;
});
AddStep("add hitobject", () => editorBeatmap.Add(hitCircle));
AddAssert("received add event", () => addedObject == hitCircle);
} }
/// <summary> /// <summary>
@ -41,13 +45,15 @@ namespace osu.Game.Tests.Beatmaps
public void HitObjectRemoveEvent() public void HitObjectRemoveEvent()
{ {
var hitCircle = new HitCircle(); var hitCircle = new HitCircle();
var editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } });
HitObject removedObject = null; HitObject removedObject = null;
EditorBeatmap editorBeatmap = null;
AddStep("add beatmap", () =>
{
Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } });
editorBeatmap.HitObjectRemoved += h => removedObject = h; editorBeatmap.HitObjectRemoved += h => removedObject = h;
});
editorBeatmap.Remove(hitCircle); AddStep("remove hitobject", () => editorBeatmap.Remove(editorBeatmap.HitObjects.First()));
Assert.That(removedObject, Is.EqualTo(hitCircle)); AddAssert("received remove event", () => removedObject == hitCircle);
} }
/// <summary> /// <summary>
@ -147,6 +153,7 @@ namespace osu.Game.Tests.Beatmaps
public void TestResortWhenStartTimeChanged() public void TestResortWhenStartTimeChanged()
{ {
var hitCircle = new HitCircle { StartTime = 1000 }; var hitCircle = new HitCircle { StartTime = 1000 };
var editorBeatmap = new EditorBeatmap(new OsuBeatmap var editorBeatmap = new EditorBeatmap(new OsuBeatmap
{ {
HitObjects = HitObjects =

View 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]);
}
}
}

View File

@ -22,27 +22,16 @@ namespace osu.Game.Tests.Visual.Collections
{ {
public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene
{ {
protected override Container<Drawable> Content => content; protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private readonly Container content; private DialogOverlay dialogOverlay;
private readonly DialogOverlay dialogOverlay; private CollectionManager manager;
private readonly CollectionManager manager;
private RulesetStore rulesets; private RulesetStore rulesets;
private BeatmapManager beatmapManager; private BeatmapManager beatmapManager;
private ManageCollectionsDialog dialog; private ManageCollectionsDialog dialog;
public TestSceneManageCollectionsDialog()
{
base.Content.AddRange(new Drawable[]
{
manager = new CollectionManager(LocalStorage),
content = new Container { RelativeSizeAxes = Axes.Both },
dialogOverlay = new DialogOverlay()
});
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host) private void load(GameHost host)
{ {
@ -50,14 +39,16 @@ namespace osu.Game.Tests.Visual.Collections
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default));
beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait();
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) base.Content.AddRange(new Drawable[]
{ {
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); manager = new CollectionManager(LocalStorage),
dependencies.Cache(manager); Content,
dependencies.Cache(dialogOverlay); dialogOverlay = new DialogOverlay()
return dependencies; });
Dependencies.Cache(manager);
Dependencies.Cache(dialogOverlay);
} }
[SetUp] [SetUp]

View File

@ -1,41 +1,27 @@
// 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.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
namespace osu.Game.Tests.Visual.Editing namespace osu.Game.Tests.Visual.Editing
{ {
public class TestSceneEditorChangeStates : EditorTestScene public class TestSceneEditorChangeStates : EditorTestScene
{ {
private EditorBeatmap editorBeatmap;
protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
protected new TestEditor Editor => (TestEditor)base.Editor;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("get beatmap", () => editorBeatmap = Editor.ChildrenOfType<EditorBeatmap>().Single());
}
[Test] [Test]
public void TestSelectedObjects() public void TestSelectedObjects()
{ {
HitCircle obj = null; HitCircle obj = null;
AddStep("add hitobject", () => editorBeatmap.Add(obj = new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(obj = new HitCircle { StartTime = 1000 }));
AddStep("select hitobject", () => editorBeatmap.SelectedHitObjects.Add(obj)); AddStep("select hitobject", () => EditorBeatmap.SelectedHitObjects.Add(obj));
AddAssert("confirm 1 selected", () => editorBeatmap.SelectedHitObjects.Count == 1); AddAssert("confirm 1 selected", () => EditorBeatmap.SelectedHitObjects.Count == 1);
AddStep("deselect hitobject", () => editorBeatmap.SelectedHitObjects.Remove(obj)); AddStep("deselect hitobject", () => EditorBeatmap.SelectedHitObjects.Remove(obj));
AddAssert("confirm 0 selected", () => editorBeatmap.SelectedHitObjects.Count == 0); AddAssert("confirm 0 selected", () => EditorBeatmap.SelectedHitObjects.Count == 0);
} }
[Test] [Test]
@ -43,11 +29,11 @@ namespace osu.Game.Tests.Visual.Editing
{ {
int hitObjectCount = 0; int hitObjectCount = 0;
AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count); AddStep("get initial state", () => hitObjectCount = EditorBeatmap.HitObjects.Count);
addUndoSteps(); addUndoSteps();
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); AddAssert("no change occurred", () => hitObjectCount == EditorBeatmap.HitObjects.Count);
AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
} }
@ -56,11 +42,11 @@ namespace osu.Game.Tests.Visual.Editing
{ {
int hitObjectCount = 0; int hitObjectCount = 0;
AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count); AddStep("get initial state", () => hitObjectCount = EditorBeatmap.HitObjects.Count);
addRedoSteps(); addRedoSteps();
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); AddAssert("no change occurred", () => hitObjectCount == EditorBeatmap.HitObjects.Count);
AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
} }
@ -73,11 +59,11 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("bind removal", () => AddStep("bind removal", () =>
{ {
editorBeatmap.HitObjectAdded += h => addedObject = h; EditorBeatmap.HitObjectAdded += h => addedObject = h;
editorBeatmap.HitObjectRemoved += h => removedObject = h; EditorBeatmap.HitObjectRemoved += h => removedObject = h;
}); });
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
AddAssert("hitobject added", () => addedObject == expectedObject); AddAssert("hitobject added", () => addedObject == expectedObject);
AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); AddAssert("unsaved changes", () => Editor.HasUnsavedChanges);
@ -95,11 +81,11 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("bind removal", () => AddStep("bind removal", () =>
{ {
editorBeatmap.HitObjectAdded += h => addedObject = h; EditorBeatmap.HitObjectAdded += h => addedObject = h;
editorBeatmap.HitObjectRemoved += h => removedObject = h; EditorBeatmap.HitObjectRemoved += h => removedObject = h;
}); });
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
addUndoSteps(); addUndoSteps();
AddStep("reset variables", () => AddStep("reset variables", () =>
@ -117,7 +103,7 @@ namespace osu.Game.Tests.Visual.Editing
[Test] [Test]
public void TestAddObjectThenSaveHasNoUnsavedChanges() public void TestAddObjectThenSaveHasNoUnsavedChanges()
{ {
AddStep("add hitobject", () => editorBeatmap.Add(new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(new HitCircle { StartTime = 1000 }));
AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); AddAssert("unsaved changes", () => Editor.HasUnsavedChanges);
AddStep("save changes", () => Editor.Save()); AddStep("save changes", () => Editor.Save());
@ -133,12 +119,12 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("bind removal", () => AddStep("bind removal", () =>
{ {
editorBeatmap.HitObjectAdded += h => addedObject = h; EditorBeatmap.HitObjectAdded += h => addedObject = h;
editorBeatmap.HitObjectRemoved += h => removedObject = h; EditorBeatmap.HitObjectRemoved += h => removedObject = h;
}); });
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
AddStep("remove object", () => editorBeatmap.Remove(expectedObject)); AddStep("remove object", () => EditorBeatmap.Remove(expectedObject));
AddStep("reset variables", () => AddStep("reset variables", () =>
{ {
addedObject = null; addedObject = null;
@ -160,12 +146,12 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("bind removal", () => AddStep("bind removal", () =>
{ {
editorBeatmap.HitObjectAdded += h => addedObject = h; EditorBeatmap.HitObjectAdded += h => addedObject = h;
editorBeatmap.HitObjectRemoved += h => removedObject = h; EditorBeatmap.HitObjectRemoved += h => removedObject = h;
}); });
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
AddStep("remove object", () => editorBeatmap.Remove(expectedObject)); AddStep("remove object", () => EditorBeatmap.Remove(expectedObject));
addUndoSteps(); addUndoSteps();
AddStep("reset variables", () => AddStep("reset variables", () =>
@ -183,18 +169,5 @@ namespace osu.Game.Tests.Visual.Editing
private void addUndoSteps() => AddStep("undo", () => Editor.Undo()); private void addUndoSteps() => AddStep("undo", () => Editor.Undo());
private void addRedoSteps() => AddStep("redo", () => Editor.Redo()); private void addRedoSteps() => AddStep("redo", () => Editor.Redo());
protected override Editor CreateEditor() => new TestEditor();
protected class TestEditor : Editor
{
public new void Undo() => base.Undo();
public new void Redo() => base.Redo();
public new void Save() => base.Save();
public new bool HasUnsavedChanges => base.HasUnsavedChanges;
}
} }
} }

View File

@ -0,0 +1,154 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Tests.Visual.Editing
{
public class TestSceneEditorClipboard : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[Test]
public void TestCutRemovesObjects()
{
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("cut hitobject", () => Editor.Cut());
AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0);
}
[TestCase(1000)]
[TestCase(2000)]
public void TestCutPaste(double newTime)
{
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("cut hitobject", () => Editor.Cut());
AddStep("move forward in time", () => EditorClock.Seek(newTime));
AddStep("paste hitobject", () => Editor.Paste());
AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1);
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime);
}
[Test]
public void TestCutPasteSlider()
{
var addedObject = new Slider
{
StartTime = 1000,
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
new PathControlPoint(new Vector2(100, 0), PathType.Bezier)
}
}
};
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("cut hitobject", () => Editor.Cut());
AddStep("paste hitobject", () => Editor.Paste());
AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1);
AddAssert("path matches", () =>
{
var path = ((Slider)EditorBeatmap.HitObjects.Single()).Path;
return path.ControlPoints.Count == 2 && path.ControlPoints.SequenceEqual(addedObject.Path.ControlPoints);
});
}
[Test]
public void TestCutPasteSpinner()
{
var addedObject = new Spinner
{
StartTime = 1000,
Duration = 5000
};
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("cut hitobject", () => Editor.Cut());
AddStep("paste hitobject", () => Editor.Paste());
AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1);
AddAssert("duration matches", () => ((Spinner)EditorBeatmap.HitObjects.Single()).Duration == 5000);
}
[Test]
public void TestCopyPaste()
{
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("copy hitobject", () => Editor.Copy());
AddStep("move forward in time", () => EditorClock.Seek(2000));
AddStep("paste hitobject", () => Editor.Paste());
AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2);
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000);
}
[Test]
public void TestCutNothing()
{
AddStep("cut hitobject", () => Editor.Cut());
AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0);
}
[Test]
public void TestCopyNothing()
{
AddStep("copy hitobject", () => Editor.Copy());
AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0);
}
[Test]
public void TestPasteNothing()
{
AddStep("paste hitobject", () => Editor.Paste());
AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0);
}
}
}

View File

@ -54,7 +54,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
Ruleset = new OsuRuleset().RulesetInfo, Ruleset = new OsuRuleset().RulesetInfo,
OnlineBeatmapID = beatmapId, OnlineBeatmapID = beatmapId,
Path = "normal.osu",
Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})",
Length = length, Length = length,
BPM = bpm, BPM = bpm,

View File

@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual.Navigation
protected TestOsuGame Game; protected TestOsuGame Game;
protected override bool UseFreshStoragePerRun => true;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host) private void load(GameHost host)
{ {

View File

@ -133,6 +133,12 @@ namespace osu.Game.Tests.Visual.Navigation
return () => imported; return () => imported;
} }
/// <summary>
/// Some tests test waiting for a particular screen twice in a row, but expect a new instance each time.
/// There's a case where they may succeed incorrectly if we don't compare against the previous instance.
/// </summary>
private IScreen lastWaitedScreen;
private void presentAndConfirm(Func<ScoreInfo> getImport, ScorePresentType type) private void presentAndConfirm(Func<ScoreInfo> getImport, ScorePresentType type)
{ {
AddStep("present score", () => Game.PresentScore(getImport(), type)); AddStep("present score", () => Game.PresentScore(getImport(), type));
@ -140,13 +146,15 @@ namespace osu.Game.Tests.Visual.Navigation
switch (type) switch (type)
{ {
case ScorePresentType.Results: case ScorePresentType.Results:
AddUntilStep("wait for results", () => Game.ScreenStack.CurrentScreen is ResultsScreen); AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen);
AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen);
AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID);
AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID);
break; break;
case ScorePresentType.Gameplay: case ScorePresentType.Gameplay:
AddUntilStep("wait for player loader", () => Game.ScreenStack.CurrentScreen is ReplayPlayerLoader); AddUntilStep("wait for player loader", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ReplayPlayerLoader);
AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen);
AddUntilStep("correct score displayed", () => ((ReplayPlayerLoader)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); AddUntilStep("correct score displayed", () => ((ReplayPlayerLoader)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID);
AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID);
break; break;

View File

@ -13,6 +13,7 @@ using osu.Game.Overlays.Mods;
using osu.Game.Overlays.Toolbar; using osu.Game.Overlays.Toolbar;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Options;
using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Beatmaps.IO;
using osuTK; using osuTK;
using osuTK.Input; using osuTK.Input;
@ -168,6 +169,29 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("Mods overlay still visible", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddAssert("Mods overlay still visible", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
} }
[Test]
public void TestBeatmapOptionsInput()
{
TestSongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show());
AddStep("Change ruleset to osu!taiko", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.PressKey(Key.Number2);
InputManager.ReleaseKey(Key.ControlLeft);
InputManager.ReleaseKey(Key.Number2);
});
AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType<ToolbarRulesetSelector>().Single().Current.Value.ID == 1);
AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible);
}
private void pushEscape() => private void pushEscape() =>
AddStep("Press escape", () => pressAndRelease(Key.Escape)); AddStep("Press escape", () => pressAndRelease(Key.Escape));
@ -193,6 +217,8 @@ namespace osu.Game.Tests.Visual.Navigation
private class TestSongSelect : PlaySongSelect private class TestSongSelect : PlaySongSelect
{ {
public ModSelectOverlay ModSelectOverlay => ModSelect; public ModSelectOverlay ModSelectOverlay => ModSelect;
public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions;
} }
} }
} }

View File

@ -839,7 +839,6 @@ namespace osu.Game.Tests.Visual.SongSelect
new BeatmapInfo new BeatmapInfo
{ {
OnlineBeatmapID = id * 10, OnlineBeatmapID = id * 10,
Path = "normal.osu",
Version = "Normal", Version = "Normal",
StarDifficulty = 2, StarDifficulty = 2,
BaseDifficulty = new BeatmapDifficulty BaseDifficulty = new BeatmapDifficulty
@ -850,7 +849,6 @@ namespace osu.Game.Tests.Visual.SongSelect
new BeatmapInfo new BeatmapInfo
{ {
OnlineBeatmapID = id * 10 + 1, OnlineBeatmapID = id * 10 + 1,
Path = "hard.osu",
Version = "Hard", Version = "Hard",
StarDifficulty = 5, StarDifficulty = 5,
BaseDifficulty = new BeatmapDifficulty BaseDifficulty = new BeatmapDifficulty
@ -861,7 +859,6 @@ namespace osu.Game.Tests.Visual.SongSelect
new BeatmapInfo new BeatmapInfo
{ {
OnlineBeatmapID = id * 10 + 2, OnlineBeatmapID = id * 10 + 2,
Path = "insane.osu",
Version = "Insane", Version = "Insane",
StarDifficulty = 6, StarDifficulty = 6,
BaseDifficulty = new BeatmapDifficulty BaseDifficulty = new BeatmapDifficulty

View File

@ -3,9 +3,8 @@
using System.ComponentModel; using System.ComponentModel;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Screens.Select.Options; using osu.Game.Screens.Select.Options;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tests.Visual.SongSelect namespace osu.Game.Tests.Visual.SongSelect
{ {
@ -16,10 +15,13 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
var overlay = new BeatmapOptionsOverlay(); var overlay = new BeatmapOptionsOverlay();
overlay.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, Color4.Purple, null, Key.Number1); var colours = new OsuColour();
overlay.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, Color4.Purple, null, Key.Number2);
overlay.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, Color4.Pink, null, Key.Number3); overlay.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, null);
overlay.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, Color4.Yellow, null, Key.Number4); overlay.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, null);
overlay.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null);
overlay.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, null);
overlay.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, null);
Add(overlay); Add(overlay);

View File

@ -23,25 +23,15 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
public class TestSceneFilterControl : OsuManualInputManagerTestScene public class TestSceneFilterControl : OsuManualInputManagerTestScene
{ {
protected override Container<Drawable> Content => content; protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private readonly Container content;
private readonly CollectionManager collectionManager; private CollectionManager collectionManager;
private RulesetStore rulesets; private RulesetStore rulesets;
private BeatmapManager beatmapManager; private BeatmapManager beatmapManager;
private FilterControl control; private FilterControl control;
public TestSceneFilterControl()
{
base.Content.AddRange(new Drawable[]
{
collectionManager = new CollectionManager(LocalStorage),
content = new Container { RelativeSizeAxes = Axes.Both }
});
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host) private void load(GameHost host)
{ {
@ -49,13 +39,14 @@ namespace osu.Game.Tests.Visual.SongSelect
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default));
beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait();
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) base.Content.AddRange(new Drawable[]
{ {
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); collectionManager = new CollectionManager(LocalStorage),
dependencies.Cache(collectionManager); Content
return dependencies; });
Dependencies.Cache(collectionManager);
} }
[SetUp] [SetUp]

View File

@ -879,7 +879,6 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
Ruleset = getRuleset(), Ruleset = getRuleset(),
OnlineBeatmapID = beatmapId, OnlineBeatmapID = beatmapId,
Path = "normal.osu",
Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})",
Length = length, Length = length,
BPM = bpm, BPM = bpm,

View 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();
}
}
}

View File

@ -0,0 +1,27 @@
// 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 Newtonsoft.Json;
using osu.Game.IO.Serialization;
using osu.Game.IO.Serialization.Converters;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit
{
public class ClipboardContent : IJsonSerializable
{
[JsonConverter(typeof(TypedListConverter<HitObject>))]
public IList<HitObject> HitObjects;
public ClipboardContent()
{
}
public ClipboardContent(EditorBeatmap editorBeatmap)
{
HitObjects = editorBeatmap.SelectedHitObjects.ToList();
}
}
}

View File

@ -271,6 +271,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
blueprint.Selected += onBlueprintSelected; blueprint.Selected += onBlueprintSelected;
blueprint.Deselected += onBlueprintDeselected; blueprint.Deselected += onBlueprintDeselected;
if (beatmap.SelectedHitObjects.Contains(hitObject))
blueprint.Select();
SelectionBlueprints.Add(blueprint); SelectionBlueprints.Add(blueprint);
} }

View File

@ -3,6 +3,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework; using osu.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -22,6 +24,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.IO.Serialization;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
@ -131,9 +134,14 @@ namespace osu.Game.Screens.Edit
updateLastSavedHash(); updateLastSavedHash();
EditorMenuBar menuBar; EditorMenuBar menuBar;
OsuMenuItem undoMenuItem; OsuMenuItem undoMenuItem;
OsuMenuItem redoMenuItem; OsuMenuItem redoMenuItem;
EditorMenuItem cutMenuItem;
EditorMenuItem copyMenuItem;
EditorMenuItem pasteMenuItem;
var fileMenuItems = new List<MenuItem> var fileMenuItems = new List<MenuItem>
{ {
new EditorMenuItem("Save", MenuItemType.Standard, Save) new EditorMenuItem("Save", MenuItemType.Standard, Save)
@ -183,7 +191,11 @@ namespace osu.Game.Screens.Edit
Items = new[] Items = new[]
{ {
undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo),
redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo) redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo),
new EditorMenuItemSpacer(),
cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut),
copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy),
pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste),
} }
} }
} }
@ -244,6 +256,16 @@ namespace osu.Game.Screens.Edit
changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
editorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) =>
{
var hasObjects = editorBeatmap.SelectedHitObjects.Count > 0;
cutMenuItem.Action.Disabled = !hasObjects;
copyMenuItem.Action.Disabled = !hasObjects;
}, true);
clipboard.BindValueChanged(content => pasteMenuItem.Action.Disabled = string.IsNullOrEmpty(content.NewValue));
menuBar.Mode.ValueChanged += onModeChanged; menuBar.Mode.ValueChanged += onModeChanged;
bottomBackground.Colour = colours.Gray2; bottomBackground.Colour = colours.Gray2;
@ -270,6 +292,18 @@ namespace osu.Game.Screens.Edit
{ {
switch (action.ActionType) switch (action.ActionType)
{ {
case PlatformActionType.Cut:
Cut();
return true;
case PlatformActionType.Copy:
Copy();
return true;
case PlatformActionType.Paste:
Paste();
return true;
case PlatformActionType.Undo: case PlatformActionType.Undo:
Undo(); Undo();
return true; return true;
@ -394,6 +428,47 @@ namespace osu.Game.Screens.Edit
this.Exit(); this.Exit();
} }
private readonly Bindable<string> clipboard = new Bindable<string>();
protected void Cut()
{
Copy();
foreach (var h in editorBeatmap.SelectedHitObjects.ToArray())
editorBeatmap.Remove(h);
}
protected void Copy()
{
if (editorBeatmap.SelectedHitObjects.Count == 0)
return;
clipboard.Value = new ClipboardContent(editorBeatmap).Serialize();
}
protected void Paste()
{
if (string.IsNullOrEmpty(clipboard.Value))
return;
var objects = clipboard.Value.Deserialize<ClipboardContent>().HitObjects;
Debug.Assert(objects.Any());
double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime);
foreach (var h in objects)
h.StartTime += timeOffset;
changeHandler.BeginChange();
editorBeatmap.SelectedHitObjects.Clear();
editorBeatmap.AddRange(objects);
editorBeatmap.SelectedHitObjects.AddRange(objects);
changeHandler.EndChange();
}
protected void Undo() => changeHandler.RestoreState(-1); protected void Undo() => changeHandler.RestoreState(-1);
protected void Redo() => changeHandler.RestoreState(1); protected void Redo() => changeHandler.RestoreState(1);

View File

@ -9,7 +9,6 @@ using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Threading;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing; using osu.Game.Beatmaps.Timing;
@ -68,41 +67,6 @@ namespace osu.Game.Screens.Edit
trackStartTime(obj); trackStartTime(obj);
} }
private readonly HashSet<HitObject> pendingUpdates = new HashSet<HitObject>();
private ScheduledDelegate scheduledUpdate;
/// <summary>
/// Updates a <see cref="HitObject"/>, invoking <see cref="HitObject.ApplyDefaults"/> and re-processing the beatmap.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to update.</param>
public void UpdateHitObject([NotNull] HitObject hitObject) => updateHitObject(hitObject, false);
private void updateHitObject([CanBeNull] HitObject hitObject, bool silent)
{
scheduledUpdate?.Cancel();
if (hitObject != null)
pendingUpdates.Add(hitObject);
scheduledUpdate = Schedule(() =>
{
beatmapProcessor?.PreProcess();
foreach (var obj in pendingUpdates)
obj.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty);
beatmapProcessor?.PostProcess();
if (!silent)
{
foreach (var obj in pendingUpdates)
HitObjectUpdated?.Invoke(obj);
}
pendingUpdates.Clear();
});
}
public BeatmapInfo BeatmapInfo public BeatmapInfo BeatmapInfo
{ {
get => PlayableBeatmap.BeatmapInfo; get => PlayableBeatmap.BeatmapInfo;
@ -125,6 +89,8 @@ namespace osu.Game.Screens.Edit
private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects;
private readonly HashSet<HitObject> pendingUpdates = new HashSet<HitObject>();
/// <summary> /// <summary>
/// Adds a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>. /// Adds a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>.
/// </summary> /// </summary>
@ -160,14 +126,27 @@ namespace osu.Game.Screens.Edit
mutableHitObjects.Insert(index, hitObject); mutableHitObjects.Insert(index, hitObject);
// must be run after any change to hitobject ordering
beatmapProcessor?.PreProcess();
processHitObject(hitObject);
beatmapProcessor?.PostProcess();
HitObjectAdded?.Invoke(hitObject); HitObjectAdded?.Invoke(hitObject);
updateHitObject(hitObject, true); }
/// <summary>
/// Updates a <see cref="HitObject"/>, invoking <see cref="HitObject.ApplyDefaults"/> and re-processing the beatmap.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to update.</param>
public void UpdateHitObject([NotNull] HitObject hitObject)
{
pendingUpdates.Add(hitObject);
} }
/// <summary> /// <summary>
/// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>. /// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>.
/// </summary> /// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to add.</param> /// <param name="hitObject">The <see cref="HitObject"/> to remove.</param>
/// <returns>True if the <see cref="HitObject"/> has been removed, false otherwise.</returns> /// <returns>True if the <see cref="HitObject"/> has been removed, false otherwise.</returns>
public bool Remove(HitObject hitObject) public bool Remove(HitObject hitObject)
{ {
@ -199,11 +178,14 @@ namespace osu.Game.Screens.Edit
var bindable = startTimeBindables[hitObject]; var bindable = startTimeBindables[hitObject];
bindable.UnbindAll(); bindable.UnbindAll();
startTimeBindables.Remove(hitObject); startTimeBindables.Remove(hitObject);
HitObjectRemoved?.Invoke(hitObject);
updateHitObject(null, true); // must be run after any change to hitobject ordering
beatmapProcessor?.PreProcess();
processHitObject(hitObject);
beatmapProcessor?.PostProcess();
HitObjectRemoved?.Invoke(hitObject);
} }
/// <summary> /// <summary>
@ -211,20 +193,33 @@ namespace osu.Game.Screens.Edit
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
var removed = HitObjects.ToList(); foreach (var h in HitObjects.ToArray())
Remove(h);
mutableHitObjects.Clear();
foreach (var b in startTimeBindables)
b.Value.UnbindAll();
startTimeBindables.Clear();
foreach (var h in removed)
HitObjectRemoved?.Invoke(h);
updateHitObject(null, true);
} }
protected override void Update()
{
base.Update();
// debounce updates as they are common and may come from input events, which can run needlessly many times per update frame.
if (pendingUpdates.Count > 0)
{
beatmapProcessor?.PreProcess();
foreach (var hitObject in pendingUpdates)
{
processHitObject(hitObject);
HitObjectUpdated?.Invoke(hitObject);
}
pendingUpdates.Clear();
beatmapProcessor?.PostProcess();
}
}
private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty);
private void trackStartTime(HitObject hitObject) private void trackStartTime(HitObject hitObject)
{ {
startTimeBindables[hitObject] = hitObject.StartTimeBindable.GetBoundCopy(); startTimeBindables[hitObject] = hitObject.StartTimeBindable.GetBoundCopy();

View File

@ -66,24 +66,9 @@ namespace osu.Game.Screens.Select
[BackgroundDependencyLoader(permitNulls: true)] [BackgroundDependencyLoader(permitNulls: true)]
private void load(OsuColour colours, IBindable<RulesetInfo> parentRuleset, OsuConfigManager config) private void load(OsuColour colours, IBindable<RulesetInfo> parentRuleset, OsuConfigManager config)
{ {
config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted);
showConverted.ValueChanged += _ => updateCriteria();
config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars);
minimumStars.ValueChanged += _ => updateCriteria();
config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars);
maximumStars.ValueChanged += _ => updateCriteria();
ruleset.BindTo(parentRuleset);
ruleset.BindValueChanged(_ => updateCriteria());
sortMode = config.GetBindable<SortMode>(OsuSetting.SongSelectSortingMode); sortMode = config.GetBindable<SortMode>(OsuSetting.SongSelectSortingMode);
groupMode = config.GetBindable<GroupMode>(OsuSetting.SongSelectGroupingMode); groupMode = config.GetBindable<GroupMode>(OsuSetting.SongSelectGroupingMode);
groupMode.BindValueChanged(_ => updateCriteria());
sortMode.BindValueChanged(_ => updateCriteria());
Children = new Drawable[] Children = new Drawable[]
{ {
new Box new Box
@ -182,6 +167,21 @@ namespace osu.Game.Screens.Select
} }
}; };
config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted);
showConverted.ValueChanged += _ => updateCriteria();
config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars);
minimumStars.ValueChanged += _ => updateCriteria();
config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars);
maximumStars.ValueChanged += _ => updateCriteria();
ruleset.BindTo(parentRuleset);
ruleset.BindValueChanged(_ => updateCriteria());
groupMode.BindValueChanged(_ => updateCriteria());
sortMode.BindValueChanged(_ => updateCriteria());
collectionDropdown.Current.ValueChanged += val => collectionDropdown.Current.ValueChanged += val =>
{ {
if (val.NewValue == null) if (val.NewValue == null)

View File

@ -12,7 +12,6 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
namespace osu.Game.Screens.Select.Options namespace osu.Game.Screens.Select.Options
@ -52,8 +51,6 @@ namespace osu.Game.Screens.Select.Options
set => secondLine.Text = value; set => secondLine.Text = value;
} }
public Key? HotKey;
protected override bool OnMouseDown(MouseDownEvent e) protected override bool OnMouseDown(MouseDownEvent e)
{ {
flash.FadeTo(0.1f, 1000, Easing.OutQuint); flash.FadeTo(0.1f, 1000, Easing.OutQuint);
@ -75,17 +72,6 @@ namespace osu.Game.Screens.Select.Options
return base.OnClick(e); return base.OnClick(e);
} }
protected override bool OnKeyDown(KeyDownEvent e)
{
if (!e.Repeat && e.Key == HotKey)
{
Click();
return true;
}
return false;
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos);
public BeatmapOptionsButton() public BeatmapOptionsButton()

View File

@ -11,6 +11,8 @@ using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input; using osuTK.Input;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Framework.Input.Events;
using System.Linq;
namespace osu.Game.Screens.Select.Options namespace osu.Game.Screens.Select.Options
{ {
@ -27,33 +29,6 @@ namespace osu.Game.Screens.Select.Options
public override bool BlockScreenWideMouse => false; public override bool BlockScreenWideMouse => false;
protected override void PopIn()
{
base.PopIn();
this.FadeIn(transition_duration, Easing.OutQuint);
if (buttonsContainer.Position.X == 1 || Alpha == 0)
buttonsContainer.MoveToX(x_position - x_movement);
holder.ScaleTo(new Vector2(1, 1), transition_duration / 2, Easing.OutQuint);
buttonsContainer.MoveToX(x_position, transition_duration, Easing.OutQuint);
buttonsContainer.TransformSpacingTo(Vector2.Zero, transition_duration, Easing.OutQuint);
}
protected override void PopOut()
{
base.PopOut();
holder.ScaleTo(new Vector2(1, 0), transition_duration / 2, Easing.InSine);
buttonsContainer.MoveToX(x_position + x_movement, transition_duration, Easing.InSine);
buttonsContainer.TransformSpacingTo(new Vector2(200f, 0f), transition_duration, Easing.InSine);
this.FadeOut(transition_duration, Easing.InQuint);
}
public BeatmapOptionsOverlay() public BeatmapOptionsOverlay()
{ {
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
@ -87,9 +62,8 @@ namespace osu.Game.Screens.Select.Options
/// <param name="secondLine">Text in the second line.</param> /// <param name="secondLine">Text in the second line.</param>
/// <param name="colour">Colour of the button.</param> /// <param name="colour">Colour of the button.</param>
/// <param name="icon">Icon of the button.</param> /// <param name="icon">Icon of the button.</param>
/// <param name="hotkey">Hotkey of the button.</param>
/// <param name="action">Binding the button does.</param> /// <param name="action">Binding the button does.</param>
public void AddButton(string firstLine, string secondLine, IconUsage icon, Color4 colour, Action action, Key? hotkey = null) public void AddButton(string firstLine, string secondLine, IconUsage icon, Color4 colour, Action action)
{ {
var button = new BeatmapOptionsButton var button = new BeatmapOptionsButton
{ {
@ -102,10 +76,58 @@ namespace osu.Game.Screens.Select.Options
Hide(); Hide();
action?.Invoke(); action?.Invoke();
}, },
HotKey = hotkey
}; };
buttonsContainer.Add(button); buttonsContainer.Add(button);
} }
protected override void PopIn()
{
base.PopIn();
this.FadeIn(transition_duration, Easing.OutQuint);
if (buttonsContainer.Position.X == 1 || Alpha == 0)
buttonsContainer.MoveToX(x_position - x_movement);
holder.ScaleTo(new Vector2(1, 1), transition_duration / 2, Easing.OutQuint);
buttonsContainer.MoveToX(x_position, transition_duration, Easing.OutQuint);
buttonsContainer.TransformSpacingTo(Vector2.Zero, transition_duration, Easing.OutQuint);
}
protected override void PopOut()
{
base.PopOut();
holder.ScaleTo(new Vector2(1, 0), transition_duration / 2, Easing.InSine);
buttonsContainer.MoveToX(x_position + x_movement, transition_duration, Easing.InSine);
buttonsContainer.TransformSpacingTo(new Vector2(200f, 0f), transition_duration, Easing.InSine);
this.FadeOut(transition_duration, Easing.InQuint);
}
protected override bool OnKeyDown(KeyDownEvent e)
{
// don't absorb control as ToolbarRulesetSelector uses control + number to navigate
if (e.ControlPressed) return false;
if (!e.Repeat && e.Key >= Key.Number1 && e.Key <= Key.Number9)
{
int requested = e.Key - Key.Number1;
// go reverse as buttonsContainer is a ReverseChildIDFillFlowContainer
BeatmapOptionsButton found = buttonsContainer.Children.ElementAtOrDefault((buttonsContainer.Children.Count - 1) - requested);
if (found != null)
{
found.Click();
return true;
}
}
return base.OnKeyDown(e);
}
} }
} }

View File

@ -36,7 +36,7 @@ namespace osu.Game.Screens.Select
{ {
ValidForResume = false; ValidForResume = false;
Edit(); Edit();
}, Key.Number4); });
((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore; ((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore;
} }

View File

@ -104,7 +104,7 @@ namespace osu.Game.Screens.Select
private MusicController music { get; set; } private MusicController music { get; set; }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections) private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog)
{ {
// initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter).
transferRulesetValue(); transferRulesetValue();
@ -275,9 +275,10 @@ namespace osu.Game.Screens.Select
Footer.AddButton(new FooterButtonRandom { Action = triggerRandom }); Footer.AddButton(new FooterButtonRandom { Action = triggerRandom });
Footer.AddButton(new FooterButtonOptions(), BeatmapOptions); Footer.AddButton(new FooterButtonOptions(), BeatmapOptions);
BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null, Key.Number1); BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show());
BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => clearScores(Beatmap.Value.BeatmapInfo), Key.Number2); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo));
BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo), Key.Number3); BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null);
BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => clearScores(Beatmap.Value.BeatmapInfo));
} }
dialogOverlay = dialog; dialogOverlay = dialog;
@ -517,6 +518,8 @@ namespace osu.Game.Screens.Select
FilterControl.Activate(); FilterControl.Activate();
ModSelect.SelectedMods.BindTo(selectedMods); ModSelect.SelectedMods.BindTo(selectedMods);
music.TrackChanged += ensureTrackLooping;
} }
private const double logo_transition = 250; private const double logo_transition = 250;
@ -568,6 +571,7 @@ namespace osu.Game.Screens.Select
BeatmapDetails.Refresh(); BeatmapDetails.Refresh();
music.CurrentTrack.Looping = true; music.CurrentTrack.Looping = true;
music.TrackChanged += ensureTrackLooping;
music.ResetTrackAdjustments(); music.ResetTrackAdjustments();
if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending) if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending)
@ -593,6 +597,7 @@ namespace osu.Game.Screens.Select
BeatmapOptions.Hide(); BeatmapOptions.Hide();
music.CurrentTrack.Looping = false; music.CurrentTrack.Looping = false;
music.TrackChanged -= ensureTrackLooping;
this.ScaleTo(1.1f, 250, Easing.InSine); this.ScaleTo(1.1f, 250, Easing.InSine);
@ -614,10 +619,14 @@ namespace osu.Game.Screens.Select
FilterControl.Deactivate(); FilterControl.Deactivate();
music.CurrentTrack.Looping = false; music.CurrentTrack.Looping = false;
music.TrackChanged -= ensureTrackLooping;
return false; return false;
} }
private void ensureTrackLooping(WorkingBeatmap beatmap, TrackChangeDirection changeDirection)
=> music.CurrentTrack.Looping = true;
public override bool OnBackButton() public override bool OnBackButton()
{ {
if (ModSelect.State.Value == Visibility.Visible) if (ModSelect.State.Value == Visibility.Visible)
@ -634,6 +643,9 @@ namespace osu.Game.Screens.Select
base.Dispose(isDisposing); base.Dispose(isDisposing);
decoupledRuleset.UnbindAll(); decoupledRuleset.UnbindAll();
if (music != null)
music.TrackChanged -= ensureTrackLooping;
} }
/// <summary> /// <summary>
@ -653,8 +665,6 @@ namespace osu.Game.Screens.Select
beatmapInfoWedge.Beatmap = beatmap; beatmapInfoWedge.Beatmap = beatmap;
BeatmapDetails.Beatmap = beatmap; BeatmapDetails.Beatmap = beatmap;
music.CurrentTrack.Looping = true;
} }
private readonly WeakReference<ITrack> lastTrack = new WeakReference<ITrack>(null); private readonly WeakReference<ITrack> lastTrack = new WeakReference<ITrack>(null);

View File

@ -15,13 +15,15 @@ namespace osu.Game.Tests.Beatmaps
{ {
public class TestBeatmap : Beatmap public class TestBeatmap : Beatmap
{ {
public TestBeatmap(RulesetInfo ruleset) public TestBeatmap(RulesetInfo ruleset, bool withHitObjects = true)
{ {
var baseBeatmap = CreateBeatmap(); var baseBeatmap = CreateBeatmap();
BeatmapInfo = baseBeatmap.BeatmapInfo; BeatmapInfo = baseBeatmap.BeatmapInfo;
ControlPointInfo = baseBeatmap.ControlPointInfo; ControlPointInfo = baseBeatmap.ControlPointInfo;
Breaks = baseBeatmap.Breaks; Breaks = baseBeatmap.Breaks;
if (withHitObjects)
HitObjects = baseBeatmap.HitObjects; HitObjects = baseBeatmap.HitObjects;
BeatmapInfo.Ruleset = ruleset; BeatmapInfo.Ruleset = ruleset;

View File

@ -14,7 +14,11 @@ namespace osu.Game.Tests.Visual
{ {
public abstract class EditorTestScene : ScreenTestScene public abstract class EditorTestScene : ScreenTestScene
{ {
protected Editor Editor { get; private set; } protected EditorBeatmap EditorBeatmap;
protected TestEditor Editor { get; private set; }
protected EditorClock EditorClock { get; private set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
@ -29,6 +33,8 @@ namespace osu.Game.Tests.Visual
AddStep("load editor", () => LoadScreen(Editor = CreateEditor())); AddStep("load editor", () => LoadScreen(Editor = CreateEditor()));
AddUntilStep("wait for editor to load", () => Editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true AddUntilStep("wait for editor to load", () => Editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true
&& Editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true); && Editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true);
AddStep("get beatmap", () => EditorBeatmap = Editor.ChildrenOfType<EditorBeatmap>().Single());
AddStep("get clock", () => EditorClock = Editor.ChildrenOfType<EditorClock>().Single());
} }
/// <summary> /// <summary>
@ -39,6 +45,23 @@ namespace osu.Game.Tests.Visual
protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset(); protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset();
protected virtual Editor CreateEditor() => new Editor(); protected virtual TestEditor CreateEditor() => new TestEditor();
protected class TestEditor : Editor
{
public new void Undo() => base.Undo();
public new void Redo() => base.Redo();
public new void Save() => base.Save();
public new void Cut() => base.Cut();
public new void Copy() => base.Copy();
public new void Paste() => base.Paste();
public new bool HasUnsavedChanges => base.HasUnsavedChanges;
}
} }
} }

View File

@ -46,7 +46,7 @@ namespace osu.Game.Tests.Visual
private Lazy<Storage> localStorage; private Lazy<Storage> localStorage;
protected Storage LocalStorage => localStorage.Value; protected Storage LocalStorage => localStorage.Value;
private readonly Lazy<DatabaseContextFactory> contextFactory; private Lazy<DatabaseContextFactory> contextFactory;
protected IAPIProvider API protected IAPIProvider API
{ {
@ -71,6 +71,17 @@ namespace osu.Game.Tests.Visual
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{ {
contextFactory = new Lazy<DatabaseContextFactory>(() =>
{
var factory = new DatabaseContextFactory(LocalStorage);
factory.ResetDatabase();
using (var usage = factory.Get())
usage.Migrate();
return factory;
});
RecycleLocalStorage();
var baseDependencies = base.CreateChildDependencies(parent); var baseDependencies = base.CreateChildDependencies(parent);
var providedRuleset = CreateRuleset(); var providedRuleset = CreateRuleset();
@ -104,19 +115,11 @@ namespace osu.Game.Tests.Visual
protected OsuTestScene() protected OsuTestScene()
{ {
RecycleLocalStorage();
contextFactory = new Lazy<DatabaseContextFactory>(() =>
{
var factory = new DatabaseContextFactory(LocalStorage);
factory.ResetDatabase();
using (var usage = factory.Get())
usage.Migrate();
return factory;
});
base.Content.Add(content = new DrawSizePreservingFillContainer()); base.Content.Add(content = new DrawSizePreservingFillContainer());
} }
protected virtual bool UseFreshStoragePerRun => false;
public virtual void RecycleLocalStorage() public virtual void RecycleLocalStorage()
{ {
if (localStorage?.IsValueCreated == true) if (localStorage?.IsValueCreated == true)
@ -131,9 +134,13 @@ namespace osu.Game.Tests.Visual
} }
} }
localStorage = new Lazy<Storage>(() => new NativeStorage(Path.Combine(RuntimeInfo.StartupDirectory, $"{GetType().Name}-{Guid.NewGuid()}"))); localStorage =
new Lazy<Storage>(() => !UseFreshStoragePerRun && host is HeadlessGameHost ? host.Storage : new NativeStorage(Path.Combine(RuntimeInfo.StartupDirectory, $"{GetType().Name}-{Guid.NewGuid()}")));
} }
[Resolved]
private GameHost host { get; set; }
[Resolved] [Resolved]
protected AudioManager Audio { get; private set; } protected AudioManager Audio { get; private set; }
@ -172,7 +179,7 @@ namespace osu.Game.Tests.Visual
if (MusicController?.TrackLoaded == true) if (MusicController?.TrackLoaded == true)
MusicController.CurrentTrack.Stop(); MusicController.CurrentTrack.Stop();
if (contextFactory.IsValueCreated) if (contextFactory?.IsValueCreated == true)
contextFactory.Value.ResetDatabase(); contextFactory.Value.ResetDatabase();
RecycleLocalStorage(); RecycleLocalStorage();

View File

@ -81,6 +81,12 @@ namespace osu.Game.Tests.Visual
LoadScreen(Player); LoadScreen(Player);
} }
protected override void Dispose(bool isDisposing)
{
LocalConfig?.Dispose();
base.Dispose(isDisposing);
}
/// <summary> /// <summary>
/// Creates the ruleset for setting up the <see cref="Player"/> component. /// Creates the ruleset for setting up the <see cref="Player"/> component.
/// </summary> /// </summary>

View File

@ -24,7 +24,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2020.910.0" /> <PackageReference Include="ppy.osu.Framework" Version="2020.911.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
<PackageReference Include="Sentry" Version="2.1.6" /> <PackageReference Include="Sentry" Version="2.1.6" />
<PackageReference Include="SharpCompress" Version="0.26.0" /> <PackageReference Include="SharpCompress" Version="0.26.0" />

View File

@ -70,7 +70,7 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2020.910.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2020.911.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.904.0" />
</ItemGroup> </ItemGroup>
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. --> <!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
@ -80,7 +80,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2020.910.0" /> <PackageReference Include="ppy.osu.Framework" Version="2020.911.0" />
<PackageReference Include="SharpCompress" Version="0.26.0" /> <PackageReference Include="SharpCompress" Version="0.26.0" />
<PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="SharpRaven" Version="2.4.0" /> <PackageReference Include="SharpRaven" Version="2.4.0" />