diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 4d9d3f97ed..6c0147a3de 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Osu.Objects set { Curve.Distance = value; } } - public List> RepeatSamples { get; set; } = new List>(); + public List RepeatSamples { get; set; } = new List(); public int RepeatCount { get; set; } = 1; private int stackHeight; @@ -117,12 +117,12 @@ namespace osu.Game.Rulesets.Osu.Objects StackHeight = StackHeight, Scale = Scale, ComboColour = ComboColour, - Samples = Samples.Select(s => new SampleInfo + Samples = new SampleInfoList(Samples.Select(s => new SampleInfo { Bank = s.Bank, Name = @"slidertick", Volume = s.Volume - }).ToList() + })) }; } } diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index cf7b9ce710..0784c94059 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -66,9 +66,10 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps var distanceData = obj as IHasDistance; var repeatsData = obj as IHasRepeats; var endTimeData = obj as IHasEndTime; + var curveData = obj as IHasCurve; // Old osu! used hit sounding to determine various hit type information - List samples = obj.Samples; + SampleInfoList samples = obj.Samples; bool strong = samples.Any(s => s.Name == SampleInfo.HIT_FINISH); @@ -102,16 +103,35 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps if (tickSpacing > 0 && osuDuration < 2 * speedAdjustedBeatLength) { + List allSamples = curveData != null ? curveData.RepeatSamples : new List(new[] { samples }); + + int i = 0; for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing) { - // Todo: This should generate different type of hits (including strongs) - // depending on hitobject sound additions (not implemented fully yet) - yield return new CentreHit + SampleInfoList currentSamples = allSamples[i]; + bool isRim = currentSamples.Any(s => s.Name == SampleInfo.HIT_CLAP || s.Name == SampleInfo.HIT_WHISTLE); + strong = currentSamples.Any(s => s.Name == SampleInfo.HIT_FINISH); + + if (isRim) { - StartTime = j, - Samples = obj.Samples, - IsStrong = strong, - }; + yield return new RimHit + { + StartTime = j, + Samples = currentSamples, + IsStrong = strong + }; + } + else + { + yield return new CentreHit + { + StartTime = j, + Samples = currentSamples, + IsStrong = strong, + }; + } + + i = (i + 1) % allSamples.Count; } } else diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 4f89fb8248..f79c01b643 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -82,12 +82,12 @@ namespace osu.Game.Rulesets.Taiko.Objects TickSpacing = tickSpacing, StartTime = t, IsStrong = IsStrong, - Samples = Samples.Select(s => new SampleInfo + Samples = new SampleInfoList(Samples.Select(s => new SampleInfo { Bank = s.Bank, Name = @"slidertick", Volume = s.Volume - }).ToList() + })) }); first = false; diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs new file mode 100644 index 0000000000..c8bb73abbb --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; + +namespace osu.Game.Rulesets.Taiko.Objects +{ + internal class TaikoHitObjectDifficulty + { + /// + /// Factor by how much individual / overall strain decays per second. + /// + /// + /// These values are results of tweaking a lot and taking into account general feedback. + /// + 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; + + /// + /// Measures note density in a way + /// + 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 + } + } +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/TaikoDifficultyCalculator.cs index cd61709db8..33e9510f1c 100644 --- a/osu.Game.Rulesets.Taiko/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/TaikoDifficultyCalculator.cs @@ -6,18 +6,133 @@ using osu.Game.Rulesets.Beatmaps; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Objects; using System.Collections.Generic; +using System.Globalization; +using System; namespace osu.Game.Rulesets.Taiko { - public class TaikoDifficultyCalculator : DifficultyCalculator + internal class TaikoDifficultyCalculator : DifficultyCalculator { - public TaikoDifficultyCalculator(Beatmap beatmap) : base(beatmap) + private const double star_scaling_factor = 0.04125; + + /// + /// 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. + /// + private const double strain_step = 400; + + /// + /// The weighting of each strain value decays to this number * it's previous value + /// + private const double decay_weight = 0.9; + + /// + /// HitObjects are stored as a member variable. + /// + private readonly List difficultyHitObjects = new List(); + + public TaikoDifficultyCalculator(Beatmap beatmap) + : base(beatmap) { } protected override double CalculateInternal(Dictionary categoryDifficulty) { - return 0; + // Fill our custom DifficultyHitObject class, that carries additional information + difficultyHitObjects.Clear(); + + foreach (var hitObject in Objects) + difficultyHitObjects.Add(new TaikoHitObjectDifficulty(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()) return 0; + + double starRating = calculateDifficulty() * star_scaling_factor; + + if (categoryDifficulty != null) + { + categoryDifficulty.Add("Strain", starRating.ToString("0.00", CultureInfo.InvariantCulture)); + categoryDifficulty.Add("Hit window 300", (35 /*HitObjectManager.HitWindow300*/ / TimeRate).ToString("0.00", CultureInfo.InvariantCulture)); + } + + return starRating; + } + + private bool calculateStrainValues() + { + // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment. + using (List.Enumerator hitObjectsEnumerator = difficultyHitObjects.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() + { + double actualStrainStep = strain_step * TimeRate; + + // Find the highest strain value within each strain step + List highestStrains = new List(); + 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 difficultyHitObjects) + { + // 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 BeatmapConverter CreateBeatmapConverter() => new TaikoBeatmapConverter(); diff --git a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj index c668b90ec4..f890e32f90 100644 --- a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj +++ b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj @@ -81,6 +81,7 @@ + diff --git a/osu.Game/Audio/SampleInfoList.cs b/osu.Game/Audio/SampleInfoList.cs new file mode 100644 index 0000000000..594341bbb1 --- /dev/null +++ b/osu.Game/Audio/SampleInfoList.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System.Collections.Generic; + +namespace osu.Game.Audio +{ + public class SampleInfoList : List + { + public SampleInfoList() + { + } + + public SampleInfoList(IEnumerable elements) + { + AddRange(elements); + } + } +} \ No newline at end of file diff --git a/osu.Game/Beatmaps/DifficultyCalculator.cs b/osu.Game/Beatmaps/DifficultyCalculator.cs index 727c89049f..8e9266b644 100644 --- a/osu.Game/Beatmaps/DifficultyCalculator.cs +++ b/osu.Game/Beatmaps/DifficultyCalculator.cs @@ -35,6 +35,10 @@ namespace osu.Game.Beatmaps protected DifficultyCalculator(Beatmap beatmap) { Objects = CreateBeatmapConverter().Convert(beatmap).HitObjects; + + foreach (var h in Objects) + h.ApplyDefaults(beatmap.TimingInfo, beatmap.BeatmapInfo.Difficulty); + PreprocessHitObjects(); } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 12e4ca6df1..46fb5fcf70 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -5,7 +5,6 @@ using osu.Game.Audio; using osu.Game.Beatmaps.Timing; using osu.Game.Database; using osu.Game.Rulesets.Objects.Types; -using System.Collections.Generic; namespace osu.Game.Rulesets.Objects { @@ -29,7 +28,7 @@ namespace osu.Game.Rulesets.Objects /// and can be treated as the default samples for the hit object. /// /// - public List Samples = new List(); + public SampleInfoList Samples = new SampleInfoList(); /// /// Applies default values to this HitObject. diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index 1550772f8e..5c534456ef 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List> repeatSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List repeatSamples) { return new ConvertSlider { diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index ad028e0cee..c5551082ec 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -123,7 +123,7 @@ namespace osu.Game.Rulesets.Objects.Legacy } // Generate the final per-node samples - var nodeSamples = new List>(nodes); + var nodeSamples = new List(nodes); for (int i = 0; i <= repeatCount; i++) nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); @@ -204,7 +204,7 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The slider repeat count. /// The samples to be played when the repeat nodes are hit. This includes the head and tail of the slider. /// The hit object. - protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List> repeatSamples); + protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List repeatSamples); /// /// Creates a legacy Spinner-type hit object. @@ -214,9 +214,9 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The hit object. protected abstract HitObject CreateSpinner(Vector2 position, double endTime); - private List convertSoundType(LegacySoundType type, SampleBankInfo bankInfo) + private SampleInfoList convertSoundType(LegacySoundType type, SampleBankInfo bankInfo) { - var soundTypes = new List + var soundTypes = new SampleInfoList { new SampleInfo { diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index 06391a9906..7580404e81 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Objects.Legacy public CurveType CurveType { get; set; } public double Distance { get; set; } - public List> RepeatSamples { get; set; } + public List RepeatSamples { get; set; } public int RepeatCount { get; set; } = 1; public double EndTime { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs index b21857797f..224f068323 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List> repeatSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List repeatSamples) { return new ConvertSlider { diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs index e45a161f52..41bf142831 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List> repeatSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List repeatSamples) { return new ConvertSlider { diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs index 2de2f217d1..0d755d7527 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List> repeatSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, List controlPoints, double length, CurveType curveType, int repeatCount, List repeatSamples) { return new ConvertSlider { diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 2fe2424d49..5abad2d661 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The samples to be played when each repeat node is hit (0 -> first repeat node, 1 -> second repeat node, etc). /// - List> RepeatSamples { get; } + List RepeatSamples { get; } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index cc9fe00cdc..cb491055c4 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -71,6 +71,7 @@ +