1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-26 18:03:11 +08:00

Merge pull request #24966 from peppy/legacy-tick-test-coverage

Adjust slider ends to be more lenient during very fast sliders
This commit is contained in:
Dean Herbert 2023-10-20 18:10:37 +09:00 committed by GitHub
commit 6399e65f3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 297 additions and 78 deletions

View File

@ -51,8 +51,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
FinalRate = { Value = 1.3 } FinalRate = { Value = 1.3 }
}); });
[Test] [TestCase(6.25f)]
public void TestPerfectScoreOnShortSliderWithRepeat() [TestCase(20)]
public void TestPerfectScoreOnShortSliderWithRepeat(float pathLength)
{ {
AddStep("set score to standardised", () => LocalConfig.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); AddStep("set score to standardised", () => LocalConfig.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
@ -70,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Path = new SliderPath(new[] Path = new SliderPath(new[]
{ {
new PathControlPoint(), new PathControlPoint(),
new PathControlPoint(new Vector2(0, 6.25f)) new PathControlPoint(new Vector2(0, pathLength))
}), }),
RepeatCount = 1, RepeatCount = 1,
SliderVelocityMultiplier = 10 SliderVelocityMultiplier = 10

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -33,8 +34,21 @@ namespace osu.Game.Rulesets.Osu.Tests
switch (hitObject) switch (hitObject)
{ {
case Slider slider: case Slider slider:
var objects = new List<ConvertValue>();
foreach (var nested in slider.NestedHitObjects) foreach (var nested in slider.NestedHitObjects)
yield return createConvertValue((OsuHitObject)nested); objects.Add(createConvertValue((OsuHitObject)nested, slider));
// stable does slider tail leniency by offsetting the last tick 36ms back.
// based on player feedback, we're doing this a little different in lazer,
// and the lazer method does not require offsetting the last tick
// (see `DrawableSliderTail.CheckForResult()`).
// however, in conversion tests, just so the output matches, we're bringing
// the 36ms offset back locally.
// in particular, on some sliders, this may rearrange nested objects,
// so we sort them again by start time to prevent test failures.
foreach (var obj in objects.OrderBy(cv => cv.StartTime))
yield return obj;
break; break;
@ -44,14 +58,30 @@ namespace osu.Game.Rulesets.Osu.Tests
break; break;
} }
static ConvertValue createConvertValue(OsuHitObject obj) => new ConvertValue static ConvertValue createConvertValue(OsuHitObject obj, OsuHitObject? parent = null)
{ {
StartTime = obj.StartTime, double startTime = obj.StartTime;
EndTime = obj.GetEndTime(), double endTime = obj.GetEndTime();
// as stated in the inline comment above, this is locally bringing back
// the stable treatment of the "legacy last tick" just to make sure
// that the conversion output matches.
// compare: `SliderEventGenerator.Generate()`, and the calculation of `legacyLastTickTime`.
if (obj is SliderTailCircle && parent is Slider slider)
{
startTime = Math.Max(startTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2);
endTime = Math.Max(endTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2);
}
return new ConvertValue
{
StartTime = startTime,
EndTime = endTime,
X = obj.StackedPosition.X, X = obj.StackedPosition.X,
Y = obj.StackedPosition.Y Y = obj.StackedPosition.Y
}; };
} }
}
protected override Ruleset CreateRuleset() => new OsuRuleset(); protected override Ruleset CreateRuleset() => new OsuRuleset();
} }

View File

