diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs index 00b79529a9..c717f03f51 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModDoubleTime.cs @@ -17,12 +17,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods { private const double offset = 18; + protected override bool AllowFail => true; + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); [Test] public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData { - PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value != 1, + PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 + && Player.ScoreProcessor.Accuracy.Value == 1 + && Player.ScoreProcessor.TotalScore.Value == 1_000_000, Autoplay = false, Beatmap = new Beatmap { @@ -40,24 +44,31 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods }); [Test] - public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData + public void TestHitWindowWithDoubleTime() { - Mod = new ManiaModDoubleTime(), - PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1, - Autoplay = false, - Beatmap = new Beatmap + var doubleTime = new ManiaModDoubleTime(); + + CreateModTest(new ModTestData { - BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, - Difficulty = { OverallDifficulty = 10 }, - HitObjects = new List<HitObject> + Mod = doubleTime, + PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 + && Player.ScoreProcessor.Accuracy.Value == 1 + && Player.ScoreProcessor.TotalScore.Value == (long)(1_000_010 * doubleTime.ScoreMultiplier), + Autoplay = false, + Beatmap = new Beatmap { - new Note { StartTime = 1000 } + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + Difficulty = { OverallDifficulty = 10 }, + HitObjects = new List<HitObject> + { + new Note { StartTime = 1000 } + }, }, - }, - ReplayFrames = new List<ReplayFrame> - { - new ManiaReplayFrame(1000 + offset, ManiaAction.Key1) - } - }); + ReplayFrames = new List<ReplayFrame> + { + new ManiaReplayFrame(1000 + offset, ManiaAction.Key1) + } + }); + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 93128c512f..044ce37832 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -200,10 +200,12 @@ namespace osu.Game.Rulesets.Mania.Tests }); assertHeadJudgement(HitResult.Perfect); - assertComboAtJudgement(0, 1); + // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult. + assertComboAtJudgement(1, 1); assertTailJudgement(HitResult.Meh); - assertComboAtJudgement(1, 0); - assertComboAtJudgement(2, 1); + assertComboAtJudgement(2, 0); + // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult. + assertComboAtJudgement(4, 1); } /// <summary> @@ -380,7 +382,8 @@ namespace osu.Game.Rulesets.Mania.Tests [Test] public void TestPressAndReleaseJustAfterTailWithNearbyNote() { - Note note; + // Next note within tail lenience + Note note = new Note { StartTime = time_tail + 50 }; var beatmap = new Beatmap<ManiaHitObject> { @@ -392,13 +395,7 @@ namespace osu.Game.Rulesets.Mania.Tests Duration = time_tail - time_head, Column = 0, }, - { - // Next note within tail lenience - note = new Note - { - StartTime = time_tail + 50 - } - } + note }, BeatmapInfo = { diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs index 3d0abaceb5..edf866952b 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests AddAssert("all objects perfectly judged", () => judgementResults.Select(result => result.Type), () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); - AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000)); + AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_030)); } [Test] @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests AddAssert("all objects perfectly judged", () => judgementResults.Select(result => result.Type), () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); - AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000)); + AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040)); } private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames) diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index ff34b69d19..0055e10ada 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -385,6 +385,9 @@ namespace osu.Game.Rulesets.Mania HitResult.Good, HitResult.Ok, HitResult.Meh, + + // HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as + // it would be a bit redundant to show this to the user. }; } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index a559e91f1b..79002b3819 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -3,7 +3,6 @@ #nullable disable -using System.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Scoring; @@ -33,35 +32,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public void UpdateResult() => base.UpdateResult(true); - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - Debug.Assert(HitObject.HitWindows != null); - + protected override void CheckForResult(bool userTriggered, double timeOffset) => // Factor in the release lenience - timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE; + base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE); - if (!userTriggered) - { - if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = r.Judgement.MinResult); + protected override HitResult GetCappedResult(HitResult result) + { + // If the head wasn't hit or the hold note was broken, cap the max score to Meh. + bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak; - return; - } + if (result > HitResult.Meh && hasComboBreak) + return HitResult.Meh; - var result = HitObject.HitWindows.ResultFor(timeOffset); - if (result == HitResult.None) - return; - - ApplyResult(r => - { - // If the head wasn't hit or the hold note was broken, cap the max score to Meh. - bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak; - - if (result > HitResult.Meh && hasComboBreak) - result = HitResult.Meh; - - r.Type = result; - }); + return result; } public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index 0819e8401c..c70dfcb761 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -13,6 +13,8 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit; @@ -38,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private Drawable headPiece; + private DrawableNotePerfectBonus perfectBonus; + public DrawableNote() : this(null) { @@ -89,7 +93,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (!userTriggered) { if (!HitObject.HitWindows.CanBeHit(timeOffset)) + { + perfectBonus.TriggerResult(false); ApplyResult(r => r.Type = r.Judgement.MinResult); + } + return; } @@ -97,9 +105,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (result == HitResult.None) return; + result = GetCappedResult(result); + + perfectBonus.TriggerResult(result == HitResult.Perfect); ApplyResult(r => r.Type = result); } + public override void MissForcefully() + { + perfectBonus.TriggerResult(false); + base.MissForcefully(); + } + + /// <summary> + /// Some objects in mania may want to limit the max result. + /// </summary> + protected virtual HitResult GetCappedResult(HitResult result) => result; + public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e) { if (e.Action != Action.Value) @@ -115,6 +137,32 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { } + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + switch (hitObject) + { + case DrawableNotePerfectBonus bonus: + AddInternal(perfectBonus = bonus); + break; + } + } + + protected override void ClearNestedHitObjects() + { + RemoveInternal(perfectBonus, false); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case NotePerfectBonus bonus: + return new DrawableNotePerfectBonus(bonus); + } + + return base.CreateNestedHitObject(hitObject); + } + private void updateSnapColour() { if (beatmap == null || HitObject == null) return; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs new file mode 100644 index 0000000000..70ddb60296 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNotePerfectBonus.cs @@ -0,0 +1,26 @@ +// 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. + +namespace osu.Game.Rulesets.Mania.Objects.Drawables +{ + public partial class DrawableNotePerfectBonus : DrawableManiaHitObject<NotePerfectBonus> + { + public override bool DisplayResult => false; + + public DrawableNotePerfectBonus() + : this(null!) + { + } + + public DrawableNotePerfectBonus(NotePerfectBonus hitObject) + : base(hitObject) + { + } + + /// <summary> + /// Apply a judgement result. + /// </summary> + /// <param name="hit">Whether this tick was reached.</param> + internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult); + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Note.cs b/osu.Game.Rulesets.Mania/Objects/Note.cs index 0035960c63..5914132624 100644 --- a/osu.Game.Rulesets.Mania/Objects/Note.cs +++ b/osu.Game.Rulesets.Mania/Objects/Note.cs @@ -1,6 +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. +using System.Threading; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Judgements; @@ -12,5 +13,12 @@ namespace osu.Game.Rulesets.Mania.Objects public class Note : ManiaHitObject { public override Judgement CreateJudgement() => new ManiaJudgement(); + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); + + AddNested(new NotePerfectBonus { StartTime = StartTime }); + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs b/osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs new file mode 100644 index 0000000000..def4c01268 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/NotePerfectBonus.cs @@ -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.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects +{ + public class NotePerfectBonus : ManiaHitObject + { + public override Judgement CreateJudgement() => new NotePerfectBonusJudgement(); + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public class NotePerfectBonusJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.SmallBonus; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs index 660f72e565..66e67136df 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs @@ -123,9 +123,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state) { - // ensure that the hold note is also faded out when the head/tail/any tick is missed. - if (state == ArmedState.Miss) - missFadeTime.Value ??= hitObject.HitStateUpdateTime; + switch (hitObject) + { + // Ensure that the hold note is also faded out when the head/tail/body is missed. + // Importantly, we filter out unrelated objects like DrawableNotePerfectBonus. + case DrawableHoldNoteTail: + case DrawableHoldNoteHead: + case DrawableHoldNoteBody: + if (state == ArmedState.Miss) + missFadeTime.Value ??= hitObject.HitStateUpdateTime; + + break; + } } private void onIsHittingChanged(ValueChangedEvent<bool> isHitting) diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 6cd55bb099..9489281176 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -109,6 +109,7 @@ namespace osu.Game.Rulesets.Mania.UI TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); RegisterPool<Note, DrawableNote>(10, 50); + RegisterPool<NotePerfectBonus, DrawableNotePerfectBonus>(10, 50); RegisterPool<HoldNote, DrawableHoldNote>(10, 50); RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50); RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50); diff --git a/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs new file mode 100644 index 0000000000..e6dc72033a --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/SpinFramesGenerator.cs @@ -0,0 +1,111 @@ +// 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 osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class SpinFramesGenerator + { + /// <summary> + /// A small amount to spin beyond a given angle to mitigate floating-point precision errors. + /// </summary> + public const float SPIN_ERROR = MathF.PI / 8; + + /// <summary> + /// The offset from the centre of the spinner at which to spin. + /// </summary> + private const float centre_spin_offset = 50; + + private readonly double startTime; + private readonly float startAngle; + private readonly List<(float deltaAngle, double duration)> sequences = new List<(float deltaAngle, double duration)>(); + + /// <summary> + /// Creates a new <see cref="SpinFramesGenerator"/> that can be used to generate spinner spin frames. + /// </summary> + /// <param name="startTime">The time at which to start spinning.</param> + /// <param name="startAngle">The angle, in radians, at which to start spinning from. Defaults to the positive-y-axis.</param> + public SpinFramesGenerator(double startTime, float startAngle = -MathF.PI / 2f) + { + this.startTime = startTime; + this.startAngle = startAngle; + } + + /// <summary> + /// Performs a single spin. + /// </summary> + /// <param name="delta">The amount of degrees to spin.</param> + /// <param name="duration">The time to spend to perform the spin.</param> + /// <returns>This <see cref="SpinFramesGenerator"/>.</returns> + public SpinFramesGenerator Spin(float delta, double duration) + { + sequences.Add((delta / 360 * 2 * MathF.PI, duration)); + return this; + } + + /// <summary> + /// Constructs the replay frames. + /// </summary> + /// <returns>The replay frames.</returns> + public List<ReplayFrame> Build() + { + List<ReplayFrame> frames = new List<ReplayFrame>(); + + double lastTime = startTime; + float lastAngle = startAngle; + int lastDirection = 0; + + for (int i = 0; i < sequences.Count; i++) + { + var seq = sequences[i]; + + int seqDirection = Math.Sign(seq.deltaAngle); + float seqError = SPIN_ERROR * seqDirection; + + if (seqDirection == lastDirection) + { + // Spinning in the same direction, but the error was already added in the last rotation. + seqError = 0; + } + else if (lastDirection != 0) + { + // Spinning in a different direction, we need to account for the error of the start angle, so double it. + seqError *= 2; + } + + double seqStartTime = lastTime; + double seqEndTime = lastTime + seq.duration; + float seqStartAngle = lastAngle; + float seqEndAngle = seqStartAngle + seq.deltaAngle + seqError; + + // Intermediate spin frames. + for (; lastTime < seqEndTime; lastTime += 10) + frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt((lastTime - seqStartTime) / (seqEndTime - seqStartTime), seqStartAngle, seqEndAngle), OsuAction.LeftButton)); + + // Final frame at the end of the current spin. + frames.Add(new OsuReplayFrame(seqEndTime, calcOffsetAt(1, seqStartAngle, seqEndAngle), OsuAction.LeftButton)); + + lastTime = seqEndTime; + lastAngle = seqEndAngle; + lastDirection = seqDirection; + } + + // Key release frame. + if (frames.Count > 0) + frames.Add(new OsuReplayFrame(frames[^1].Time, ((OsuReplayFrame)frames[^1]).Position)); + + return frames; + } + + private static Vector2 calcOffsetAt(double p, float startAngle, float endAngle) + { + float angle = startAngle + (endAngle - startAngle) * (float)p; + return new Vector2(256, 192) + centre_spin_offset * new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index a2ef72fe57..fa6aa580a3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -356,15 +356,16 @@ namespace osu.Game.Rulesets.Osu.Tests }, }; - performTest(hitObjects, new List<ReplayFrame> + List<ReplayFrame> frames = new List<ReplayFrame> { new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } }, - new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, - }); + }; + + frames.AddRange(new SpinFramesGenerator(time_spinner + 10) + .Spin(360, 500) + .Build()); + + performTest(hitObjects, frames); addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Meh); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs index 1ae17432be..dae81f4cff 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("rotate some", () => dho.RotationTracker.AddRotation(180)); - AddAssert("rotation is set", () => dho.Result.RateAdjustedRotation == 180); + AddAssert("rotation is set", () => dho.Result.TotalRotation == 180); AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner { @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests Duration = 1000, }))); - AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0); + AddAssert("rotation is reset", () => dho.Result.TotalRotation == 0); } private Spinner prepareObject(Spinner circle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs new file mode 100644 index 0000000000..c4bf0d4e2e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs @@ -0,0 +1,290 @@ +// 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 NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneSpinnerInput : RateAdjustedBeatmapTestScene + { + private const int centre_x = 256; + private const int centre_y = 192; + private const double time_spinner_start = 1500; + private const double time_spinner_end = 8000; + + private readonly List<JudgementResult> judgementResults = new List<JudgementResult>(); + + private ScoreAccessibleReplayPlayer currentPlayer = null!; + private ManualClock? manualClock; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + { + return manualClock == null + ? base.CreateWorkingBeatmap(beatmap, storyboard) + : new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(manualClock), Audio); + } + + [SetUp] + public void Setup() => Schedule(() => + { + manualClock = null; + }); + + /// <summary> + /// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms. + /// </summary> + [Test] + [Ignore("An upcoming implementation will fix this case")] + public void TestVibrateWithoutSpinningOffCentre() + { + List<ReplayFrame> frames = new List<ReplayFrame>(); + + const int vibrate_time = 50; + const float y_pos = centre_y - 50; + + int direction = -1; + + for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time) + { + frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, y_pos), OsuAction.LeftButton)); + frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, y_pos), OsuAction.LeftButton)); + + direction *= -1; + } + + performTest(frames); + + assertTicksHit(0); + assertSpinnerHit(false); + } + + /// <summary> + /// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms. + /// </summary> + [Test] + [Ignore("An upcoming implementation will fix this case")] + public void TestVibrateWithoutSpinningOnCentre() + { + List<ReplayFrame> frames = new List<ReplayFrame>(); + + const int vibrate_time = 50; + + int direction = -1; + + for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time) + { + frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, centre_y), OsuAction.LeftButton)); + frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, centre_y), OsuAction.LeftButton)); + + direction *= -1; + } + + performTest(frames); + + assertTicksHit(0); + assertSpinnerHit(false); + } + + /// <summary> + /// Spins in a single direction. + /// </summary> + [TestCase(180, 0)] + [TestCase(-180, 0)] + [TestCase(360, 1)] + [TestCase(-360, 1)] + [TestCase(540, 1)] + [TestCase(-540, 1)] + [TestCase(720, 2)] + [TestCase(-720, 2)] + public void TestSpinSingleDirection(float amount, int expectedTicks) + { + performTest(new SpinFramesGenerator(time_spinner_start) + .Spin(amount, 500) + .Build()); + + assertTicksHit(expectedTicks); + assertSpinnerHit(false); + } + + /// <summary> + /// Spin half-way clockwise then perform one full spin counter-clockwise. + /// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW). + /// </summary> + [Test] + [Ignore("An upcoming implementation will fix this case")] + public void TestSpinHalfBothDirections() + { + performTest(new SpinFramesGenerator(time_spinner_start) + .Spin(180, 500) // Rotate to +0.5. + .Spin(-360, 500) // Rotate to -0.5 + .Build()); + + assertTicksHit(0); + assertSpinnerHit(false); + } + + /// <summary> + /// Spin in one direction then spin in the other. + /// </summary> + [TestCase(180, -540, 1)] + [TestCase(-180, 540, 1)] + [TestCase(180, -900, 2)] + [TestCase(-180, 900, 2)] + [Ignore("An upcoming implementation will fix this case")] + public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks) + { + performTest(new SpinFramesGenerator(time_spinner_start) + .Spin(direction1, 500) + .Spin(direction2, 500) + .Build()); + + assertTicksHit(expectedTicks); + assertSpinnerHit(false); + } + + [Test] + [Ignore("An upcoming implementation will fix this case")] + public void TestRewind() + { + AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 }); + + List<ReplayFrame> frames = new SpinFramesGenerator(time_spinner_start) + .Spin(360, 500) // 2000ms -> 1 full CW spin + .Spin(-180, 500) // 2500ms -> 0.5 CCW spins + .Spin(90, 500) // 3000ms -> 0.25 CW spins + .Spin(450, 500) // 3500ms -> 1 full CW spin + .Spin(180, 500) // 4000ms -> 0.5 CW spins + .Build(); + + loadPlayer(frames); + + GameplayClockContainer clock = null!; + DrawableRuleset drawableRuleset = null!; + AddStep("get gameplay objects", () => + { + clock = currentPlayer.ChildrenOfType<GameplayClockContainer>().Single(); + drawableRuleset = currentPlayer.ChildrenOfType<DrawableRuleset>().Single(); + }); + + addSeekStep(frames.Last().Time); + + DrawableSpinner drawableSpinner = null!; + AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null); + + assertTotalRotation(4000, 900); + assertTotalRotation(3750, 810); + assertTotalRotation(3500, 720); + assertTotalRotation(3250, 530); + assertTotalRotation(3000, 540); + assertTotalRotation(2750, 540); + assertTotalRotation(2500, 540); + assertTotalRotation(2250, 360); + assertTotalRotation(2000, 180); + assertTotalRotation(1500, 0); + + void assertTotalRotation(double time, float expected) + { + addSeekStep(time); + AddAssert($"total rotation @ {time} is {expected}", () => drawableSpinner.Result.TotalRotation, + () => Is.EqualTo(expected).Within(MathHelper.RadiansToDegrees(SpinFramesGenerator.SPIN_ERROR * 2))); + } + + void addSeekStep(double time) + { + AddStep($"seek to {time}", () => clock.Seek(time)); + AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time)); + } + } + + private void assertTicksHit(int count) + { + AddAssert($"{count} ticks hit", () => judgementResults.Where(r => r.HitObject is SpinnerTick).Count(r => r.IsHit), () => Is.EqualTo(count)); + } + + private void assertSpinnerHit(bool shouldBeHit) + { + AddAssert($"spinner is {(shouldBeHit ? "hit" : "missed")}", () => judgementResults.Single(r => r.HitObject is Spinner).IsHit, () => Is.EqualTo(shouldBeHit)); + } + + private void loadPlayer(List<ReplayFrame> frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject> + { + HitObjects = + { + new Spinner + { + StartTime = time_spinner_start, + EndTime = time_spinner_end, + Position = new Vector2(centre_x, centre_y) + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty(), + Ruleset = new OsuRuleset().RulesetInfo + }, + }); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults.Clear(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + } + + private void performTest(List<ReplayFrame> frames) + { + loadPlayer(frames); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs index c969cb11b4..8d8c2e9639 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs @@ -1,7 +1,6 @@ // 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 NUnit.Framework; @@ -11,14 +10,12 @@ using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Tests.Visual; -using osuTK; namespace osu.Game.Rulesets.Osu.Tests { @@ -59,26 +56,9 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult)); } - private static List<ReplayFrame> generateReplay(int spins) - { - var replayFrames = new List<ReplayFrame>(); - - const int frames_per_spin = 30; - - for (int i = 0; i < spins * frames_per_spin; ++i) - { - float totalProgress = i / (float)(spins * frames_per_spin); - float spinProgress = (i % frames_per_spin) / (float)frames_per_spin; - double time = time_spinner_start + (time_spinner_end - time_spinner_start) * totalProgress; - float posX = MathF.Cos(2 * MathF.PI * spinProgress); - float posY = MathF.Sin(2 * MathF.PI * spinProgress); - Vector2 finalPos = OsuPlayfield.BASE_SIZE / 2 + new Vector2(posX, posY) * 50; - - replayFrames.Add(new OsuReplayFrame(time, finalPos, OsuAction.LeftButton)); - } - - return replayFrames; - } + private static List<ReplayFrame> generateReplay(int spins) => new SpinFramesGenerator(time_spinner_start) + .Spin(spins * 360, time_spinner_end - time_spinner_start) + .Build(); private void performTest(List<ReplayFrame> frames) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 116c974f32..8711aa9c09 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -63,11 +63,11 @@ namespace osu.Game.Rulesets.Osu.Tests trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); }); AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100)); - AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100)); + AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.Not.EqualTo(0).Within(100)); addSeekStep(0); AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance)); - AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100)); + AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(0).Within(100)); } [Test] @@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Tests finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); }); - AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); + AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.TotalRotation); addSeekStep(spinner_start_time + 2500); AddAssert("disc rotation rewound", @@ -92,13 +92,13 @@ namespace osu.Game.Rulesets.Osu.Tests () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation rewound", // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. - () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100)); + () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100)); addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation almost same", - () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100)); + () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100)); } [Test] @@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Tests { // multipled by 2 to nullify the score multiplier. (autoplay mod selected) long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; - return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; + return totalScore == (int)(drawableSpinner.Result.TotalRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; }); addSeekStep(0); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index f0af3f0c39..3475680c71 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -284,15 +284,16 @@ namespace osu.Game.Rulesets.Osu.Tests }, }; - performTest(hitObjects, new List<ReplayFrame> + List<ReplayFrame> frames = new List<ReplayFrame> { new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, - new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, - new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, - }); + }; + + frames.AddRange(new SpinFramesGenerator(time_spinner + 10) + .Spin(360, 500) + .Build()); + + performTest(hitObjects, frames); addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Great); diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index 941cb667cf..c5e15d63ea 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Judgements /// If Double Time is active instead (with a speed multiplier of 1.5x), /// in the same scenario the property will return 720 * 1.5 = 1080. /// </example> - public float RateAdjustedRotation; + public float TotalRotation; /// <summary> /// Time instant at which the spin was started (the first user input which caused an increase in spin). diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 24446db92a..9fa180cf93 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -218,7 +218,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // these become implicitly hit. return 1; - return Math.Clamp(Result.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1); + return Math.Clamp(Result.TotalRotation / 360 / HitObject.SpinsRequired, 0, 1); } } @@ -279,7 +279,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // don't update after end time to avoid the rate display dropping during fade out. // this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period. if (Time.Current <= HitObject.EndTime) - spmCalculator.SetRotation(Result.RateAdjustedRotation); + spmCalculator.SetRotation(Result.TotalRotation); updateBonusScore(); } @@ -293,7 +293,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (ticks.Count == 0) return; - int spins = (int)(Result.RateAdjustedRotation / 360); + int spins = (int)(Result.TotalRotation / 360); if (spins < completedFullSpins) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs index bdc93eb63f..079758c21e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon { get { - int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); + int rotations = (int)(drawableSpinner.Result.TotalRotation / 360); if (wholeRotationCount == rotations) return false; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs index 75f3247448..b498975a83 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs @@ -200,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { get { - int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); + int rotations = (int)(drawableSpinner.Result.TotalRotation / 360); if (wholeRotationCount == rotations) return false; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 719cf57d98..69c2bf3dd0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -22,11 +23,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private readonly DrawableSpinner drawableSpinner; - private Vector2 mousePosition; + private Vector2? mousePosition; + private float? lastAngle; - private float lastAngle; private float currentRotation; - private bool rotationTransferred; [Resolved(canBeNull: true)] @@ -63,17 +63,23 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default protected override void Update() { base.Update(); - float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)); - float delta = thisAngle - lastAngle; + if (mousePosition is Vector2 pos) + { + float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(pos.X - DrawSize.X / 2, pos.Y - DrawSize.Y / 2)); + float delta = lastAngle == null ? 0 : thisAngle - lastAngle.Value; - if (Tracking) - AddRotation(delta); + // Normalise the delta to -180 .. 180 + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; - lastAngle = thisAngle; + if (Tracking) + AddRotation(delta); + + lastAngle = thisAngle; + } IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f; - Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed)); } @@ -83,41 +89,37 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// <remarks> /// Will be a no-op if not a valid time to spin. /// </remarks> - /// <param name="angle">The delta angle.</param> - public void AddRotation(float angle) + /// <param name="delta">The delta angle.</param> + public void AddRotation(float delta) { if (!isSpinnableTime) return; if (!rotationTransferred) { - currentRotation = Rotation * 2; + currentRotation = Rotation; rotationTransferred = true; } - if (angle > 180) - { - lastAngle += 360; - angle -= 360; - } - else if (-angle > 180) - { - lastAngle -= 360; - angle += 360; - } + currentRotation += delta; + + double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate; + + Debug.Assert(Math.Abs(delta) <= 180); - currentRotation += angle; // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback // (see: ModTimeRamp) - drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate)); + drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate); } private void resetState(DrawableHitObject obj) { Tracking = false; IsSpinning.Value = false; - mousePosition = default; - lastAngle = currentRotation = Rotation = 0; + mousePosition = null; + lastAngle = null; + currentRotation = 0; + Rotation = 0; rotationTransferred = false; } diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 92e94bd02d..cba90b2ebe 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -74,7 +74,7 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 79_333)] [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 158_667)] - [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 302_402)] + [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 317_626)] [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 492_894)] [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 492_894)] [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] @@ -86,7 +86,7 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)] [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)] - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 30_398)] + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 31_928)] [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)] [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)] [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index f60b3a6c02..cd1e81046d 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -190,10 +190,9 @@ namespace osu.Game.Rulesets.Judgements return 200; case HitResult.Great: - return 300; - + // Perfect doesn't actually give more score / accuracy directly. case HitResult.Perfect: - return 315; + return 300; case HitResult.SmallBonus: return SMALL_BONUS_SCORE; diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index ccd1f49de4..fed338b012 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -55,6 +55,13 @@ namespace osu.Game.Rulesets.Scoring [Order(1)] Great, + /// <summary> + /// This is an optional timing window tighter than <see cref="Great"/>. + /// </summary> + /// <remarks> + /// By default, this does not give any bonus accuracy or score. + /// To have it affect scoring, consider adding a nested bonus object. + /// </remarks> [Description(@"Perfect")] [EnumMember(Value = "perfect")] [Order(0)]