3
\$\begingroup\$

This script executes a sequence of actions over time and is based on the methods exposed by the UrhoSharp framework. I am new to Unity, but well acquainted with C# so I know what I'm doing for the most part. I'm primarily concerned with performance. Is this how such problems are usually approached in Unity or can we do better?

Using the functions published here, I was able to make Vector3 interpolations work. I will be honest and admit I know nothing about quaternions so I just use Unity's built-in helper function for linear interpolations and throw an exception if some other interpolation is specified.

Ultimately, this solution aims to provide a proper framework for animating anything in a easy to understand way.

This is how you'd use it.

myGameObject.AddComponent<Animate>()
    .Begin(Repeat.Forever,
        new MoveTo(TimeSpan.FromSeconds(5), myGameObject.transform.position + new Vector3(15, 0, 0), Easing.EaseInBounce, TransformOrigin.World),
        new DelayTime(TimeSpan.FromSeconds(3)),
        new MoveTo(TimeSpan.FromSeconds(5), myGameObject.transform.position, Easing.EaseOutQuint, TransformOrigin.World),
        new DelayTime(TimeSpan.FromSeconds(3)),
        new MoveTo(TimeSpan.FromSeconds(5), myGameObject.transform.position - new Vector3(15, 0, 0), Easing.EaseInBack, TransformOrigin.World),
        new DelayTime(TimeSpan.FromSeconds(3)),
        new MoveTo(TimeSpan.FromSeconds(5), myGameObject.transform.position, Easing.Spring, TransformOrigin.World),
        new DelayTime(TimeSpan.FromSeconds(3)));

Or to perform actions in parallel:

myGameObject.AddComponent<Animate>()
    .Begin(Repeat.Forever, new Parallel(
        new MoveTo(TimeSpan.FromSeconds(5), myGameObject.transform.position - new Vector3(0, 5, 0)), 
        new ScaleTo(TimeSpan.FromSeconds(5), Vector3.zero)));

And here is the code (not including the code here).

Animate:

public class Animate : MonoBehaviour
{
    public const Repeat DefaultRepeat = Repeat.Once;

    class ActionSequence
    {
        /// <summary>
        /// The actions to perform.
        /// </summary>
        readonly ActionBase[] _actions;

        /// <summary>
        /// The current action.
        /// </summary>
        public ActionBase Current;

        /// <summary>
        /// The arguments to evaluate for the current action.
        /// </summary>
        public object[] CurrentArguments;

        /// <summary>
        /// The index of the current action.
        /// </summary>
        public uint CurrentIndex = 0;

        /// <summary>
        /// The "journey" of the current action.
        /// </summary>
        public float CurrentJourney = 0;

        /// <summary>
        /// Whether or not the current action has started.
        /// </summary>
        public bool CurrentStarted;

        /// <summary>
        /// The number of actions in the sequence.
        /// </summary>
        public readonly uint Length;

        /// <summary>
        /// The manner in which the sequence of actions should repeat.
        /// </summary>
        public Repeat Repeat;

        public ActionSequence(Repeat repeat, ActionBase[] actions)
        {
            _actions = actions;
            Current = _actions[0];
            Length = Convert.ToUInt32(_actions.Length);
            Repeat = repeat;
        }

        public ActionBase this[uint index]
        {
            get
            {
                return _actions[index];
            }
        }

        public void Next()
        {
            CurrentArguments = null;
            CurrentIndex++;
            CurrentJourney = 0;
            CurrentStarted = false;
        }
    }

    public enum Repeat
    {
        Once,
        Forever
    }

    List<ActionSequence> _sequences = new List<ActionSequence>();

    void Update()
    {
        var count = _sequences.Count;

        if (count == 0)
            return;

        var deltaTime = Time.deltaTime;
        for (int i = 0; i < count; i++)
        {
            var sequence = _sequences[i];

            if (sequence.Current.Duration <= TimeSpan.Zero)
                goto Done;

            if (!sequence.CurrentStarted)
            {
                sequence.CurrentArguments = sequence.Current.GetArguments(gameObject);
                sequence.CurrentStarted = true;
            }

            if (sequence.CurrentJourney >= sequence.Current.Duration.TotalSeconds)
                goto Done;

            sequence.CurrentJourney += deltaTime;
            sequence.Current.Update(gameObject, deltaTime, sequence.CurrentJourney, sequence.CurrentArguments);

            continue;

            //Is the action done?
            Done:
            {
                //If so, get the next
                sequence.Next();
                if (sequence.Repeat == Repeat.Forever)
                {
                    //If done, prepare for next iteration
                    if (sequence.CurrentIndex == sequence.Length)
                        sequence.CurrentIndex = 0;

                    //Get next action now so we don't have to on subsequent updates
                    sequence.Current = sequence[sequence.CurrentIndex];
                }
                else if (sequence.Repeat == Repeat.Once)
                {
                    //If done, remove current sequence
                    if (sequence.CurrentIndex == sequence.Length)
                    {
                        _sequences.RemoveAt(i);
                        count--;
                        i--;
                    }
                    //Otherwise, get next action now so we don't have to on subsequent updates
                    else sequence.Current = sequence[sequence.CurrentIndex];
                }
            }
        }
    }

