mirror of
https://github.com/ppy/osu.git
synced 2024-12-14 17:32:54 +08:00
Merge branch 'master' into no-gameplay-clock-gameplay-offset
This commit is contained in:
commit
3a17c6df08
@ -31,8 +31,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
private OsuInputManager inputManager = null!;
|
||||
|
||||
private IFrameStableClock gameplayClock = null!;
|
||||
|
||||
private List<OsuReplayFrame> replayFrames = null!;
|
||||
|
||||
private int currentFrame;
|
||||
@ -41,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
{
|
||||
if (currentFrame == replayFrames.Count - 1) return;
|
||||
|
||||
double time = gameplayClock.CurrentTime;
|
||||
double time = playfield.Clock.CurrentTime;
|
||||
|
||||
// Very naive implementation of autopilot based on proximity to replay frames.
|
||||
// TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered).
|
||||
@ -56,8 +54,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||
{
|
||||
gameplayClock = drawableRuleset.FrameStableClock;
|
||||
|
||||
// Grab the input manager to disable the user's cursor, and for future use
|
||||
inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager;
|
||||
inputManager.AllowUserCursorMovement = false;
|
||||
|
@ -5,6 +5,7 @@ using System;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
@ -27,8 +28,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
public override double ScoreMultiplier => 0.5;
|
||||
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) };
|
||||
|
||||
private IFrameStableClock gameplayClock = null!;
|
||||
|
||||
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
|
||||
public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
|
||||
{
|
||||
@ -39,8 +38,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||
{
|
||||
gameplayClock = drawableRuleset.FrameStableClock;
|
||||
|
||||
// Hide judgment displays and follow points as they won't make any sense.
|
||||
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
||||
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
||||
@ -56,27 +53,27 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
switch (drawable)
|
||||
{
|
||||
case DrawableHitCircle circle:
|
||||
easeTo(circle, cursorPos);
|
||||
easeTo(playfield.Clock, circle, cursorPos);
|
||||
break;
|
||||
|
||||
case DrawableSlider slider:
|
||||
|
||||
if (!slider.HeadCircle.Result.HasResult)
|
||||
easeTo(slider, cursorPos);
|
||||
easeTo(playfield.Clock, slider, cursorPos);
|
||||
else
|
||||
easeTo(slider, cursorPos - slider.Ball.DrawPosition);
|
||||
easeTo(playfield.Clock, slider, cursorPos - slider.Ball.DrawPosition);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void easeTo(DrawableHitObject hitObject, Vector2 destination)
|
||||
private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination)
|
||||
{
|
||||
double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
|
||||
|
||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
|
||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);
|
||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime);
|
||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime);
|
||||
|
||||
hitObject.Position = new Vector2(x, y);
|
||||
}
|
||||
|
@ -2,9 +2,9 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
public override double ScoreMultiplier => 1;
|
||||
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised) };
|
||||
|
||||
private IFrameStableClock? gameplayClock;
|
||||
|
||||
[SettingSource("Repulsion strength", "How strong the repulsion is.", 0)]
|
||||
public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f)
|
||||
{
|
||||
@ -39,8 +37,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||
{
|
||||
gameplayClock = drawableRuleset.FrameStableClock;
|
||||
|
||||
// Hide judgment displays and follow points as they won't make any sense.
|
||||
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
||||
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
||||
@ -69,29 +65,27 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
switch (drawable)
|
||||
{
|
||||
case DrawableHitCircle circle:
|
||||
easeTo(circle, destination, cursorPos);
|
||||
easeTo(playfield.Clock, circle, destination, cursorPos);
|
||||
break;
|
||||
|
||||
case DrawableSlider slider:
|
||||
|
||||
if (!slider.HeadCircle.Result.HasResult)
|
||||
easeTo(slider, destination, cursorPos);
|
||||
easeTo(playfield.Clock, slider, destination, cursorPos);
|
||||
else
|
||||
easeTo(slider, destination - slider.Ball.DrawPosition, cursorPos);
|
||||
easeTo(playfield.Clock, slider, destination - slider.Ball.DrawPosition, cursorPos);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void easeTo(DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos)
|
||||
private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos)
|
||||
{
|
||||
Debug.Assert(gameplayClock != null);
|
||||
|
||||
double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04);
|
||||
|
||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
|
||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);
|
||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime);
|
||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime);
|
||||
|
||||
hitObject.Position = new Vector2(x, y);
|
||||
}
|
||||
|
@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
|
||||
|
||||
[TestCase(1.9971301024093662d, 200, "diffcalc-test")]
|
||||
[TestCase(1.9971301024093662d, 200, "diffcalc-test-strong")]
|
||||
[TestCase(3.1098944660126882d, 200, "diffcalc-test")]
|
||||
[TestCase(3.1098944660126882d, 200, "diffcalc-test-strong")]
|
||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||
|
||||
[TestCase(3.1645810961313674d, 200, "diffcalc-test")]
|
||||
[TestCase(3.1645810961313674d, 200, "diffcalc-test-strong")]
|
||||
[TestCase(4.0974106752474251d, 200, "diffcalc-test")]
|
||||
[TestCase(4.0974106752474251d, 200, "diffcalc-test-strong")]
|
||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
|
||||
|
||||
|
@ -0,0 +1,67 @@
|
||||
// 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.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
|
||||
{
|
||||
public class ColourEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2).
|
||||
/// </summary>
|
||||
/// <param name="val">The input value.</param>
|
||||
/// <param name="center">The center of the sigmoid, where the largest gradient occurs and value is equal to middle.</param>
|
||||
/// <param name="width">The radius of the sigmoid, outside of which values are near the minimum/maximum.</param>
|
||||
/// <param name="middle">The middle of the sigmoid output.</param>
|
||||
/// <param name="height">The height of the sigmoid output. This will be equal to max value - min value.</param>
|
||||
private static double sigmoid(double val, double center, double width, double middle, double height)
|
||||
{
|
||||
double sigmoid = Math.Tanh(Math.E * -(val - center) / width);
|
||||
return sigmoid * (height / 2) + middle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate the difficulty of the first note of a <see cref="MonoStreak"/>.
|
||||
/// </summary>
|
||||
public static double EvaluateDifficultyOf(MonoStreak monoStreak)
|
||||
{
|
||||
return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate the difficulty of the first note of a <see cref="AlternatingMonoPattern"/>.
|
||||
/// </summary>
|
||||
public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern)
|
||||
{
|
||||
return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate the difficulty of the first note of a <see cref="RepeatingHitPatterns"/>.
|
||||
/// </summary>
|
||||
public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern)
|
||||
{
|
||||
return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1));
|
||||
}
|
||||
|
||||
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
|
||||
{
|
||||
TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour;
|
||||
double difficulty = 0.0d;
|
||||
|
||||
if (colour.MonoStreak != null) // Difficulty for MonoStreak
|
||||
difficulty += EvaluateDifficultyOf(colour.MonoStreak);
|
||||
if (colour.AlternatingMonoPattern != null) // Difficulty for AlternatingMonoPattern
|
||||
difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern);
|
||||
if (colour.RepeatingHitPattern != null) // Difficulty for RepeatingHitPattern
|
||||
difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern);
|
||||
|
||||
return difficulty;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
// 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.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
|
||||
{
|
||||
public class StaminaEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a speed bonus dependent on the time since the last hit performed using this key.
|
||||
/// </summary>
|
||||
/// <param name="interval">The interval between the current and previous note hit using the same key.</param>
|
||||
private static double speedBonus(double interval)
|
||||
{
|
||||
// Cap to 600bpm 1/4, 25ms note interval, 50ms key interval
|
||||
// Interval will be capped at a very small value to avoid infinite/negative speed bonuses.
|
||||
// TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus.
|
||||
interval = Math.Max(interval, 50);
|
||||
|
||||
return 30 / interval;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the minimum mechanical stamina required to play the current object. This is calculated using the
|
||||
/// maximum possible interval between two hits using the same key, by alternating 2 keys for each colour.
|
||||
/// </summary>
|
||||
public static double EvaluateDifficultyOf(DifficultyHitObject current)
|
||||
{
|
||||
if (current.BaseObject is not Hit)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find the previous hit object hit by the current key, which is two notes of the same colour prior.
|
||||
TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current;
|
||||
TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(1);
|
||||
|
||||
if (keyPrevious == null)
|
||||
{
|
||||
// There is no previous hit object hit by the current key
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double objectStrain = 0.5; // Add a base strain to all objects
|
||||
objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime);
|
||||
return objectStrain;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
// 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;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="MonoStreak"/>s.
|
||||
/// <see cref="MonoStreak"/>s with the same <see cref="MonoStreak.RunLength"/> are grouped together.
|
||||
/// </summary>
|
||||
public class AlternatingMonoPattern
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="MonoStreak"/>s that are grouped together within this <see cref="AlternatingMonoPattern"/>.
|
||||
/// </summary>
|
||||
public readonly List<MonoStreak> MonoStreaks = new List<MonoStreak>();
|
||||
|
||||
/// <summary>
|
||||
/// The parent <see cref="RepeatingHitPatterns"/> that contains this <see cref="AlternatingMonoPattern"/>
|
||||
/// </summary>
|
||||
public RepeatingHitPatterns Parent = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Index of this <see cref="AlternatingMonoPattern"/> within it's parent <see cref="RepeatingHitPatterns"/>
|
||||
/// </summary>
|
||||
public int Index;
|
||||
|
||||
/// <summary>
|
||||
/// The first <see cref="TaikoDifficultyHitObject"/> in this <see cref="AlternatingMonoPattern"/>.
|
||||
/// </summary>
|
||||
public TaikoDifficultyHitObject FirstHitObject => MonoStreaks[0].FirstHitObject;
|
||||
|
||||
/// <summary>
|
||||
/// Determine if this <see cref="AlternatingMonoPattern"/> is a repetition of another <see cref="AlternatingMonoPattern"/>. This
|
||||
/// is a strict comparison and is true if and only if the colour sequence is exactly the same.
|
||||
/// </summary>
|
||||
public bool IsRepetitionOf(AlternatingMonoPattern other)
|
||||
{
|
||||
return HasIdenticalMonoLength(other) &&
|
||||
other.MonoStreaks.Count == MonoStreaks.Count &&
|
||||
other.MonoStreaks[0].HitType == MonoStreaks[0].HitType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine if this <see cref="AlternatingMonoPattern"/> has the same mono length of another <see cref="AlternatingMonoPattern"/>.
|
||||
/// </summary>
|
||||
public bool HasIdenticalMonoLength(AlternatingMonoPattern other)
|
||||
{
|
||||
return other.MonoStreaks[0].RunLength == MonoStreaks[0].RunLength;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Encode colour information for a sequence of <see cref="TaikoDifficultyHitObject"/>s. Consecutive <see cref="TaikoDifficultyHitObject"/>s
|
||||
/// of the same <see cref="Objects.HitType"/> are encoded within the same <see cref="MonoStreak"/>.
|
||||
/// </summary>
|
||||
public class MonoStreak
|
||||
{
|
||||
/// <summary>
|
||||
/// List of <see cref="DifficultyHitObject"/>s that are encoded within this <see cref="MonoStreak"/>.
|
||||
/// </summary>
|
||||
public List<TaikoDifficultyHitObject> HitObjects { get; private set; } = new List<TaikoDifficultyHitObject>();
|
||||
|
||||
/// <summary>
|
||||
/// The parent <see cref="AlternatingMonoPattern"/> that contains this <see cref="MonoStreak"/>
|
||||
/// </summary>
|
||||
public AlternatingMonoPattern Parent = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Index of this <see cref="MonoStreak"/> within it's parent <see cref="AlternatingMonoPattern"/>
|
||||
/// </summary>
|
||||
public int Index;
|
||||
|
||||
/// <summary>
|
||||
/// The first <see cref="TaikoDifficultyHitObject"/> in this <see cref="MonoStreak"/>.
|
||||
/// </summary>
|
||||
public TaikoDifficultyHitObject FirstHitObject => HitObjects[0];
|
||||
|
||||
/// <summary>
|
||||
/// The hit type of all objects encoded within this <see cref="MonoStreak"/>
|
||||
/// </summary>
|
||||
public HitType? HitType => (HitObjects[0].BaseObject as Hit)?.Type;
|
||||
|
||||
/// <summary>
|
||||
/// How long the mono pattern encoded within is
|
||||
/// </summary>
|
||||
public int RunLength => HitObjects.Count;
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="AlternatingMonoPattern"/>s, grouped together by back and forth repetition of the same
|
||||
/// <see cref="AlternatingMonoPattern"/>. Also stores the repetition interval between this and the previous <see cref="RepeatingHitPatterns"/>.
|
||||
/// </summary>
|
||||
public class RepeatingHitPatterns
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum amount of <see cref="RepeatingHitPatterns"/>s to look back to find a repetition.
|
||||
/// </summary>
|
||||
private const int max_repetition_interval = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="AlternatingMonoPattern"/>s that are grouped together within this <see cref="RepeatingHitPatterns"/>.
|
||||
/// </summary>
|
||||
public readonly List<AlternatingMonoPattern> AlternatingMonoPatterns = new List<AlternatingMonoPattern>();
|
||||
|
||||
/// <summary>
|
||||
/// The parent <see cref="TaikoDifficultyHitObject"/> in this <see cref="RepeatingHitPatterns"/>
|
||||
/// </summary>
|
||||
public TaikoDifficultyHitObject FirstHitObject => AlternatingMonoPatterns[0].FirstHitObject;
|
||||
|
||||
/// <summary>
|
||||
/// The previous <see cref="RepeatingHitPatterns"/>. This is used to determine the repetition interval.
|
||||
/// </summary>
|
||||
public readonly RepeatingHitPatterns? Previous;
|
||||
|
||||
/// <summary>
|
||||
/// How many <see cref="RepeatingHitPatterns"/> between the current and previous identical <see cref="RepeatingHitPatterns"/>.
|
||||
/// If no repetition is found this will have a value of <see cref="max_repetition_interval"/> + 1.
|
||||
/// </summary>
|
||||
public int RepetitionInterval { get; private set; } = max_repetition_interval + 1;
|
||||
|
||||
public RepeatingHitPatterns(RepeatingHitPatterns? previous)
|
||||
{
|
||||
Previous = previous;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if other is considered a repetition of this pattern. This is true if other's first two payloads
|
||||
/// have identical mono lengths.
|
||||
/// </summary>
|
||||
private bool isRepetitionOf(RepeatingHitPatterns other)
|
||||
{
|
||||
if (AlternatingMonoPatterns.Count != other.AlternatingMonoPatterns.Count) return false;
|
||||
|
||||
for (int i = 0; i < Math.Min(AlternatingMonoPatterns.Count, 2); i++)
|
||||
{
|
||||
if (!AlternatingMonoPatterns[i].HasIdenticalMonoLength(other.AlternatingMonoPatterns[i])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the closest previous <see cref="RepeatingHitPatterns"/> that has the identical <see cref="AlternatingMonoPatterns"/>.
|
||||
/// Interval is defined as the amount of <see cref="RepeatingHitPatterns"/> chunks between the current and repeated patterns.
|
||||
/// </summary>
|
||||
public void FindRepetitionInterval()
|
||||
{
|
||||
if (Previous == null)
|
||||
{
|
||||
RepetitionInterval = max_repetition_interval + 1;
|
||||
return;
|
||||
}
|
||||
|
||||
RepeatingHitPatterns? other = Previous;
|
||||
int interval = 1;
|
||||
|
||||
while (interval < max_repetition_interval)
|
||||
{
|
||||
if (isRepetitionOf(other))
|
||||
{
|
||||
RepetitionInterval = Math.Min(interval, max_repetition_interval);
|
||||
return;
|
||||
}
|
||||
|
||||
other = other.Previous;
|
||||
if (other == null) break;
|
||||
|
||||
++interval;
|
||||
}
|
||||
|
||||
RepetitionInterval = max_repetition_interval + 1;
|
||||
}
|
||||
}
|
||||
}
|
@ -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.Collections.Generic;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class to perform various encodings.
|
||||
/// </summary>
|
||||
public static class TaikoColourDifficultyPreprocessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes and encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="TaikoDifficultyHitObjectColour"/>s,
|
||||
/// assigning the appropriate <see cref="TaikoDifficultyHitObjectColour"/>s to each <see cref="TaikoDifficultyHitObject"/>,
|
||||
/// and pre-evaluating colour difficulty of each <see cref="TaikoDifficultyHitObject"/>.
|
||||
/// </summary>
|
||||
public static void ProcessAndAssign(List<DifficultyHitObject> hitObjects)
|
||||
{
|
||||
List<RepeatingHitPatterns> hitPatterns = encode(hitObjects);
|
||||
|
||||
// Assign indexing and encoding data to all relevant objects. Only the first note of each encoding type is
|
||||
// assigned with the relevant encodings.
|
||||
foreach (var repeatingHitPattern in hitPatterns)
|
||||
{
|
||||
repeatingHitPattern.FirstHitObject.Colour.RepeatingHitPattern = repeatingHitPattern;
|
||||
|
||||
// The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to
|
||||
// keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with
|
||||
// documentation.
|
||||
for (int i = 0; i < repeatingHitPattern.AlternatingMonoPatterns.Count; ++i)
|
||||
{
|
||||
AlternatingMonoPattern monoPattern = repeatingHitPattern.AlternatingMonoPatterns[i];
|
||||
monoPattern.Parent = repeatingHitPattern;
|
||||
monoPattern.Index = i;
|
||||
monoPattern.FirstHitObject.Colour.AlternatingMonoPattern = monoPattern;
|
||||
|
||||
for (int j = 0; j < monoPattern.MonoStreaks.Count; ++j)
|
||||
{
|
||||
MonoStreak monoStreak = monoPattern.MonoStreaks[j];
|
||||
monoStreak.Parent = monoPattern;
|
||||
monoStreak.Index = j;
|
||||
monoStreak.FirstHitObject.Colour.MonoStreak = monoStreak;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="RepeatingHitPatterns"/>s.
|
||||
/// </summary>
|
||||
private static List<RepeatingHitPatterns> encode(List<DifficultyHitObject> data)
|
||||
{
|
||||
List<MonoStreak> monoStreaks = encodeMonoStreak(data);
|
||||
List<AlternatingMonoPattern> alternatingMonoPatterns = encodeAlternatingMonoPattern(monoStreaks);
|
||||
List<RepeatingHitPatterns> repeatingHitPatterns = encodeRepeatingHitPattern(alternatingMonoPatterns);
|
||||
|
||||
return repeatingHitPatterns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="MonoStreak"/>s.
|
||||
/// </summary>
|
||||
private static List<MonoStreak> encodeMonoStreak(List<DifficultyHitObject> data)
|
||||
{
|
||||
List<MonoStreak> monoStreaks = new List<MonoStreak>();
|
||||
MonoStreak? currentMonoStreak = null;
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
TaikoDifficultyHitObject taikoObject = (TaikoDifficultyHitObject)data[i];
|
||||
|
||||
// This ignores all non-note objects, which may or may not be the desired behaviour
|
||||
TaikoDifficultyHitObject? previousObject = taikoObject.PreviousNote(0);
|
||||
|
||||
// If this is the first object in the list or the colour changed, create a new mono streak
|
||||
if (currentMonoStreak == null || previousObject == null || (taikoObject.BaseObject as Hit)?.Type != (previousObject.BaseObject as Hit)?.Type)
|
||||
{
|
||||
currentMonoStreak = new MonoStreak();
|
||||
monoStreaks.Add(currentMonoStreak);
|
||||
}
|
||||
|
||||
// Add the current object to the encoded payload.
|
||||
currentMonoStreak.HitObjects.Add(taikoObject);
|
||||
}
|
||||
|
||||
return monoStreaks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="MonoStreak"/>s into a list of <see cref="AlternatingMonoPattern"/>s.
|
||||
/// </summary>
|
||||
private static List<AlternatingMonoPattern> encodeAlternatingMonoPattern(List<MonoStreak> data)
|
||||
{
|
||||
List<AlternatingMonoPattern> monoPatterns = new List<AlternatingMonoPattern>();
|
||||
AlternatingMonoPattern? currentMonoPattern = null;
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
// Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length, or if this is the first MonoStreak in the list.
|
||||
if (currentMonoPattern == null || data[i].RunLength != data[i - 1].RunLength)
|
||||
{
|
||||
currentMonoPattern = new AlternatingMonoPattern();
|
||||
monoPatterns.Add(currentMonoPattern);
|
||||
}
|
||||
|
||||
// Add the current MonoStreak to the encoded payload.
|
||||
currentMonoPattern.MonoStreaks.Add(data[i]);
|
||||
}
|
||||
|
||||
return monoPatterns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a list of <see cref="AlternatingMonoPattern"/>s into a list of <see cref="RepeatingHitPatterns"/>s.
|
||||
/// </summary>
|
||||
private static List<RepeatingHitPatterns> encodeRepeatingHitPattern(List<AlternatingMonoPattern> data)
|
||||
{
|
||||
List<RepeatingHitPatterns> hitPatterns = new List<RepeatingHitPatterns>();
|
||||
RepeatingHitPatterns? currentHitPattern = null;
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
// Start a new RepeatingHitPattern. AlternatingMonoPatterns that should be grouped together will be handled later within this loop.
|
||||
currentHitPattern = new RepeatingHitPatterns(currentHitPattern);
|
||||
|
||||
// Determine if future AlternatingMonoPatterns should be grouped.
|
||||
bool isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]);
|
||||
|
||||
if (!isCoupled)
|
||||
{
|
||||
// If not, add the current AlternatingMonoPattern to the encoded payload and continue.
|
||||
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the
|
||||
// subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check.
|
||||
while (isCoupled)
|
||||
{
|
||||
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||
i++;
|
||||
isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]);
|
||||
}
|
||||
|
||||
// Skip over viewed data and add the rest to the payload
|
||||
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||
currentHitPattern.AlternatingMonoPatterns.Add(data[i + 1]);
|
||||
i++;
|
||||
}
|
||||
|
||||
hitPatterns.Add(currentHitPattern);
|
||||
}
|
||||
|
||||
// Final pass to find repetition intervals
|
||||
for (int i = 0; i < hitPatterns.Count; i++)
|
||||
{
|
||||
hitPatterns[i].FindRepetitionInterval();
|
||||
}
|
||||
|
||||
return hitPatterns;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores colour compression information for a <see cref="TaikoDifficultyHitObject"/>.
|
||||
/// </summary>
|
||||
public class TaikoDifficultyHitObjectColour
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="MonoStreak"/> that encodes this note, only present if this is the first note within a <see cref="MonoStreak"/>
|
||||
/// </summary>
|
||||
public MonoStreak? MonoStreak;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="AlternatingMonoPattern"/> that encodes this note, only present if this is the first note within a <see cref="AlternatingMonoPattern"/>
|
||||
/// </summary>
|
||||
public AlternatingMonoPattern? AlternatingMonoPattern;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="RepeatingHitPattern"/> that encodes this note, only present if this is the first note within a <see cref="RepeatingHitPattern"/>
|
||||
/// </summary>
|
||||
public RepeatingHitPatterns? RepeatingHitPattern;
|
||||
}
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a rhythm change in a taiko map.
|
@ -1,14 +1,14 @@
|
||||
// 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.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
{
|
||||
@ -17,21 +17,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
/// </summary>
|
||||
public class TaikoDifficultyHitObject : DifficultyHitObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of all <see cref="TaikoDifficultyHitObject"/> of the same colour as this <see cref="TaikoDifficultyHitObject"/> in the beatmap.
|
||||
/// </summary>
|
||||
private readonly IReadOnlyList<TaikoDifficultyHitObject>? monoDifficultyHitObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="monoDifficultyHitObjects"/>.
|
||||
/// </summary>
|
||||
public readonly int MonoIndex;
|
||||
|
||||
/// <summary>
|
||||
/// The list of all <see cref="TaikoDifficultyHitObject"/> that is either a regular note or finisher in the beatmap
|
||||
/// </summary>
|
||||
private readonly IReadOnlyList<TaikoDifficultyHitObject> noteDifficultyHitObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="noteDifficultyHitObjects"/>.
|
||||
/// </summary>
|
||||
public readonly int NoteIndex;
|
||||
|
||||
/// <summary>
|
||||
/// The rhythm required to hit this hit object.
|
||||
/// </summary>
|
||||
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
|
||||
|
||||
/// <summary>
|
||||
/// The hit type of this hit object.
|
||||
/// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used
|
||||
/// by other skills in the future.
|
||||
/// </summary>
|
||||
public readonly HitType? HitType;
|
||||
|
||||
/// <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;
|
||||
public readonly TaikoDifficultyHitObjectColour Colour;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new difficulty hit object.
|
||||
@ -40,15 +55,44 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
/// <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="objects">The list of <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||
/// /// <param name="index">The position of this <see cref="DifficultyHitObject"/> in the <paramref name="objects"/> list.</param>
|
||||
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List<DifficultyHitObject> objects, int index)
|
||||
/// <param name="objects">The list of all <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||
/// <param name="centreHitObjects">The list of centre (don) <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||
/// <param name="rimHitObjects">The list of rim (kat) <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||
/// <param name="noteObjects">The list of <see cref="DifficultyHitObject"/>s that is a hit (i.e. not a drumroll or swell) in the current beatmap.</param>
|
||||
/// <param name="index">The position of this <see cref="DifficultyHitObject"/> in the <paramref name="objects"/> list.</param>
|
||||
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate,
|
||||
List<DifficultyHitObject> objects,
|
||||
List<TaikoDifficultyHitObject> centreHitObjects,
|
||||
List<TaikoDifficultyHitObject> rimHitObjects,
|
||||
List<TaikoDifficultyHitObject> noteObjects, int index)
|
||||
: base(hitObject, lastObject, clockRate, objects, index)
|
||||
{
|
||||
var currentHit = hitObject as Hit;
|
||||
noteDifficultyHitObjects = noteObjects;
|
||||
|
||||
// Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
|
||||
Colour = new TaikoDifficultyHitObjectColour();
|
||||
Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
|
||||
HitType = currentHit?.Type;
|
||||
|
||||
switch ((hitObject as Hit)?.Type)
|
||||
{
|
||||
case HitType.Centre:
|
||||
MonoIndex = centreHitObjects.Count;
|
||||
centreHitObjects.Add(this);
|
||||
monoDifficultyHitObjects = centreHitObjects;
|
||||
break;
|
||||
|
||||
case HitType.Rim:
|
||||
MonoIndex = rimHitObjects.Count;
|
||||
rimHitObjects.Add(this);
|
||||
monoDifficultyHitObjects = rimHitObjects;
|
||||
break;
|
||||
}
|
||||
|
||||
if (hitObject is Hit)
|
||||
{
|
||||
NoteIndex = noteObjects.Count;
|
||||
noteObjects.Add(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -87,5 +131,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||
|
||||
return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
|
||||
}
|
||||
|
||||
public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1));
|
||||
|
||||
public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1));
|
||||
|
||||
public TaikoDifficultyHitObject? PreviousNote(int backwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex - (backwardsIndex + 1));
|
||||
|
||||
public TaikoDifficultyHitObject? NextNote(int forwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex + (forwardsIndex + 1));
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,10 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Difficulty.Utils;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
@ -18,29 +13,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
/// </summary>
|
||||
public class Colour : StrainDecaySkill
|
||||
{
|
||||
protected override double SkillMultiplier => 1;
|
||||
protected override double StrainDecayBase => 0.4;
|
||||
protected override double SkillMultiplier => 0.12;
|
||||
|
||||
/// <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;
|
||||
// This is set to decay slower than other skills, due to the fact that only the first note of each encoding class
|
||||
// having any difficulty values, and we want to allow colour difficulty to be able to build up even on
|
||||
// slower maps.
|
||||
protected override double StrainDecayBase => 0.8;
|
||||
|
||||
public Colour(Mod[] mods)
|
||||
: base(mods)
|
||||
@ -49,95 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
|
||||
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;
|
||||
return ColourEvaluator.EvaluateDifficultyOf(current);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
93
osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs
Normal file
93
osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs
Normal file
@ -0,0 +1,93 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
public class Peaks : Skill
|
||||
{
|
||||
private const double rhythm_skill_multiplier = 0.2 * final_multiplier;
|
||||
private const double colour_skill_multiplier = 0.375 * final_multiplier;
|
||||
private const double stamina_skill_multiplier = 0.375 * final_multiplier;
|
||||
|
||||
private const double final_multiplier = 0.0625;
|
||||
|
||||
private readonly Rhythm rhythm;
|
||||
private readonly Colour colour;
|
||||
private readonly Stamina stamina;
|
||||
|
||||
public double ColourDifficultyValue => colour.DifficultyValue() * colour_skill_multiplier;
|
||||
public double RhythmDifficultyValue => rhythm.DifficultyValue() * rhythm_skill_multiplier;
|
||||
public double StaminaDifficultyValue => stamina.DifficultyValue() * stamina_skill_multiplier;
|
||||
|
||||
public Peaks(Mod[] mods)
|
||||
: base(mods)
|
||||
{
|
||||
rhythm = new Rhythm(mods);
|
||||
colour = new Colour(mods);
|
||||
stamina = new Stamina(mods);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
public override void Process(DifficultyHitObject current)
|
||||
{
|
||||
rhythm.Process(current);
|
||||
colour.Process(current);
|
||||
stamina.Process(current);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the combined 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>
|
||||
public override double DifficultyValue()
|
||||
{
|
||||
List<double> peaks = new List<double>();
|
||||
|
||||
var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
|
||||
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
|
||||
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
|
||||
|
||||
for (int i = 0; i < colourPeaks.Count; i++)
|
||||
{
|
||||
double colourPeak = colourPeaks[i] * colour_skill_multiplier;
|
||||
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
|
||||
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier;
|
||||
|
||||
double peak = norm(1.5, colourPeak, staminaPeak);
|
||||
peak = norm(2, peak, rhythmPeak);
|
||||
|
||||
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// These sections will not contribute to the difficulty.
|
||||
if (peak > 0)
|
||||
peaks.Add(peak);
|
||||
}
|
||||
|
||||
double difficulty = 0;
|
||||
double weight = 1;
|
||||
|
||||
foreach (double strain in peaks.OrderByDescending(d => d))
|
||||
{
|
||||
difficulty += strain * weight;
|
||||
weight *= 0.9;
|
||||
}
|
||||
|
||||
return difficulty;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,44 +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.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// Stamina of a single key, calculated based on repetition speed.
|
||||
/// </summary>
|
||||
public class SingleKeyStamina
|
||||
{
|
||||
private double? previousHitTime;
|
||||
|
||||
/// <summary>
|
||||
/// Similar to <see cref="StrainDecaySkill.StrainValueOf"/>
|
||||
/// </summary>
|
||||
public double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
if (previousHitTime == null)
|
||||
{
|
||||
previousHitTime = current.StartTime;
|
||||
return 0;
|
||||
}
|
||||
|
||||
double objectStrain = 0.5;
|
||||
objectStrain += speedBonus(current.StartTime - previousHitTime.Value);
|
||||
previousHitTime = current.StartTime;
|
||||
return objectStrain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a speed bonus dependent on the time since the last hit performed using this key.
|
||||
/// </summary>
|
||||
/// <param name="notePairDuration">The duration between the current and previous note hit using the same key.</param>
|
||||
private double speedBonus(double notePairDuration)
|
||||
{
|
||||
return 175 / (notePairDuration + 100);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,8 +6,7 @@
|
||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
@ -19,31 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
/// </remarks>
|
||||
public class Stamina : StrainDecaySkill
|
||||
{
|
||||
protected override double SkillMultiplier => 1;
|
||||
protected override double SkillMultiplier => 1.1;
|
||||
protected override double StrainDecayBase => 0.4;
|
||||
|
||||
private readonly SingleKeyStamina[] centreKeyStamina =
|
||||
{
|
||||
new SingleKeyStamina(),
|
||||
new SingleKeyStamina()
|
||||
};
|
||||
|
||||
private readonly SingleKeyStamina[] rimKeyStamina =
|
||||
{
|
||||
new SingleKeyStamina(),
|
||||
new SingleKeyStamina()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Current index into <see cref="centreKeyStamina" /> for a centre hit.
|
||||
/// </summary>
|
||||
private int centreKeyIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Current index into <see cref="rimKeyStamina" /> for a rim hit.
|
||||
/// </summary>
|
||||
private int rimKeyIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Stamina"/> skill.
|
||||
/// </summary>
|
||||
@ -53,32 +30,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the next <see cref="SingleKeyStamina"/> to use for the given <see cref="TaikoDifficultyHitObject"/>.
|
||||
/// </summary>
|
||||
/// <param name="current">The current <see cref="TaikoDifficultyHitObject"/>.</param>
|
||||
private SingleKeyStamina getNextSingleKeyStamina(TaikoDifficultyHitObject current)
|
||||
{
|
||||
// Alternate key for the same color.
|
||||
if (current.HitType == HitType.Centre)
|
||||
{
|
||||
centreKeyIndex = (centreKeyIndex + 1) % 2;
|
||||
return centreKeyStamina[centreKeyIndex];
|
||||
}
|
||||
|
||||
rimKeyIndex = (rimKeyIndex + 1) % 2;
|
||||
return rimKeyStamina[rimKeyIndex];
|
||||
}
|
||||
|
||||
protected override double StrainValueOf(DifficultyHitObject current)
|
||||
{
|
||||
if (!(current.BaseObject is Hit))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
|
||||
return getNextSingleKeyStamina(hitObject).StrainValueOf(hitObject);
|
||||
return StaminaEvaluator.EvaluateDifficultyOf(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,13 +29,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
public double ColourDifficulty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
|
||||
/// The difficulty corresponding to the hardest parts of the map.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
|
||||
/// </remarks>
|
||||
[JsonProperty("approach_rate")]
|
||||
public double ApproachRate { get; set; }
|
||||
[JsonProperty("peak_difficulty")]
|
||||
public double PeakDifficulty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
|
||||
|
@ -13,6 +13,7 @@ using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Taiko.Mods;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
@ -22,9 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
public class TaikoDifficultyCalculator : DifficultyCalculator
|
||||
{
|
||||
private const double rhythm_skill_multiplier = 0.014;
|
||||
private const double colour_skill_multiplier = 0.01;
|
||||
private const double stamina_skill_multiplier = 0.021;
|
||||
private const double difficulty_multiplier = 1.35;
|
||||
|
||||
public override int Version => 20220701;
|
||||
|
||||
@ -33,12 +32,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
}
|
||||
|
||||
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[]
|
||||
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
|
||||
{
|
||||
new Colour(mods),
|
||||
new Rhythm(mods),
|
||||
new Stamina(mods)
|
||||
return new Skill[]
|
||||
{
|
||||
new Peaks(mods)
|
||||
};
|
||||
}
|
||||
|
||||
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
|
||||
{
|
||||
@ -50,18 +50,23 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
|
||||
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
|
||||
{
|
||||
List<DifficultyHitObject> taikoDifficultyHitObjects = new List<DifficultyHitObject>();
|
||||
List<DifficultyHitObject> difficultyHitObjects = new List<DifficultyHitObject>();
|
||||
List<TaikoDifficultyHitObject> centreObjects = new List<TaikoDifficultyHitObject>();
|
||||
List<TaikoDifficultyHitObject> rimObjects = new List<TaikoDifficultyHitObject>();
|
||||
List<TaikoDifficultyHitObject> noteObjects = new List<TaikoDifficultyHitObject>();
|
||||
|
||||
for (int i = 2; i < beatmap.HitObjects.Count; i++)
|
||||
{
|
||||
taikoDifficultyHitObjects.Add(
|
||||
difficultyHitObjects.Add(
|
||||
new TaikoDifficultyHitObject(
|
||||
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, taikoDifficultyHitObjects, taikoDifficultyHitObjects.Count
|
||||
)
|
||||
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects,
|
||||
centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count)
|
||||
);
|
||||
}
|
||||
|
||||
return taikoDifficultyHitObjects;
|
||||
TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects);
|
||||
|
||||
return difficultyHitObjects;
|
||||
}
|
||||
|
||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||
@ -69,28 +74,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
if (beatmap.HitObjects.Count == 0)
|
||||
return new TaikoDifficultyAttributes { Mods = mods };
|
||||
|
||||
var colour = (Colour)skills[0];
|
||||
var rhythm = (Rhythm)skills[1];
|
||||
var stamina = (Stamina)skills[2];
|
||||
var combined = (Peaks)skills[0];
|
||||
|
||||
double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
|
||||
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
|
||||
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
|
||||
double colourRating = combined.ColourDifficultyValue * difficulty_multiplier;
|
||||
double rhythmRating = combined.RhythmDifficultyValue * difficulty_multiplier;
|
||||
double staminaRating = combined.StaminaDifficultyValue * difficulty_multiplier;
|
||||
|
||||
double staminaPenalty = simpleColourPenalty(staminaRating, colourRating);
|
||||
staminaRating *= staminaPenalty;
|
||||
double combinedRating = combined.DifficultyValue() * difficulty_multiplier;
|
||||
double starRating = rescale(combinedRating * 1.4);
|
||||
|
||||
//TODO : This is a temporary fix for the stamina rating of converts, due to their low colour variance.
|
||||
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0 && colourRating < 0.05)
|
||||
// TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
|
||||
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
|
||||
{
|
||||
staminaPenalty *= 0.25;
|
||||
starRating *= 0.925;
|
||||
// For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
|
||||
if (colourRating < 2 && staminaRating > 8)
|
||||
starRating *= 0.80;
|
||||
}
|
||||
|
||||
double combinedRating = locallyCombinedDifficulty(colour, rhythm, stamina, 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.Difficulty.OverallDifficulty);
|
||||
|
||||
@ -101,75 +102,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
StaminaDifficulty = staminaRating,
|
||||
RhythmDifficulty = rhythmRating,
|
||||
ColourDifficulty = colourRating,
|
||||
PeakDifficulty = combinedRating,
|
||||
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
|
||||
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <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 stamina, double staminaPenalty)
|
||||
{
|
||||
List<double> peaks = new List<double>();
|
||||
|
||||
var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
|
||||
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
|
||||
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
|
||||
|
||||
for (int i = 0; i < colourPeaks.Count; i++)
|
||||
{
|
||||
double colourPeak = colourPeaks[i] * colour_skill_multiplier;
|
||||
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
|
||||
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * staminaPenalty;
|
||||
|
||||
double peak = norm(2, colourPeak, rhythmPeak, staminaPeak);
|
||||
|
||||
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// These sections will not contribute to the difficulty.
|
||||
if (peak > 0)
|
||||
peaks.Add(peak);
|
||||
}
|
||||
|
||||
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.
|
||||
/// Applies a final re-scaling of the star rating.
|
||||
/// </summary>
|
||||
/// <param name="sr">The raw star rating value before re-scaling.</param>
|
||||
private double rescale(double sr)
|
||||
|
@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
[JsonProperty("accuracy")]
|
||||
public double Accuracy { get; set; }
|
||||
|
||||
[JsonProperty("effective_miss_count")]
|
||||
public double EffectiveMissCount { get; set; }
|
||||
|
||||
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
|
||||
{
|
||||
foreach (var attribute in base.GetAttributesForDisplay())
|
||||
|
@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
private int countMeh;
|
||||
private int countMiss;
|
||||
|
||||
private double effectiveMissCount;
|
||||
|
||||
public TaikoPerformanceCalculator()
|
||||
: base(new TaikoRuleset())
|
||||
{
|
||||
@ -35,7 +37,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
|
||||
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
|
||||
|
||||
double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things
|
||||
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000.
|
||||
if (totalSuccessfulHits > 0)
|
||||
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
|
||||
|
||||
double multiplier = 1.13;
|
||||
|
||||
if (score.Mods.Any(m => m is ModHidden))
|
||||
multiplier *= 1.075;
|
||||
@ -55,6 +61,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
Difficulty = difficultyValue,
|
||||
Accuracy = accuracyValue,
|
||||
EffectiveMissCount = effectiveMissCount,
|
||||
Total = totalValue
|
||||
};
|
||||
}
|
||||
@ -66,18 +73,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
|
||||
difficultyValue *= lengthBonus;
|
||||
|
||||
difficultyValue *= Math.Pow(0.986, countMiss);
|
||||
difficultyValue *= Math.Pow(0.986, effectiveMissCount);
|
||||
|
||||
if (score.Mods.Any(m => m is ModEasy))
|
||||
difficultyValue *= 0.980;
|
||||
difficultyValue *= 0.985;
|
||||
|
||||
if (score.Mods.Any(m => m is ModHidden))
|
||||
difficultyValue *= 1.025;
|
||||
|
||||
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
|
||||
difficultyValue *= 1.05 * lengthBonus;
|
||||
if (score.Mods.Any(m => m is ModHardRock))
|
||||
difficultyValue *= 1.050;
|
||||
|
||||
return difficultyValue * Math.Pow(score.Accuracy, 1.5);
|
||||
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
|
||||
difficultyValue *= 1.050 * lengthBonus;
|
||||
|
||||
return difficultyValue * Math.Pow(score.Accuracy, 2.0);
|
||||
}
|
||||
|
||||
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
|
||||
@ -85,18 +95,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
if (attributes.GreatHitWindow <= 0)
|
||||
return 0;
|
||||
|
||||
double accuracyValue = Math.Pow(140.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 12.0) * 27;
|
||||
double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0;
|
||||
|
||||
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
|
||||
accuracyValue *= lengthBonus;
|
||||
|
||||
// Slight HDFL Bonus for accuracy.
|
||||
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values
|
||||
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden))
|
||||
accuracyValue *= 1.10 * lengthBonus;
|
||||
accuracyValue *= Math.Max(1.050, 1.075 * lengthBonus);
|
||||
|
||||
return accuracyValue;
|
||||
}
|
||||
|
||||
private int totalHits => countGreat + countOk + countMeh + countMiss;
|
||||
|
||||
private int totalSuccessfulHits => countGreat + countOk + countMeh;
|
||||
}
|
||||
}
|
||||
|
@ -244,8 +244,12 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
const int total_set_count = 200;
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
for (int i = 0; i < total_set_count; i++)
|
||||
sets.Add(TestResources.CreateTestBeatmapSetInfo());
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
@ -275,8 +279,12 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
const int total_set_count = 20;
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
for (int i = 0; i < total_set_count; i++)
|
||||
sets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
@ -493,6 +501,10 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
const string zzz_string = "zzzzz";
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var set = TestResources.CreateTestBeatmapSetInfo();
|
||||
@ -505,6 +517,7 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
sets.Add(set);
|
||||
}
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
@ -521,6 +534,11 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
public void TestSortingStability()
|
||||
{
|
||||
var sets = new List<BeatmapSetInfo>();
|
||||
int idOffset = 0;
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
@ -535,7 +553,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
sets.Add(set);
|
||||
}
|
||||
|
||||
int idOffset = sets.First().OnlineID;
|
||||
idOffset = sets.First().OnlineID;
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
@ -556,6 +575,11 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
public void TestSortingStabilityWithNewItems()
|
||||
{
|
||||
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
||||
int idOffset = 0;
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
@ -570,12 +594,13 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
sets.Add(set);
|
||||
}
|
||||
|
||||
int idOffset = sets.First().OnlineID;
|
||||
idOffset = sets.First().OnlineID;
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false));
|
||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
||||
assertOriginalOrderMaintained();
|
||||
|
||||
AddStep("Add new item", () =>
|
||||
{
|
||||
@ -590,10 +615,16 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
carousel.UpdateBeatmapSet(set);
|
||||
});
|
||||
|
||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
||||
assertOriginalOrderMaintained();
|
||||
|
||||
AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false));
|
||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
||||
assertOriginalOrderMaintained();
|
||||
|
||||
void assertOriginalOrderMaintained()
|
||||
{
|
||||
AddAssert("Items remain in original order",
|
||||
() => carousel.BeatmapSets.Select(s => s.OnlineID), () => Is.EqualTo(carousel.BeatmapSets.Select((set, index) => idOffset + index)));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -601,6 +632,10 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
sets.Clear();
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var set = TestResources.CreateTestBeatmapSetInfo(3);
|
||||
@ -608,6 +643,7 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
set.Beatmaps[2].StarRating = 6 + i;
|
||||
sets.Add(set);
|
||||
}
|
||||
});
|
||||
|
||||
loadBeatmaps(sets);
|
||||
|
||||
@ -759,8 +795,13 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
List<BeatmapSetInfo> manySets = new List<BeatmapSetInfo>();
|
||||
|
||||
AddStep("Populuate beatmap sets", () =>
|
||||
{
|
||||
manySets.Clear();
|
||||
|
||||
for (int i = 1; i <= 50; i++)
|
||||
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
||||
});
|
||||
|
||||
loadBeatmaps(manySets);
|
||||
|
||||
@ -791,6 +832,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
AddStep("populate maps", () =>
|
||||
{
|
||||
manySets.Clear();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[]
|
||||
|
@ -69,8 +69,9 @@ namespace osu.Game.Database
|
||||
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
|
||||
/// 22 2022-07-31 Added ModPreset.
|
||||
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
|
||||
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
|
||||
/// </summary>
|
||||
private const int schema_version = 23;
|
||||
private const int schema_version = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
|
||||
|
@ -74,6 +74,9 @@ namespace osu.Game.Online.API.Requests.Responses
|
||||
[JsonProperty("statistics")]
|
||||
public Dictionary<HitResult, int> Statistics { get; set; } = new Dictionary<HitResult, int>();
|
||||
|
||||
[JsonProperty("maximum_statistics")]
|
||||
public Dictionary<HitResult, int> MaximumStatistics { get; set; } = new Dictionary<HitResult, int>();
|
||||
|
||||
#region osu-web API additions (not stored to database).
|
||||
|
||||
[JsonProperty("id")]
|
||||
@ -153,6 +156,7 @@ namespace osu.Game.Online.API.Requests.Responses
|
||||
MaxCombo = MaxCombo,
|
||||
Rank = Rank,
|
||||
Statistics = Statistics,
|
||||
MaximumStatistics = MaximumStatistics,
|
||||
Date = EndedAt,
|
||||
Hash = HasReplay ? "online" : string.Empty, // TODO: temporary?
|
||||
Mods = mods,
|
||||
@ -174,6 +178,7 @@ namespace osu.Game.Online.API.Requests.Responses
|
||||
Passed = score.Passed,
|
||||
Mods = score.APIMods,
|
||||
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
};
|
||||
|
||||
public long OnlineID => ID ?? -1;
|
||||
|
@ -1,27 +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.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Overlays.Chat;
|
||||
using osu.Game.Overlays.Dialog;
|
||||
|
||||
namespace osu.Game.Online.Chat
|
||||
{
|
||||
public class ExternalLinkOpener : Component
|
||||
{
|
||||
[Resolved]
|
||||
private GameHost host { get; set; }
|
||||
private GameHost host { get; set; } = null!;
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IDialogOverlay dialogOverlay { get; set; }
|
||||
private IDialogOverlay? dialogOverlay { get; set; }
|
||||
|
||||
private Bindable<bool> externalLinkWarning;
|
||||
private Bindable<bool> externalLinkWarning = null!;
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
private void load(OsuConfigManager config)
|
||||
@ -31,10 +31,39 @@ namespace osu.Game.Online.Chat
|
||||
|
||||
public void OpenUrlExternally(string url, bool bypassWarning = false)
|
||||
{
|
||||
if (!bypassWarning && externalLinkWarning.Value)
|
||||
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url)));
|
||||
if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null)
|
||||
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => host.GetClipboard()?.SetText(url)));
|
||||
else
|
||||
host.OpenUrlExternally(url);
|
||||
}
|
||||
|
||||
public class ExternalLinkDialog : PopupDialog
|
||||
{
|
||||
public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction)
|
||||
{
|
||||
HeaderText = "Just checking...";
|
||||
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
|
||||
|
||||
Icon = FontAwesome.Solid.ExclamationTriangle;
|
||||
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton
|
||||
{
|
||||
Text = @"Yes. Go for it.",
|
||||
Action = openExternalLinkAction
|
||||
},
|
||||
new PopupDialogCancelButton
|
||||
{
|
||||
Text = @"Copy URL to the clipboard instead.",
|
||||
Action = copyExternalLinkAction
|
||||
},
|
||||
new PopupDialogCancelButton
|
||||
{
|
||||
Text = @"No! Abort mission!"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +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.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Game.Overlays.Dialog;
|
||||
|
||||
namespace osu.Game.Overlays.Chat
|
||||
{
|
||||
public class ExternalLinkDialog : PopupDialog
|
||||
{
|
||||
public ExternalLinkDialog(string url, Action openExternalLinkAction)
|
||||
{
|
||||
HeaderText = "Just checking...";
|
||||
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
|
||||
|
||||
Icon = FontAwesome.Solid.ExclamationTriangle;
|
||||
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton
|
||||
{
|
||||
Text = @"Yes. Go for it.",
|
||||
Action = openExternalLinkAction
|
||||
},
|
||||
new PopupDialogCancelButton
|
||||
{
|
||||
Text = @"No! Abort mission!"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -128,8 +128,7 @@ namespace osu.Game.Rulesets.Scoring
|
||||
private bool beatmapApplied;
|
||||
|
||||
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
|
||||
|
||||
private Dictionary<HitResult, int>? maximumResultCounts;
|
||||
private readonly Dictionary<HitResult, int> maximumResultCounts = new Dictionary<HitResult, int>();
|
||||
|
||||
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
|
||||
private HitObject? lastHitObject;
|
||||
@ -405,8 +404,6 @@ namespace osu.Game.Rulesets.Scoring
|
||||
return ScoreRank.D;
|
||||
}
|
||||
|
||||
public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result);
|
||||
|
||||
/// <summary>
|
||||
/// Resets this ScoreProcessor to a default state.
|
||||
/// </summary>
|
||||
@ -421,7 +418,9 @@ namespace osu.Game.Rulesets.Scoring
|
||||
if (storeResults)
|
||||
{
|
||||
maximumScoringValues = currentScoringValues;
|
||||
maximumResultCounts = new Dictionary<HitResult, int>(scoreResultCounts);
|
||||
|
||||
maximumResultCounts.Clear();
|
||||
maximumResultCounts.AddRange(scoreResultCounts);
|
||||
}
|
||||
|
||||
scoreResultCounts.Clear();
|
||||
@ -449,7 +448,10 @@ namespace osu.Game.Rulesets.Scoring
|
||||
score.HitEvents = hitEvents;
|
||||
|
||||
foreach (var result in HitResultExtensions.ALL_TYPES)
|
||||
score.Statistics[result] = GetStatistic(result);
|
||||
score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result);
|
||||
|
||||
foreach (var result in HitResultExtensions.ALL_TYPES)
|
||||
score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result);
|
||||
|
||||
// Populate total score after everything else.
|
||||
score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score));
|
||||
|
@ -73,6 +73,9 @@ namespace osu.Game.Scoring
|
||||
|
||||
if (string.IsNullOrEmpty(model.StatisticsJson))
|
||||
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
|
||||
|
||||
if (string.IsNullOrEmpty(model.MaximumStatisticsJson))
|
||||
model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics);
|
||||
}
|
||||
|
||||
protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport)
|
||||
|
@ -63,6 +63,9 @@ namespace osu.Game.Scoring
|
||||
[MapTo("Statistics")]
|
||||
public string StatisticsJson { get; set; } = string.Empty;
|
||||
|
||||
[MapTo("MaximumStatistics")]
|
||||
public string MaximumStatisticsJson { get; set; } = string.Empty;
|
||||
|
||||
public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null)
|
||||
{
|
||||
Ruleset = ruleset ?? new RulesetInfo();
|
||||
@ -133,6 +136,7 @@ namespace osu.Game.Scoring
|
||||
var clone = (ScoreInfo)this.Detach().MemberwiseClone();
|
||||
|
||||
clone.Statistics = new Dictionary<HitResult, int>(clone.Statistics);
|
||||
clone.MaximumStatistics = new Dictionary<HitResult, int>(clone.MaximumStatistics);
|
||||
clone.RealmUser = new RealmUser
|
||||
{
|
||||
OnlineID = RealmUser.OnlineID,
|
||||
@ -181,6 +185,24 @@ namespace osu.Game.Scoring
|
||||
set => statistics = value;
|
||||
}
|
||||
|
||||
private Dictionary<HitResult, int>? maximumStatistics;
|
||||
|
||||
[Ignored]
|
||||
public Dictionary<HitResult, int> MaximumStatistics
|
||||
{
|
||||
get
|
||||
{
|
||||
if (maximumStatistics != null)
|
||||
return maximumStatistics;
|
||||
|
||||
if (!string.IsNullOrEmpty(MaximumStatisticsJson))
|
||||
maximumStatistics = JsonConvert.DeserializeObject<Dictionary<HitResult, int>>(MaximumStatisticsJson);
|
||||
|
||||
return maximumStatistics ??= new Dictionary<HitResult, int>();
|
||||
}
|
||||
set => maximumStatistics = value;
|
||||
}
|
||||
|
||||
private Mod[]? mods;
|
||||
|
||||
[Ignored]
|
||||
|
@ -55,8 +55,6 @@ namespace osu.Game.Screens.Select.Carousel
|
||||
match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) ||
|
||||
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
|
||||
|
||||
match &= criteria.Sort != SortMode.DateRanked || BeatmapInfo.BeatmapSet?.DateRanked != null;
|
||||
|
||||
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
|
||||
|
||||
if (match && criteria.SearchTerms.Length > 0)
|
||||
|
@ -99,6 +99,13 @@ namespace osu.Game.Screens.Select.Carousel
|
||||
|
||||
case SortMode.Difficulty:
|
||||
return compareUsingAggregateMax(otherSet, b => b.StarRating);
|
||||
|
||||
case SortMode.DateSubmitted:
|
||||
// Beatmaps which have no submitted date should already be filtered away in this mode.
|
||||
if (BeatmapSet.DateSubmitted == null || otherSet.BeatmapSet.DateSubmitted == null)
|
||||
return 0;
|
||||
|
||||
return otherSet.BeatmapSet.DateSubmitted.Value.CompareTo(BeatmapSet.DateSubmitted.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,7 +129,12 @@ namespace osu.Game.Screens.Select.Carousel
|
||||
public override void Filter(FilterCriteria criteria)
|
||||
{
|
||||
base.Filter(criteria);
|
||||
Filtered.Value = Items.All(i => i.Filtered.Value);
|
||||
bool match = Items.All(i => i.Filtered.Value);
|
||||
|
||||
match &= criteria.Sort != SortMode.DateRanked || BeatmapSet?.DateRanked != null;
|
||||
match &= criteria.Sort != SortMode.DateSubmitted || BeatmapSet?.DateSubmitted != null;
|
||||
|
||||
Filtered.Value = match;
|
||||
}
|
||||
|
||||
public override string ToString() => BeatmapSet.ToString();
|
||||
|
@ -20,6 +20,9 @@ namespace osu.Game.Screens.Select.Filter
|
||||
[LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))]
|
||||
BPM,
|
||||
|
||||
[Description("Date Submitted")]
|
||||
DateSubmitted,
|
||||
|
||||
[Description("Date Added")]
|
||||
DateAdded,
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user