1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 17:27:24 +08:00

Implement new difficulty calculator for Rulesets.Taiko

This commit is contained in:
smoogipoo 2019-02-18 14:54:21 +09:00
parent a8faa942a6
commit 25d85b6eb4
9 changed files with 173 additions and 275 deletions

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);
protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoLegacyDifficultyCalculator(new TaikoRuleset(), beatmap); protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset(), beatmap);
protected override Ruleset CreateRuleset() => new TaikoRuleset(); protected override Ruleset CreateRuleset() => new TaikoRuleset();
} }

View File

@ -0,0 +1,20 @@
// 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.Objects;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
{
public class TaikoDifficultyHitObject : DifficultyHitObject
{
public readonly bool HasTypeChange;
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double timeRate)
: base(hitObject, lastObject, timeRate)
{
HasTypeChange = lastObject is RimHit != hitObject is RimHit;
}
}
}

View File

@ -0,0 +1,90 @@
// 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 sameTypeCount;
protected override double StrainValueOf(DifficultyHitObject current)
{
double addition = 1;
// We get an extra addition if we are not a slider or spinner
if (Previous[0].BaseObject is Hit && current.BaseObject is Hit && current.BaseObject.StartTime - Previous[0].BaseObject.StartTime < 1000)
{
if (hasRhythmChange(current))
addition += 1;
if (hasColourChange(current))
addition += 0.75;
}
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[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)
{
sameTypeCount++;
return false;
}
var oldColourSwitch = lastColourSwitch;
var newColourSwitch = sameTypeCount % 2 == 0 ? ColourSwitch.Even : ColourSwitch.Odd;
lastColourSwitch = newColourSwitch;
sameTypeCount = 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

@ -11,8 +11,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public double GreatHitWindow; public double GreatHitWindow;
public int MaxCombo; public int MaxCombo;
public TaikoDifficultyAttributes(Mod[] mods, double starRating) public TaikoDifficultyAttributes(Mod[] mods)
: base(mods, starRating) : base(mods)
{ {
} }
} }

View File

@ -0,0 +1,56 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
public class TaikoDifficultyCalculator : DifficultyCalculator
{
private const double star_scaling_factor = 0.018;
public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
}
protected override void PopulateAttributes(DifficultyAttributes attributes, IBeatmap beatmap, Skill[] skills, double timeRate)
{
var taikoAttributes = (TaikoDifficultyAttributes)attributes;
taikoAttributes.StarRating = skills.Single().DifficultyValue() * star_scaling_factor;
// Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future
taikoAttributes.GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / timeRate;
taikoAttributes.MaxCombo = beatmap.HitObjects.Count(h => h is Hit);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double timeRate)
{
for (int i = 1; i < beatmap.HitObjects.Count; i++)
yield return new TaikoDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], timeRate);
}
protected override Skill[] CreateSkills() => new Skill[] { new Strain() };
protected override DifficultyAttributes CreateDifficultyAttributes(Mod[] mods) => new TaikoDifficultyAttributes(mods);
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
{
new TaikoModDoubleTime(),
new TaikoModHalfTime(),
new TaikoModEasy(),
new TaikoModHardRock(),
};
}
}

View File

@ -1,144 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
internal class TaikoLegacyDifficultyCalculator : LegacyDifficultyCalculator
{
private const double star_scaling_factor = 0.04125;
/// <summary>
/// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size STRAIN_STEP.
/// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain.
/// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage.
/// </summary>
private const double strain_step = 400;
/// <summary>
/// The weighting of each strain value decays to this number * it's previous value
/// </summary>
private const double decay_weight = 0.9;
public TaikoLegacyDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
}
protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate)
{
if (!beatmap.HitObjects.Any())
return new TaikoDifficultyAttributes(mods, 0);
var difficultyHitObjects = new List<TaikoHitObjectDifficulty>();
foreach (var hitObject in beatmap.HitObjects)
difficultyHitObjects.Add(new TaikoHitObjectDifficulty((TaikoHitObject)hitObject));
// Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure.
difficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime.CompareTo(b.BaseHitObject.StartTime));
if (!calculateStrainValues(difficultyHitObjects, timeRate))
return new TaikoDifficultyAttributes(mods, 0);
double starRating = calculateDifficulty(difficultyHitObjects, timeRate) * star_scaling_factor;
return new TaikoDifficultyAttributes(mods, starRating)
{
// Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be remoevd in the future
GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / timeRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit)
};
}
private bool calculateStrainValues(List<TaikoHitObjectDifficulty> objects, double timeRate)
{
// Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment.
using (var hitObjectsEnumerator = objects.GetEnumerator())
{
if (!hitObjectsEnumerator.MoveNext()) return false;
TaikoHitObjectDifficulty current = hitObjectsEnumerator.Current;
// First hitObject starts at strain 1. 1 is the default for strain values, so we don't need to set it here. See DifficultyHitObject.
while (hitObjectsEnumerator.MoveNext())
{
var next = hitObjectsEnumerator.Current;
next?.CalculateStrains(current, timeRate);
current = next;
}
return true;
}
}
private double calculateDifficulty(List<TaikoHitObjectDifficulty> objects, double timeRate)
{
double actualStrainStep = strain_step * timeRate;
// Find the highest strain value within each strain step
List<double> highestStrains = new List<double>();
double intervalEndTime = actualStrainStep;
double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval
TaikoHitObjectDifficulty previousHitObject = null;
foreach (var hitObject in objects)
{
// While we are beyond the current interval push the currently available maximum to our strain list
while (hitObject.BaseHitObject.StartTime > intervalEndTime)
{
highestStrains.Add(maximumStrain);
// The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay
// until the beginning of the next interval.
if (previousHitObject == null)
{
maximumStrain = 0;
}
else
{
double decay = Math.Pow(TaikoHitObjectDifficulty.DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000);
maximumStrain = previousHitObject.Strain * decay;
}
// Go to the next time interval
intervalEndTime += actualStrainStep;
}
// Obtain maximum strain
maximumStrain = Math.Max(hitObject.Strain, maximumStrain);
previousHitObject = hitObject;
}
// Build the weighted sum over the highest strains for each interval
double difficulty = 0;
double weight = 1;
highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain.
foreach (double strain in highestStrains)
{
difficulty += weight * strain;
weight *= decay_weight;
}
return difficulty;
}
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
{
new TaikoModDoubleTime(),
new TaikoModHalfTime(),
new TaikoModEasy(),
new TaikoModHardRock(),
};
}
}