    /// <summary>
    /// Performs the given sequence of actions once.
    /// </summary>
    /// <param name="actions"></param>
    public void Begin(params ActionBase[] actions)
    {
        Begin(DefaultRepeat, actions);
    }

    /// <summary>
    /// Performs the given sequence of actions once or forever.
    /// </summary>
    /// <param name="repeat"></param>
    /// <param name="actions"></param>
    public void Begin(Repeat repeat, params ActionBase[] actions)
    {
        var sequence = new ActionSequence(repeat, actions);
        _sequences.Add(sequence);
    }

    /// <summary>
    /// Performs the given sequence of actions in parallel once.
    /// </summary>
    /// <param name="repeat"></param>
    /// <param name="actions"></param>
    public void Begin(Parallel parallel)
    {
        Begin(DefaultRepeat, parallel);
    }

    /// <summary>
    ///  Performs the given sequence of actions in parallel once or forever.
    /// </summary>
    /// <param name="repeat"></param>
    /// <param name="parallel"></param>
    public void Begin(Repeat repeat, Parallel parallel)
    {
        for (uint i = 0; i < parallel.Count; i++)
            Begin(repeat, parallel[i]);
    }
}

ActionBase + some base actions:

public abstract class ActionBase
{
    protected readonly TimeSpan _duration;
    public TimeSpan Duration
    {
        get
        {
            return _duration;
        }
    }

    protected ActionBase(TimeSpan duration)
    {
        _duration = duration;
    }

    internal abstract object[] GetArguments(GameObject go);

    internal abstract void Update(GameObject go, float deltaTime, float journey, object[] arguments);
}

public abstract class Interpolate<TValue> : ActionBase
{
    protected readonly Easing _easing;

    protected readonly TValue _target;

    protected Interpolate(TimeSpan duration, TValue target, Easing easing) : base(duration)
    {
        _easing = easing;
        _target = target;
    }

    protected float GetValue(Easing easing, float origin, float target, float percent)
    {
        switch (_easing)
        {
            case Easing.EaseInBack:
                return EasingFunction.EaseInBack(origin, target, percent);
            case Easing.EaseInBounce:
                return EasingFunction.EaseInBounce(origin, target, percent);
            case Easing.EaseInCirc:
                return EasingFunction.EaseInCirc(origin, target, percent);
            case Easing.EaseInCubic:
                return EasingFunction.EaseInCubic(origin, target, percent);
            case Easing.EaseInElastic:
                return EasingFunction.EaseInElastic(origin, target, percent);
            case Easing.EaseInExpo:
                return EasingFunction.EaseInExpo(origin, target, percent);
            case Easing.EaseInOutBack:
                return EasingFunction.EaseInOutBack(origin, target, percent);
            case Easing.EaseInOutBounce:
                return EasingFunction.EaseInOutBounce(origin, target, percent);
            case Easing.EaseInOutCirc:
                return EasingFunction.EaseInOutCirc(origin, target, percent);
            case Easing.EaseInOutCubic:
                return EasingFunction.EaseInOutCubic(origin, target, percent);
            case Easing.EaseInOutElastic:
                return EasingFunction.EaseInOutElastic(origin, target, percent);
            case Easing.EaseInOutExpo:
                return EasingFunction.EaseInOutExpo(origin, target, percent);
            case Easing.EaseInOutQuad:
                return EasingFunction.EaseInOutQuad(origin, target, percent);
            case Easing.EaseInOutQuart:
                return EasingFunction.EaseInOutQuart(origin, target, percent);
            case Easing.EaseInOutQuint:
                return EasingFunction.EaseInOutQuint(origin, target, percent);
            case Easing.EaseInOutSine:
                return EasingFunction.EaseInOutSine(origin, target, percent);
            case Easing.EaseInQuad:
                return EasingFunction.EaseInQuad(origin, target, percent);
            case Easing.EaseInQuart:
                return EasingFunction.EaseInQuart(origin, target, percent);
            case Easing.EaseInQuint:
                return EasingFunction.EaseInQuint(origin, target, percent);
            case Easing.EaseInSine:
                return EasingFunction.EaseInSine(origin, target, percent);
            case Easing.EaseOutBack:
                return EasingFunction.EaseOutBack(origin, target, percent);
            case Easing.EaseOutBounce:
                return EasingFunction.EaseOutBounce(origin, target, percent);
            case Easing.EaseOutCirc:
                return EasingFunction.EaseOutCirc(origin, target, percent);
            case Easing.EaseOutCubic:
                return EasingFunction.EaseOutCubic(origin, target, percent);
            case Easing.EaseOutElastic:
                return EasingFunction.EaseOutElastic(origin, target, percent);
            case Easing.EaseOutExpo:
                return EasingFunction.EaseOutExpo(origin, target, percent);
            case Easing.EaseOutQuad:
                return EasingFunction.EaseOutQuad(origin, target, percent);
            case Easing.EaseOutQuart:
                return EasingFunction.EaseOutQuart(origin, target, percent);
            case Easing.EaseOutQuint:
                return EasingFunction.EaseOutQuint(origin, target, percent);
            case Easing.EaseOutSine:
                return EasingFunction.EaseOutSine(origin, target, percent);
            case Easing.Linear:
                return EasingFunction.Linear(origin, target, percent);
            case Easing.Spring:
                return EasingFunction.Spring(origin, target, percent);
        }
        throw new NotSupportedException();
    }