@ -17,16 +17,19 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase(6.7115569159190587d, 206, "diffcalc-test")] [TestCase(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")] [TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 2, "very-fast-slider")]
[TestCase(0.14102693012101306d, 1, "nan-slider")] [TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9757300665532966d, 206, "diffcalc-test")] [TestCase(8.9757300665532966d, 206, "diffcalc-test")]
[TestCase(0.55071082800473514d, 2, "very-fast-slider")]
[TestCase(1.7437232654020756d, 45, "zero-length-sliders")] [TestCase(1.7437232654020756d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7115569159190587d, 239, "diffcalc-test")] [TestCase(6.7115569159190587d, 239, "diffcalc-test")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(1.4391311903612753d, 54, "zero-length-sliders")] [TestCase(1.4391311903612753d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -33,7 +32,100 @@ namespace osu.Game.Rulesets.Osu.Tests
private const double time_during_slide_4 = 3800; private const double time_during_slide_4 = 3800;
private const double time_slider_end = 4000; private const double time_slider_end = 4000;
private List<JudgementResult> judgementResults; private ScoreAccessibleReplayPlayer currentPlayer = null!;
private const float slider_path_length = 25;
private readonly List<JudgementResult> judgementResults = new List<JudgementResult>();
// Making these too short causes breakage from frames not being processed fast enough.
// To keep things simple, these tests are crafted to always be >16ms length.
// If sliders shorter than this are ever used in gameplay it will probably break things and we can revisit.
[TestCase(30, 0)]
[TestCase(30, 1)]
[TestCase(40, 0)]
[TestCase(40, 1)]
[TestCase(50, 1)]
[TestCase(60, 1)]
[TestCase(70, 1)]
[TestCase(80, 1)]
[TestCase(80, 0)]
[TestCase(80, 10)]
[TestCase(90, 1)]
[Ignore("headless test doesn't run at high enough precision for this to always enter a tracking state in time.")]
public void TestVeryShortSlider(float sliderLength, int repeatCount)
{
Slider slider;
performTest(new List<ReplayFrame>
{
new OsuReplayFrame { Position = new Vector2(10, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start - 10 },
new OsuReplayFrame { Position = new Vector2(10, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start + 2000 },
}, slider = new Slider
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocityMultiplier = 10f,
RepeatCount = repeatCount,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(sliderLength, 0),
}),
}, 240, 1);
assertAllMaxJudgements();
// Even if the last tick is hit early, the slider should always execute its final judgement at its endtime.
// If not, hitsounds will not play on time.
AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0);
AddAssert("Slider judged at end time", () => judgementResults.Last().TimeAbsolute, () => Is.EqualTo(slider.EndTime));
AddAssert("Slider is last judgement", () => judgementResults[^1].HitObject, Is.TypeOf<Slider>);
AddAssert("Tail is second last judgement", () => judgementResults[^2].HitObject, Is.TypeOf<SliderTailCircle>);
}
[TestCase(300, false)]
[TestCase(200, true)]
[TestCase(150, true)]
[TestCase(120, true)]
[TestCase(60, true)]
[TestCase(10, true)]
[TestCase(0, true)]
[TestCase(-30, false)]
[Ignore("headless test doesn't run at high enough precision for this to always enter a tracking state in time.")]
public void TestTailLeniency(float finalPosition, bool hit)
{
Slider slider;
performTest(new List<ReplayFrame>
{
new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start },
new OsuReplayFrame { Position = new Vector2(finalPosition, slider_path_length * 3), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start + 20 },
}, slider = new Slider
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocityMultiplier = 10f,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(slider_path_length * 10, 0),
new Vector2(slider_path_length * 10, slider_path_length * 3),
new Vector2(0, slider_path_length * 3),
}),
}, 240, 1);
if (hit)
assertAllMaxJudgements();
else
AddAssert("Tracking dropped", assertMidSliderJudgementFail);
// Even if the last tick is hit early, the slider should always execute its final judgement at its endtime.
// If not, hitsounds will not play on time.
AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0);
AddAssert("Slider judged at end time", () => judgementResults.Last().TimeAbsolute, () => Is.EqualTo(slider.EndTime));
}
[Test] [Test]
public void TestPressBothKeysSimultaneouslyAndReleaseOne() public void TestPressBothKeysSimultaneouslyAndReleaseOne()
@ -44,7 +136,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -86,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -107,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -128,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -301,7 +393,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.199f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.199f), Actions = { OsuAction.LeftButton }, Time = time_slider_end },
}); });
AddAssert("Tracking kept", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -325,7 +417,13 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Tracking dropped", assertMidSliderJudgementFail); AddAssert("Tracking dropped", assertMidSliderJudgementFail);
} }
private bool assertMaxJudge() => judgementResults.Any() && judgementResults.All(t => t.Type == t.Judgement.MaxResult); private void assertAllMaxJudgements()
{
AddAssert("All judgements max", () =>
{
return judgementResults.Select(j => (j.HitObject, j.Type));
}, () => Is.EqualTo(judgementResults.Select(j => (j.HitObject, j.Judgement.MaxResult))));
}
private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit; private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit;
@ -333,19 +431,9 @@ namespace osu.Game.Rulesets.Osu.Tests
private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss; private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss;
private ScoreAccessibleReplayPlayer currentPlayer; private void performTest(List<ReplayFrame> frames, Slider? slider = null, double? bpm = null, int? tickRate = null)
private const float slider_path_length = 25;
private void performTest(List<ReplayFrame> frames)
{ {
AddStep("load player", () => slider ??= new Slider
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Slider
{ {
StartTime = time_slider_start, StartTime = time_slider_start,
Position = new Vector2(0, 0), Position = new Vector2(0, 0),
@ -355,13 +443,24 @@ namespace osu.Game.Rulesets.Osu.Tests
Vector2.Zero, Vector2.Zero,
new Vector2(slider_path_length, 0), new Vector2(slider_path_length, 0),
}, slider_path_length), }, slider_path_length),
} };
},
AddStep("load player", () =>
{
var cpi = new ControlPointInfo();
if (bpm != null)
cpi.Add(0, new TimingControlPoint { BeatLength = 60000 / bpm.Value });
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects = { slider },
BeatmapInfo = BeatmapInfo =
{ {
Difficulty = new BeatmapDifficulty { SliderTickRate = 3 }, Difficulty = new BeatmapDifficulty { SliderTickRate = tickRate ?? 3 },
Ruleset = new OsuRuleset().RulesetInfo Ruleset = new OsuRuleset().RulesetInfo,
}, },
ControlPointInfo = cpi,
}); });
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
@ -375,7 +474,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}; };
LoadScreen(currentPlayer = p); LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>(); judgementResults.Clear();
}); });
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and slider difficulty.</description></item> /// <item><description>and slider difficulty.</description></item>
/// </list> /// </list>
/// </summary> /// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliders) public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{ {
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0; return 0;
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object. // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliders) if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{ {
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// As above, do the same for the previous hitobject. // As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime;
if (osuLastLastObj.BaseObject is Slider && withSliders) if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{ {
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier);
// Add in additional slider velocity bonus. // Add in additional slider velocity bonus.
if (withSliders) if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier; aimStrain += sliderBonus * slider_multiplier;
return aimStrain; return aimStrain;

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
@ -214,7 +215,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (slider.LazyEndPosition != null) if (slider.LazyEndPosition != null)
return; return;
slider.LazyTravelTime = slider.NestedHitObjects[^1].StartTime - slider.StartTime; // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from
// difficulty calculator to preserve known behaviour.
// double trackingEndTime = Math.Max(
// // SliderTailCircle always occurs at the final end time of the slider, but the player only needs to hold until within a lenience before it.
// slider.Duration + SliderEventGenerator.TAIL_LENIENCY,
// // There's an edge case where one or more ticks/repeats fall within that leniency range.
// // In such a case, the player needs to track until the final tick or repeat.
// slider.NestedHitObjects.LastOrDefault(n => n is not SliderTailCircle)?.StartTime ?? double.MinValue
// );
double trackingEndTime = Math.Max(
slider.StartTime + slider.Duration + SliderEventGenerator.TAIL_LENIENCY,
slider.StartTime + slider.Duration / 2
);
IList<HitObject> nestedObjects = slider.NestedHitObjects;
SliderTick? lastRealTick = slider.NestedHitObjects.OfType<SliderTick>().LastOrDefault();
if (lastRealTick?.StartTime > trackingEndTime)
{
trackingEndTime = lastRealTick.StartTime;
// When the last tick falls after the tracking end time, we need to re-sort the nested objects
// based on time. This creates a somewhat weird ordering which is counter to how a user would
// understand the slider, but allows a zero-diff with known diffcalc output.
//
// To reiterate, this is definitely not correct from a difficulty calculation perspective
// and should be revisited at a later date (likely by replacing this whole code with the commented
// version above).
List<HitObject> reordered = nestedObjects.ToList();
reordered.Remove(lastRealTick);
reordered.Add(lastRealTick);
nestedObjects = reordered;
}
slider.LazyTravelTime = trackingEndTime - slider.StartTime;
double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; double endTimeMin = slider.LazyTravelTime / slider.SpanDuration;
if (endTimeMin % 2 >= 1) if (endTimeMin % 2 >= 1)
@ -223,12 +262,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
endTimeMin %= 1; endTimeMin %= 1;
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
var currCursorPosition = slider.StackedPosition;
Vector2 currCursorPosition = slider.StackedPosition;
double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
for (int i = 1; i < slider.NestedHitObjects.Count; i++) for (int i = 1; i < nestedObjects.Count; i++)
{ {
var currMovementObj = (OsuHitObject)slider.NestedHitObjects[i]; var currMovementObj = (OsuHitObject)nestedObjects[i];
Vector2 currMovement = Vector2.Subtract(currMovementObj.StackedPosition, currCursorPosition); Vector2 currMovement = Vector2.Subtract(currMovementObj.StackedPosition, currCursorPosition);
double currMovementLength = scalingFactor * currMovement.Length; double currMovementLength = scalingFactor * currMovement.Length;
@ -236,7 +277,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Amount of movement required so that the cursor position needs to be updated. // Amount of movement required so that the cursor position needs to be updated.
double requiredMovement = assumed_slider_radius; double requiredMovement = assumed_slider_radius;
if (i == slider.NestedHitObjects.Count - 1) if (i == nestedObjects.Count - 1)
{ {
// The end of a slider has special aim rules due to the relaxed time constraint on position. // The end of a slider has special aim rules due to the relaxed time constraint on position.
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
@ -263,7 +304,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
slider.LazyTravelDistance += (float)currMovementLength; slider.LazyTravelDistance += (float)currMovementLength;
} }
if (i == slider.NestedHitObjects.Count - 1) if (i == nestedObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition; slider.LazyEndPosition = currCursorPosition;
} }
} }

