Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 38 additions & 19 deletions osucatch-editor-realtimeviewer/BeatmapConverter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using osu.Game.Beatmaps;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Catch.Difficulty;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Catch.Difficulty.Skills;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;

Expand Down Expand Up @@ -196,40 +199,56 @@ public void CalDistanceToNext(IBeatmap beatmap, PalpableCatchHitObject hitObject

}

/// <summary>
/// Calculates the star rating of <paramref name="beatmap"/> using the version
/// <see cref="CatchDifficultyCalculator.Version"/> difficulty calculation pipeline
/// (<see cref="CatchDifficultyHitObject"/> + <c>MovementEvaluator</c> + <see cref="Movement"/>),
/// and writes the per-object star rating to <see cref="PalpableCatchHitObject.DifficultyToLast"/>.
/// </summary>
public double CalDifficulty(IBeatmap beatmap, List<PalpableCatchHitObject> comboHitObjects)
{
const double difficulty_multiplier = 4.59;
const float normalized_hitobject_radius = 41.0f;
const double clock_rate = 1.0;

float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
// We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = normalized_hitobject_radius / halfCatcherWidth;

StrainSkill skill = new Movement(halfCatcherWidth, 1);
var skill = new Movement(NoMod);

for (int i = 0; i < comboHitObjects.Count; i++)
{
CalDifficultyToLast(skill, comboHitObjects[i], scalingFactor);
}
CalDifficultyToLast(skill, comboHitObjects, halfCatcherWidth, clock_rate);

return Math.Sqrt(skill.DifficultyValue()) * difficulty_multiplier;
}

public void CalDifficultyToLast(StrainSkill skill, PalpableCatchHitObject hitObject, float scalingFactor)
/// <summary>
/// Feeds all combo-contributing objects of the beatmap through the difficulty pipeline.
/// </summary>
public void CalDifficultyToLast(Movement skill, List<PalpableCatchHitObject> comboHitObjects, float halfCatcherWidth, double clockRate)
{
if (hitObject.lastObject == null)
List<DifficultyHitObject> difficultyHitObjects = new List<DifficultyHitObject>(comboHitObjects.Count);

for (int i = 0; i < comboHitObjects.Count; i++)
{
hitObject.DifficultyToLast = 0;
return;
var hitObject = comboHitObjects[i];
var lastObject = hitObject.lastObject;

if (lastObject == null)
{
hitObject.DifficultyToLast = 0;
continue;
}

difficultyHitObjects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, difficultyHitObjects, difficultyHitObjects.Count));
}

hitObject.NormalizedPosition = hitObject.EffectiveX * scalingFactor;
hitObject.LastNormalizedPosition = hitObject.lastObject.EffectiveX * scalingFactor;
// Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure
hitObject.DeltaTime = hitObject.StartTime - hitObject.lastObject.StartTime;
hitObject.StrainTime = Math.Max(40, hitObject.DeltaTime);
skill.Process(hitObject);
foreach (var difficultyHitObject in difficultyHitObjects)
{
skill.Process(difficultyHitObject);

var hitObject = (PalpableCatchHitObject)difficultyHitObject.BaseObject;
hitObject.DifficultyToLast = difficultyHitObject.DifficultyToLast;
}
}

private void CalFruitCountInCombo(List<PalpableCatchHitObject> comboHitObjects)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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;

namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyAttributes : DifficultyAttributes
{
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
foreach (var v in base.ToDatabaseAttributes())
yield return v;

// Todo: osu!catch should not output star rating in the 'aim' attribute.
yield return (ATTRIB_ID_AIM, StarRating);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;using osu.Game.Rulesets.Catch.Difficulty.Skills;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;

namespace osu.Game.Rulesets.Catch.Difficulty
{
/// <summary>
/// The osu!catch difficulty calculator.
/// </summary>
/// <remarks>
/// The difficulty calculation in this viewer is performed per-hitobject (see
/// <c>osucatch_editor_realtimeviewer.BeatmapConverter.CalDifficulty</c>) so that a star rating
/// can be drawn beside each fruit, but it walks the exact same
/// <see cref="CatchDifficultyHitObject"/> / <see cref="MovementEvaluator"/> / <see cref="Movement"/>
/// pipeline as the official calculator below.
/// </remarks>
public class CatchDifficultyCalculator : DifficultyCalculator
{
private const double difficulty_multiplier = 4.59;

/// <summary>
/// osu!catch difficulty calculation version.
/// </summary>
/// <remarks>
/// Version history:
/// 20260706 osu!catch star rating update: movement difficulty split into
/// <see cref="CatchDifficultyHitObject"/> (preprocessing), <see cref="MovementEvaluator"/>
/// (evaluation) and <see cref="Movement"/> (skill), adding the linear spacing nerf and
/// the three-object buzz pattern detection.
/// </remarks>
public override int Version => 20260706;

public CatchDifficultyCalculator(Ruleset ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
}

protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{
if (beatmap.HitObjects.Count == 0 || skills.Length == 0)
return new CatchDifficultyAttributes { Mods = mods };

CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills.OfType<Movement>().Single().DifficultyValue()) * difficulty_multiplier,
Mods = mods,
MaxCombo = getMaxCombo(beatmap),
};

return attributes;
}

protected override List<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
CatchHitObject? lastObject = null;

List<DifficultyHitObject> objects = new List<DifficultyHitObject>(beatmap.HitObjects.Count);

float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;

// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);

// In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
{
// We want to only consider fruits that contribute to the combo.
if (hitObject is Banana || hitObject is TinyDroplet)
continue;

if (lastObject != null)
objects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, objects, objects.Count));

lastObject = hitObject;
}

return objects;
}

protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
{
return new Skill[]
{
new Movement(mods),
};
}

// This project's mod set only contains EZ / HR, neither of which adjusts the gameplay clock rate.
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
{
new CatchModEasy(),
new CatchModHardRock(),
};

/// <summary>
/// Returns the maximum achievable combo for the beatmap.
/// </summary>
/// <remarks>
/// Only fruits and droplets contribute to the combo; bananas and tiny droplets do not.
/// </remarks>
private static int getMaxCombo(IBeatmap beatmap)
{
int combo = 0;

foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
{
if (hitObject is Banana || hitObject is TinyDroplet)
continue;

combo++;
}

return combo;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;

namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{
public static class MovementEvaluator
{
private const double direction_change_bonus = 21.0;

public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);

// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
double catcherSpeedMultiplier = current.ClockRate;

double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);

double distanceAddition = (DiffUtils.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);

double edgeDashBonus = 0;

// Direction change bonus.
if (Math.Abs(catchCurrent.DistanceMoved) > 0.1)
{
if (current.Index >= 1 && Math.Abs(catchLast.DistanceMoved) > 0.1 && Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchLast.DistanceMoved))
{
double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50;
double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38);

distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - DiffUtils.Pow(weightedStrainTime / 1000, 3), 0);
}

// Base bonus for every movement, giving some weight to streams.
distanceAddition += 12.5 * Math.Min(Math.Abs(catchCurrent.DistanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2)
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
}

// Linear spacing nerf.
int linearSpacingCount = 0;

for (int i = 0; i < Math.Min(current.Index, 10); i++)
{
var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i);

// Only same direction movements matter as they do not take any additional inputs.
if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0)
break;

double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime);
double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime);

double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1);

if (relativeDifference > 0.05)
break;

linearSpacingCount++;
}

distanceAddition *= DiffUtils.Pow(0.7, linearSpacingCount);

// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
if (!catchCurrent.LastObject.HyperDash)
edgeDashBonus += 5.7;

distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* DiffUtils.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}

// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH)
if (current.Index >= 2 && Math.Abs(catchCurrent.ExactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2
&& catchCurrent.ExactDistanceMoved == -catchLast.ExactDistanceMoved && catchLast.ExactDistanceMoved == -catchLastLast.ExactDistanceMoved
&& catchCurrent.StrainTime == catchLast.StrainTime && catchLast.StrainTime == catchLastLast.StrainTime)
distanceAddition = 0;

return distanceAddition / weightedStrainTime;
}
}
}
Loading
Loading