    protected abstract TValue GetValue(TValue origin, float percent);

    protected abstract void SetValue(GameObject go, TValue value);

    internal override void Update(GameObject go, float deltaTime, float journey, object[] arguments)
    {
        var origin = (TValue)arguments[0];
        var percent = Mathf.Clamp01(journey / Convert.ToSingle(_duration.TotalSeconds));
        var value = GetValue(origin, percent);
        SetValue(go, value);
    }
}

public abstract class InterpolateVector3 : Interpolate<Vector3>
{
    protected InterpolateVector3(TimeSpan duration, Vector3 target, Easing easing) : base(duration, target, easing) { }

    protected override Vector3 GetValue(Vector3 origin, float percent)
    {
        var value = new float[3];
        for (var i = 0; i < 3; i++)
        {
            var origin_
                = i == 0
                ? origin.x
                : i == 1
                ? origin.y
                : origin.z;

            var target_
                = i == 0
                ? _target.x
                : i == 1
                ? _target.y
                : _target.z;

            value[i] = GetValue(_easing, origin_, target_, percent);
        }
        return new Vector3(value[0], value[1], value[2]);
    }
}

public abstract class InterpolateVector4 : Interpolate<Vector4>
{
    protected InterpolateVector4(TimeSpan duration, Vector3 target, Easing easing) : base(duration, target, easing) { }

    protected override Vector4 GetValue(Vector4 origin, float percent)
    {
        var value = new float[4];
        for (var i = 0; i < 4; i++)
        {
            var origin_
                = i == 0
                ? origin.w
                : i == 1
                ? origin.x
                : i == 2
                ? origin.y
                : origin.z;

            var target_
                = i == 0
                ? _target.w
                : i == 1
                ? _target.x
                : i == 2
                ? _target.y
                : _target.z;

            value[i] = GetValue(_easing, origin_, target_, percent);
        }
        return new Vector4(value[0], value[1], value[2], value[3]);
    }
}

public abstract class InterpolateQuaternion : Interpolate<Quaternion>
{
    protected InterpolateQuaternion(TimeSpan duration, Quaternion target, Easing easing) : base(duration, target, easing) { }

    protected override Quaternion GetValue(Quaternion origin, float percent)
    {
        switch (_easing)
        {
            case Easing.Linear:
                return Quaternion.Lerp(origin, _target, percent);
        }
        throw new NotSupportedException();
    }
}

Some default actions:

public class DelayTime : ActionBase
{
    public DelayTime(TimeSpan duration) : base(duration) { }

    internal override object[] GetArguments(GameObject go)
    {
        return default(object[]);
    }

    internal override void Update(GameObject go, float deltaTime, float journey, object[] arguments) { }
}

public class MoveTo : InterpolateVector3
{
    protected readonly TransformOrigin _transformOrigin;

    public MoveTo(TimeSpan duration, Vector3 target, Easing easing = Easing.Linear, TransformOrigin transformOrigin = TransformOrigin.Local) : base(duration, target, easing)
    {
        _transformOrigin = transformOrigin;
    }

    internal override object[] GetArguments(GameObject go)
    {
        return new object[]
        {
            _transformOrigin == TransformOrigin.Local
            ? go.transform.localPosition
            : go.transform.position
        };
    }

    protected override void SetValue(GameObject go, Vector3 value)
    {
        switch (_transformOrigin)
        {
            case TransformOrigin.Local:
                go.transform.localPosition = value;
                break;
            case TransformOrigin.World:
                go.transform.position = value;
                break;
        }
    }
}