View File

@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods
}); });
break; break;
case SliderEventType.LastTick: case SliderEventType.Tail:
AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,

View File

@ -264,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
if (userTriggered || Time.Current < HitObject.EndTime) if (userTriggered || !TailCircle.Judged || Time.Current < HitObject.EndTime)
return; return;
// If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes. // If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes.

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.Skinning.Default;
@ -153,9 +154,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Tracking = Tracking =
// in valid time range // in valid time range
Time.Current >= drawableSlider.HitObject.StartTime && Time.Current < drawableSlider.HitObject.EndTime && Time.Current >= drawableSlider.HitObject.StartTime
// even in an edge case where current time has exceeded the slider's time, we may not have finished judging.
// we don't want to potentially update from Tracking=true to Tracking=false at this point.
&& (!drawableSlider.AllJudged || Time.Current <= drawableSlider.HitObject.GetEndTime())
// in valid position range // in valid position range
lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && && lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) &&
// valid action // valid action
(actions?.Any(isValidTrackingAction) ?? false); (actions?.Any(isValidTrackingAction) ?? false);

View File

@ -8,6 +8,7 @@ using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -125,8 +126,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
if (!userTriggered && timeOffset >= 0) if (userTriggered)
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); return;
// Ensure the tail can only activate after all previous ticks already have.
//
// This covers the edge case where the lenience may allow the tail to activate before
// the last tick, changing ordering of score/combo awarding.
if (DrawableSlider.NestedHitObjects.Count > 1 && !DrawableSlider.NestedHitObjects[^2].Judged)
return;
// The player needs to have engaged in tracking at any point after the tail leniency cutoff.
// An actual tick miss should only occur if reaching the tick itself.
if (timeOffset >= SliderEventGenerator.TAIL_LENIENCY && Tracking)
ApplyResult(r => r.Type = r.Judgement.MaxResult);
else if (timeOffset > 0)
ApplyResult(r => r.Type = r.Judgement.MinResult);
} }
protected override void OnApply() protected override void OnApply()