View File

@ -1,127 +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;
namespace osu.Game.Rulesets.Taiko.Objects
{
internal class TaikoHitObjectDifficulty
{
/// <summary>
/// Factor by how much individual / overall strain decays per second.
/// </summary>
/// <remarks>
/// These values are results of tweaking a lot and taking into account general feedback.
/// </remarks>
internal const double DECAY_BASE = 0.30;
private const double type_change_bonus = 0.75;
private const double rhythm_change_bonus = 1.0;
private const double rhythm_change_base_threshold = 0.2;
private const double rhythm_change_base = 2.0;
internal TaikoHitObject BaseHitObject;
/// <summary>
/// Measures note density in a way
/// </summary>
internal double Strain = 1;
private double timeElapsed;
private int sameTypeSince = 1;
private bool isRim => BaseHitObject is RimHit;
public TaikoHitObjectDifficulty(TaikoHitObject baseHitObject)
{
BaseHitObject = baseHitObject;
}
internal void CalculateStrains(TaikoHitObjectDifficulty previousHitObject, double timeRate)
{
// Rather simple, but more specialized things are inherently inaccurate due to the big difference playstyles and opinions make.
// See Taiko feedback thread.
timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate;
double decay = Math.Pow(DECAY_BASE, timeElapsed / 1000);
double addition = 1;
// Only if we are no slider or spinner we get an extra addition
if (previousHitObject.BaseHitObject is Hit && BaseHitObject is Hit
&& BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime < 1000) // And we only want to check out hitobjects which aren't so far in the past
{
addition += typeChangeAddition(previousHitObject);
addition += rhythmChangeAddition(previousHitObject);
}
double additionFactor = 1.0;
// Scale AdditionFactor linearly from 0.4 to 1 for TimeElapsed from 0 to 50
if (timeElapsed < 50.0)
additionFactor = 0.4 + 0.6 * timeElapsed / 50.0;
Strain = previousHitObject.Strain * decay + addition * additionFactor;
}
private TypeSwitch lastTypeSwitchEven = TypeSwitch.None;
private double typeChangeAddition(TaikoHitObjectDifficulty previousHitObject)
{
// If we don't have the same hit type, trigger a type change!
if (previousHitObject.isRim != isRim)
{
lastTypeSwitchEven = previousHitObject.sameTypeSince % 2 == 0 ? TypeSwitch.Even : TypeSwitch.Odd;
// We only want a bonus if the parity of the type switch changes!
switch (previousHitObject.lastTypeSwitchEven)
{
case TypeSwitch.Even:
if (lastTypeSwitchEven == TypeSwitch.Odd)
return type_change_bonus;
break;
case TypeSwitch.Odd:
if (lastTypeSwitchEven == TypeSwitch.Even)
return type_change_bonus;
break;
}
}
// No type change? Increment counter and keep track of last type switch
else
{
lastTypeSwitchEven = previousHitObject.lastTypeSwitchEven;
sameTypeSince = previousHitObject.sameTypeSince + 1;
}
return 0;
}
private double rhythmChangeAddition(TaikoHitObjectDifficulty previousHitObject)
{
// We don't want a division by zero if some random mapper decides to put 2 HitObjects at the same time.
if (timeElapsed == 0 || previousHitObject.timeElapsed == 0)
return 0;
double timeElapsedRatio = Math.Max(previousHitObject.timeElapsed / timeElapsed, timeElapsed / previousHitObject.timeElapsed);
if (timeElapsedRatio >= 8)
return 0;
double difference = Math.Log(timeElapsedRatio, rhythm_change_base) % 1.0;
if (isWithinChangeThreshold(difference))
return rhythm_change_bonus;
return 0;
}
private bool isWithinChangeThreshold(double value)
{
return value > rhythm_change_base_threshold && value < 1 - rhythm_change_base_threshold;
}
private enum TypeSwitch
{
None,
Even,
Odd
}
}
}

View File

@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Taiko
public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_taiko_o }; public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_taiko_o };
public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoLegacyDifficultyCalculator(this, beatmap); public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap);
public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new TaikoPerformanceCalculator(this, beatmap, score); public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new TaikoPerformanceCalculator(this, beatmap, score);

View File

@ -10,4 +10,7 @@
<ItemGroup Label="Project References"> <ItemGroup Label="Project References">
<ProjectReference Include="..\osu.Game\osu.Game.csproj" /> <ProjectReference Include="..\osu.Game\osu.Game.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Difficulty2" />
</ItemGroup>
</Project> </Project>