public class RotateTo : InterpolateQuaternion
{
    protected readonly TransformOrigin _transformOrigin;

    public RotateTo(TimeSpan duration, Quaternion target, Easing easing = Easing.Linear, TransformOrigin transformOrigin = TransformOrigin.Local) : base(duration, target, easing)
    {
        _transformOrigin = transformOrigin;
    }

    internal override object[] GetArguments(GameObject go)
    {
        return new object[]
        {
            _transformOrigin == TransformOrigin.Local
            ? go.transform.localRotation
            : go.transform.rotation
        };
    }

    protected override void SetValue(GameObject go, Quaternion value)
    {
        switch (_transformOrigin)
        {
            case TransformOrigin.Local:
                go.transform.localRotation = value;
                break;
            case TransformOrigin.World:
                go.transform.rotation = value;
                break;
        }
    }
}

public class ScaleTo : InterpolateVector3
{
    public ScaleTo(TimeSpan duration, Vector3 target, Easing easing = Easing.Linear) : base(duration, target, easing) { }

    internal override object[] GetArguments(GameObject go)
    {
        return new object[]
        {
            go.transform.localScale
        };
    }

    protected override void SetValue(GameObject go, Vector3 value)
    {
        go.transform.localScale = value;
    }
}

For executing actions in parallel:

public class Parallel
{
    readonly ActionBase[] _actions;

    public uint Count
    {
        get
        {
            return Convert.ToUInt32(_actions.Length);
        }
    }

    public Parallel(params ActionBase[] actions)
    {
        _actions = actions;
    }

    public ActionBase this[uint index]
    {
        get
        {
            return _actions[index];
        }
    }
}

Other:

public enum TransformOrigin
{
    Local,
    World
}
\$\endgroup\$
  • 2
    \$\begingroup\$ This isn't directly related to a code review, but just because you said you are new to Unity, I wanted to point out a few existing tweening libraries, such as DoTween, GoKit, iTween, LeanTween. Personally I'm using DoTween and the source is on GitHub. If you are concerned about performance, LeanTween emphasizes that it is efficient and lightweight. In case you don't get a response here, it may be good to review these libraries' code to get a sense of how things are done there. \$\endgroup\$ – sonny May 3 '18 at 15:29
  • \$\begingroup\$ Thanks for the recommendations. Why isn't this related to code review? I published my code and I'm asking the community to review it? Personally, I won't fuss with DoTween or any library I have to pay for. I might go with iTween since it is free, but it would still be nice to learn how this all works. \$\endgroup\$ – James M May 3 '18 at 18:19
  • 1
    \$\begingroup\$ Oops sorry, I meant my comment isn't related to code review. Your post is fine of course. All the libraries I mentioned are free (though DoTween does have a "Pro" version). iTween is one of the most popular options, though I think it's also known for having a messy codebase. I've used both DOTween and iTween and I find DOTween much easier to use. I definitely think it's good to learn how it all works. I was just suggesting that you go look at the source code of these libraries to see how it works, and how they did it differently compared to you. \$\endgroup\$ – sonny May 3 '18 at 18:32
  • \$\begingroup\$ Here is the DOTween GitHub repository and here is the LeanTween Github repository \$\endgroup\$ – sonny May 3 '18 at 18:34
0
\$\begingroup\$

Review

  • Your system is timespan based. This is fine, but I would opt for a time-indexed system completely independant of any time unit. You could then provide a callback function that provides the timespan for a single time unit () => Time.deltaTime; or alternatively with the index as argument, so you could skew time if you desire to (index) => index * Time.deltaTime;. I like being independant from actual time and work with discrete time instead. It allows flexibility in defining time.
  • The easing functions look alright, I would only refactor Animate and its nested class ActionSequence.
  • The fields of ActionSequence should be (almost-)immutable properties, or at least unmodifiable from a public perspective. State should only change through methods, this enforces integrity and encapsulation.
  • Next should have a circular incrementation CurrentIndex = CurrentIndex + 1 % Length;
  • ActionSequence should know whether is has completed, based on CurrentIndex, Length and Repeat.
  • Animate.Update should be cleaned up. Much of the behavior should be forwared to a sequence instance. This would provide better encapsulation.
  • These goto Done;, continue; statements should be refactored because you have made some spaghetti code here. It's hard to read method Update like this.
  • _sequences.RemoveAt(i); should be replaced by having a flag on a sequence IsCompleted. I would not modify the instance assignmens in the array. Using LINQ with Where(!IsCompleted) reads much better.
  • You might want to think about a recursive structure where an action migth have 0 or more sequences, each containing 1 or more actions. You'd be able to design much more complex scenarios.
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.