View File

@ -204,11 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects
}); });
break; break;
case SliderEventType.LastTick: case SliderEventType.Tail:
// Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle.
// It is required as difficulty calculation and gameplay relies on reading this value.
// (although it is displayed in classic skins, which may be a concern).
// If this is to change, we should revisit this.
AddNested(TailCircle = new SliderTailCircle(this) AddNested(TailCircle = new SliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,

View File

@ -2,16 +2,11 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Objects namespace osu.Game.Rulesets.Osu.Objects
{ {
/// <summary>
/// Note that this should not be used for timing correctness.
/// See <see cref="SliderEventType.LastTick"/> usage in <see cref="Slider"/> for more information.
/// </summary>
public class SliderTailCircle : SliderEndCircle public class SliderTailCircle : SliderEndCircle
{ {
public SliderTailCircle(Slider slider) public SliderTailCircle(Slider slider)

View File

@ -0,0 +1,21 @@
osu file format v128
[Difficulty]
HPDrainRate: 3
CircleSize: 4
OverallDifficulty: 9
ApproachRate: 9.3
SliderMultiplier: 3.59999990463257
SliderTickRate: 1
[TimingPoints]
812,342.857142857143,4,1,1,70,1,0
57383,-28.5714285714286,4,1,1,70,0,0
[HitObjects]
// Taken from https://osu.ppy.sh/beatmapsets/881996#osu/1844019
// This slider is 42 ms in length, triggering the LegacyLastTick edge case.
// The tick will be at 21.5 ms (sliderDuration / 2) instead of 6 ms (sliderDuration - LAST_TICK_LENIENCE).
416,41,57383,6,0,L|467:217,1,157.499997329712,2|0,3:3|3:0,3:0:0:0:
// Include the next slider as well to cover the jump back to the start position.
407,73,57469,2,0,L|470:215,1,129.599999730835,2|0,0:0|0:0,0:0:0:0:

View File

@ -87,8 +87,8 @@ namespace osu.Game.Tests.Beatmaps
{ {
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray();
Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick)); Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick));
Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET)); Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.TAIL_LENIENCY));
} }
[Test] [Test]

