Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs
2264 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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;
            }
        }
    }
}