mirror of
https://github.com/ppy/osu.git
synced 2024-11-11 10:18:22 +08:00
Merge pull request #25157 from peppy/spinner-anti-cheese-final
Fix spinner cheese by accounting for spin directionality
This commit is contained in:
commit
0e5a521695
135
osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs
Normal file
135
osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs
Normal file
@ -0,0 +1,135 @@
|
||||
// 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 NUnit.Framework;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SpinnerSpinHistoryTest
|
||||
{
|
||||
private SpinnerSpinHistory history = null!;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
history = new SpinnerSpinHistory();
|
||||
}
|
||||
|
||||
[TestCase(0, 0)]
|
||||
[TestCase(10, 10)]
|
||||
[TestCase(180, 180)]
|
||||
[TestCase(350, 350)]
|
||||
[TestCase(360, 360)]
|
||||
[TestCase(370, 370)]
|
||||
[TestCase(540, 540)]
|
||||
[TestCase(720, 720)]
|
||||
// ---
|
||||
[TestCase(-0, 0)]
|
||||
[TestCase(-10, 10)]
|
||||
[TestCase(-180, 180)]
|
||||
[TestCase(-350, 350)]
|
||||
[TestCase(-360, 360)]
|
||||
[TestCase(-370, 370)]
|
||||
[TestCase(-540, 540)]
|
||||
[TestCase(-720, 720)]
|
||||
public void TestSpinOneDirection(float spin, float expectedRotation)
|
||||
{
|
||||
history.ReportDelta(500, spin);
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
|
||||
}
|
||||
|
||||
[TestCase(0, 0, 0, 0)]
|
||||
// ---
|
||||
[TestCase(10, -10, 0, 10)]
|
||||
[TestCase(-10, 10, 0, 10)]
|
||||
// ---
|
||||
[TestCase(10, -20, 0, 10)]
|
||||
[TestCase(-10, 20, 0, 10)]
|
||||
// ---
|
||||
[TestCase(20, -10, 0, 20)]
|
||||
[TestCase(-20, 10, 0, 20)]
|
||||
// ---
|
||||
[TestCase(10, -360, 0, 350)]
|
||||
[TestCase(-10, 360, 0, 350)]
|
||||
// ---
|
||||
[TestCase(360, -10, 0, 370)]
|
||||
[TestCase(360, 10, 0, 370)]
|
||||
[TestCase(-360, 10, 0, 370)]
|
||||
[TestCase(-360, -10, 0, 370)]
|
||||
// ---
|
||||
[TestCase(10, 10, 10, 30)]
|
||||
[TestCase(10, 10, -10, 20)]
|
||||
[TestCase(10, -10, 10, 10)]
|
||||
[TestCase(-10, -10, -10, 30)]
|
||||
[TestCase(-10, -10, 10, 20)]
|
||||
[TestCase(-10, 10, 10, 10)]
|
||||
// ---
|
||||
[TestCase(10, -20, -350, 360)]
|
||||
[TestCase(10, -20, 350, 340)]
|
||||
[TestCase(-10, 20, 350, 360)]
|
||||
[TestCase(-10, 20, -350, 340)]
|
||||
public void TestSpinMultipleDirections(float spin1, float spin2, float spin3, float expectedRotation)
|
||||
{
|
||||
history.ReportDelta(500, spin1);
|
||||
history.ReportDelta(1000, spin2);
|
||||
history.ReportDelta(1500, spin3);
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
|
||||
}
|
||||
|
||||
// One spin
|
||||
[TestCase(370, -50, 320)]
|
||||
[TestCase(-370, 50, 320)]
|
||||
// Two spins
|
||||
[TestCase(740, -420, 320)]
|
||||
[TestCase(-740, 420, 320)]
|
||||
public void TestRemoveAndCrossFullSpin(float deltaToAdd, float deltaToRemove, float expectedRotation)
|
||||
{
|
||||
history.ReportDelta(1000, deltaToAdd);
|
||||
history.ReportDelta(500, deltaToRemove);
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
|
||||
}
|
||||
|
||||
// One spin + partial
|
||||
[TestCase(400, -30, -50, 320)]
|
||||
[TestCase(-400, 30, 50, 320)]
|
||||
// Two spins + partial
|
||||
[TestCase(800, -430, -50, 320)]
|
||||
[TestCase(-800, 430, 50, 320)]
|
||||
public void TestRemoveAndCrossFullAndPartialSpins(float deltaToAdd1, float deltaToAdd2, float deltaToRemove, float expectedRotation)
|
||||
{
|
||||
history.ReportDelta(1000, deltaToAdd1);
|
||||
history.ReportDelta(1500, deltaToAdd2);
|
||||
history.ReportDelta(500, deltaToRemove);
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRewindMultipleFullSpins()
|
||||
{
|
||||
history.ReportDelta(500, 360);
|
||||
history.ReportDelta(1000, 720);
|
||||
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(1080));
|
||||
|
||||
history.ReportDelta(250, -900);
|
||||
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(180));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRewindOverDirectionChange()
|
||||
{
|
||||
history.ReportDelta(1000, 40); // max is now CW 40 degrees
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(40));
|
||||
history.ReportDelta(1100, -90); // max is now CCW 50 degrees
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(50));
|
||||
history.ReportDelta(1200, 110); // max is now CW 60 degrees
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(60));
|
||||
|
||||
history.ReportDelta(1000, -20);
|
||||
Assert.That(history.TotalRotation, Is.EqualTo(40));
|
||||
}
|
||||
}
|
||||
}
|
@ -53,7 +53,6 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
/// 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>();
|
||||
@ -81,7 +80,6 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
/// 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>();
|
||||
@ -130,7 +128,6 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
/// 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)
|
||||
@ -149,7 +146,6 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
[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)
|
||||
@ -162,18 +158,28 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Ignore("An upcoming implementation will fix this case")]
|
||||
public void TestRewind()
|
||||
{
|
||||
AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 });
|
||||
AddStep("set manual clock", () => manualClock = new ManualClock
|
||||
{
|
||||
// Avoids interpolation trying to run ahead during testing.
|
||||
Rate = 0
|
||||
});
|
||||
|
||||
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();
|
||||
List<ReplayFrame> frames =
|
||||
new SpinFramesGenerator(time_spinner_start)
|
||||
// 1500ms start
|
||||
.Spin(360, 500)
|
||||
// 2000ms -> 1 full CW spin
|
||||
.Spin(-180, 500)
|
||||
// 2500ms -> 1 full CW spin + 0.5 CCW spins
|
||||
.Spin(90, 500)
|
||||
// 3000ms -> 1 full CW spin + 0.25 CCW spins
|
||||
.Spin(450, 500)
|
||||
// 3500ms -> 2 full CW spins
|
||||
.Spin(180, 500)
|
||||
// 4000ms -> 2 full CW spins + 0.5 CW spins
|
||||
.Build();
|
||||
|
||||
loadPlayer(frames);
|
||||
|
||||
@ -190,15 +196,35 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
DrawableSpinner drawableSpinner = null!;
|
||||
AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null);
|
||||
|
||||
assertTotalRotation(4000, 900);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(3750, 810);
|
||||
assertTotalRotation(3500, 720);
|
||||
assertTotalRotation(3250, 530);
|
||||
assertTotalRotation(3000, 540);
|
||||
assertTotalRotation(3000, 450);
|
||||
assertTotalRotation(2750, 540);
|
||||
assertTotalRotation(2500, 540);
|
||||
assertTotalRotation(2250, 360);
|
||||
assertTotalRotation(2000, 180);
|
||||
assertTotalRotation(2250, 450);
|
||||
assertTotalRotation(2000, 360);
|
||||
assertTotalRotation(1500, 0);
|
||||
|
||||
// same thing but always returning to final time to check.
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(3750, 810);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(3500, 720);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(3250, 530);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(3000, 450);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(2750, 540);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(2500, 540);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(2250, 450);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(2000, 360);
|
||||
assertFinalRotationCorrect();
|
||||
assertTotalRotation(1500, 0);
|
||||
|
||||
void assertTotalRotation(double time, float expected)
|
||||
@ -211,8 +237,11 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
void addSeekStep(double time)
|
||||
{
|
||||
AddStep($"seek to {time}", () => clock.Seek(time));
|
||||
// Lenience is required due to interpolation running slightly ahead on a stalled clock.
|
||||
AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time));
|
||||
}
|
||||
|
||||
void assertFinalRotationCorrect() => assertTotalRotation(4000, 900);
|
||||
}
|
||||
|
||||
private void assertTicksHit(int count)
|
||||
|
@ -4,6 +4,7 @@
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Judgements
|
||||
{
|
||||
@ -15,28 +16,15 @@ namespace osu.Game.Rulesets.Osu.Judgements
|
||||
public Spinner Spinner => (Spinner)HitObject;
|
||||
|
||||
/// <summary>
|
||||
/// The total rotation performed on the spinner disc, disregarding the spin direction,
|
||||
/// adjusted for the track's playback rate.
|
||||
/// The total amount that the spinner was rotated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This value is always non-negative and is monotonically increasing with time
|
||||
/// (i.e. will only increase if time is passing forward, but can decrease during rewind).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rotation from each frame is multiplied by the clock's current playback rate.
|
||||
/// The reason this is done is to ensure that spinners give the same score and require the same number of spins
|
||||
/// regardless of whether speed-modifying mods are applied.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// Assuming no speed-modifying mods are active,
|
||||
/// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise,
|
||||
/// this property will return the value of 720 (as opposed to 0).
|
||||
/// 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 TotalRotation;
|
||||
public float TotalRotation => History.TotalRotation;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the spinning history of the spinner.<br />
|
||||
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
|
||||
/// </summary>
|
||||
public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
|
||||
|
||||
/// <summary>
|
||||
/// Time instant at which the spin was started (the first user input which caused an increase in spin).
|
||||
|
146
osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs
Normal file
146
osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs
Normal file
@ -0,0 +1,146 @@
|
||||
// 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.Diagnostics;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores the spinning history of a single spinner.<br />
|
||||
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A single, full rotation of the spinner is defined as a 360-degree rotation of the spinner, starting from 0, going in a single direction.<br />
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// If the player spins 90-degrees clockwise, then changes direction, they need to spin 90-degrees counter-clockwise to return to 0
|
||||
/// and then continue rotating the spinner for another 360-degrees in the same direction.
|
||||
/// </example>
|
||||
public class SpinnerSpinHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// The sum of all complete spins and any current partial spin, in degrees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the final scoring value.
|
||||
/// </remarks>
|
||||
public float TotalRotation => 360 * completedSpins.Count + currentSpinMaxRotation;
|
||||
|
||||
private readonly Stack<CompletedSpin> completedSpins = new Stack<CompletedSpin>();
|
||||
|
||||
/// <summary>
|
||||
/// The total accumulated (absolute) rotation.
|
||||
/// </summary>
|
||||
private float totalAccumulatedRotation;
|
||||
|
||||
private float totalAccumulatedRotationAtLastCompletion;
|
||||
|
||||
/// <summary>
|
||||
/// For the current spin, represents the maximum absolute rotation (from 0..360) achieved by the user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used to report <see cref="TotalRotation"/> in the case a user spins backwards.
|
||||
/// Basically it allows us to not reduce the total rotation in such a case.
|
||||
///
|
||||
/// This also stops spinner "cheese" where a user may rapidly change directions and cause an increase
|
||||
/// in rotations.
|
||||
/// </remarks>
|
||||
private float currentSpinMaxRotation;
|
||||
|
||||
/// <summary>
|
||||
/// The current spin, from -360..360.
|
||||
/// </summary>
|
||||
private float currentSpinRotation => totalAccumulatedRotation - totalAccumulatedRotationAtLastCompletion;
|
||||
|
||||
private double lastReportTime = double.NegativeInfinity;
|
||||
|
||||
/// <summary>
|
||||
/// Report a delta update based on user input.
|
||||
/// </summary>
|
||||
/// <param name="currentTime">The current time.</param>
|
||||
/// <param name="delta">The delta of the angle moved through since the last report.</param>
|
||||
public void ReportDelta(double currentTime, float delta)
|
||||
{
|
||||
if (delta == 0)
|
||||
return;
|
||||
|
||||
// Importantly, outside of tests the max delta entering here is 180 degrees.
|
||||
// If it wasn't for tests, we could add this line:
|
||||
//
|
||||
// Debug.Assert(Math.Abs(delta) < 180);
|
||||
//
|
||||
// For this to be 101% correct, we need to add the ability for important frames to be
|
||||
// created based on gameplay intrinsics (ie. there should be one frame for any spinner delta 90 < n < 180 degrees).
|
||||
//
|
||||
// But this can come later.
|
||||
|
||||
totalAccumulatedRotation += delta;
|
||||
|
||||
if (currentTime >= lastReportTime)
|
||||
{
|
||||
currentSpinMaxRotation = Math.Max(currentSpinMaxRotation, Math.Abs(currentSpinRotation));
|
||||
|
||||
// Handle the case where the user has completed another spin.
|
||||
// Note that this does could be an `if` rather than `while` if the above assertion held true.
|
||||
// It is a `while` loop to handle tests which throw larger values at this method.
|
||||
while (currentSpinMaxRotation >= 360)
|
||||
{
|
||||
int direction = Math.Sign(currentSpinRotation);
|
||||
|
||||
completedSpins.Push(new CompletedSpin(currentTime, direction));
|
||||
|
||||
// Incrementing the last completion point will cause `currentSpinRotation` to
|
||||
// hold the remaining spin that needs to be considered.
|
||||
totalAccumulatedRotationAtLastCompletion += direction * 360;
|
||||
|
||||
// Reset the current max as we are entering a new spin.
|
||||
// Importantly, carry over the remainder (which is now stored in `currentSpinRotation`).
|
||||
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// When rewinding, the main thing we care about is getting `totalAbsoluteRotationsAtLastCompletion`
|
||||
// to the correct value. We can used the stored history for this.
|
||||
while (completedSpins.TryPeek(out var segment) && segment.CompletionTime > currentTime)
|
||||
{
|
||||
completedSpins.Pop();
|
||||
totalAccumulatedRotationAtLastCompletion -= segment.Direction * 360;
|
||||
}
|
||||
|
||||
// This is a best effort. We may not have enough data to match this 1:1, but that's okay.
|
||||
// We know that the player is somewhere in a spin.
|
||||
// In the worst case, this will be lower than expected, and recover in forward playback.
|
||||
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
|
||||
}
|
||||
|
||||
lastReportTime = currentTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single completed spin.
|
||||
/// </summary>
|
||||
private class CompletedSpin
|
||||
{
|
||||
/// <summary>
|
||||
/// The time at which this spin completion occurred.
|
||||
/// </summary>
|
||||
public readonly double CompletionTime;
|
||||
|
||||
/// <summary>
|
||||
/// The direction this spin completed in.
|
||||
/// </summary>
|
||||
public readonly int Direction;
|
||||
|
||||
public CompletedSpin(double completionTime, int direction)
|
||||
{
|
||||
Debug.Assert(direction == -1 || direction == 1);
|
||||
|
||||
CompletionTime = completionTime;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -101,15 +101,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
rotationTransferred = true;
|
||||
}
|
||||
|
||||
currentRotation += delta;
|
||||
|
||||
double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate;
|
||||
delta = (float)(delta * Math.Abs(rate));
|
||||
|
||||
Debug.Assert(Math.Abs(delta) <= 180);
|
||||
|
||||
// rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
|
||||
// (see: ModTimeRamp)
|
||||
drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate);
|
||||
currentRotation += delta;
|
||||
drawableSpinner.Result.History.ReportDelta(Time.Current, delta);
|
||||
}
|
||||
|
||||
private void resetState(DrawableHitObject obj)
|
||||
|
@ -432,6 +432,11 @@ namespace osu.Game.Tests.Visual
|
||||
|
||||
private bool running;
|
||||
|
||||
public override double Rate => base.Rate
|
||||
// This is mainly to allow some tests to override the rate to zero
|
||||
// and avoid interpolation.
|
||||
* referenceClock.Rate;
|
||||
|
||||
public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual")
|
||||
: base(name)
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user