View File

@ -16,8 +16,12 @@ namespace osu.Game.Rulesets.Objects
/// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object. /// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object.
/// ///
/// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way. /// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way.
/// These days, this is implemented in the drawable implementation of Slider in the osu! ruleset.
///
/// We need to keep the <see cref="SliderEventType.LegacyLastTick"/> *only* for osu!catch conversion, which relies on it to generate tiny ticks
/// correctly.
/// </summary> /// </summary>
public const double LAST_TICK_OFFSET = -36; public const double TAIL_LENIENCY = -36;
public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@ -84,18 +88,27 @@ namespace osu.Game.Rulesets.Objects
int finalSpanIndex = spanCount - 1; int finalSpanIndex = spanCount - 1;
double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; double finalSpanStartTime = startTime + finalSpanIndex * spanDuration;
double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + LAST_TICK_OFFSET);
double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration;
if (spanCount % 2 == 0) finalProgress = 1 - finalProgress; // Note that `finalSpanStartTime + spanDuration ≈ startTime + totalDuration`, but we write it like this to match floating point precision
// of stable.
//
// So thinking about this in a saner way, the time of the LegacyLastTick is
//
// `slider.StartTime + max(slider.Duration / 2, slider.Duration - 36)`
//
// As a slider gets shorter than 72 ms, the leniency offered falls below the 36 ms `TAIL_LENIENCY` constant.
double legacyLastTickTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + TAIL_LENIENCY);
double legacyLastTickProgress = (legacyLastTickTime - finalSpanStartTime) / spanDuration;
if (spanCount % 2 == 0) legacyLastTickProgress = 1 - legacyLastTickProgress;
yield return new SliderEventDescriptor yield return new SliderEventDescriptor
{ {
Type = SliderEventType.LastTick, Type = SliderEventType.LegacyLastTick,
SpanIndex = finalSpanIndex, SpanIndex = finalSpanIndex,
SpanStartTime = finalSpanStartTime, SpanStartTime = finalSpanStartTime,
Time = finalSpanEndTime, Time = legacyLastTickTime,
PathProgress = finalProgress, PathProgress = legacyLastTickProgress,
}; };
yield return new SliderEventDescriptor yield return new SliderEventDescriptor
@ -183,9 +196,10 @@ namespace osu.Game.Rulesets.Objects
Tick, Tick,
/// <summary> /// <summary>
/// Occurs just before the tail. See <see cref="SliderEventGenerator.LAST_TICK_OFFSET"/>. /// Occurs just before the tail. See <see cref="SliderEventGenerator.TAIL_LENIENCY"/>.
/// Should generally be ignored.
/// </summary> /// </summary>
LastTick, LegacyLastTick,
Head, Head,
Tail, Tail,
Repeat Repeat