Action

Action is a time-based, self-contained interpolation framework. It has the capability of interpolating values over a duration with delays, ease types, and completion callbacks. Because it uses templates and pointers to the values it is interpolating, Action is flexible in what it can do: lower the volume of a sound clip, scale a transform, change the color of a particle, etc.

See this blog post for an introduction on how to use Action. This page is more about the architecture of it.

Architecture

Because Action interpolates values within the codebase of the project, it needs to store references to these values as well as the interpolation values. Commonly referred to as To, From, and FromTo tweens, Action uses a simple templated struct to keep track of each value:

template <typename T>
struct Value
{
    // values for the start and end of the interpolation
    // reference to the value we're interpolating,
    // which also acts as the 'current' value
    T start, end, *current;


    Value<T> (T *current, T start, T end)
    {
        // set the reference to current
        this->current = current;

        // set the value of current and start
        *this->current = (this->start = start);

        this->end = end;
    }

    // more overloded constructors...
}

At its root, Action’s interpolator runs off of the concept of a Single – a single interpolation. This is split into two layers: a base layer that handles the majority of the logic and a top layer that contains a Value and handles the templated nature of the system:

class SingleBase : public ActionBase
{

protected:

    float          duration, elapsed;
    TimingFunction timingFunction;
    Timer          timer;


public:

    // getters and setters...

    void start()  { this->timer.start();  }
    void pause()  { this->timer.pause();  }
    void resume() { this->timer.resume(); }
    void stop()   { delete this;          }
};

And here’s an overview of the top level:

template <typename T>
class Single : public SingleBase
{

private:

   Value<T>* value;


public:

   bool update()
   {
       // get elapsed time
       elapsed = timer.getElapsedMS() / 1000.0f;

       if(elapsed > delay)
       {
           // get the current completion percentage
           float percent = (elapsed - delay) / duration;

           // we're finished
           if(percent >= 1.0f)
           {
               // make sure we don't over-pass the end value
               *value->current = value->end;

               callback();

               // update our values based on if we're looping
               OnComplete();

               return true;
           }

           // otherwise interpolate our value
           else *value->current = (value->start + ((timingFunction)(percent))*(value->end - value->start));
       }

       return false;
   }
}

If you’re eagle-eyed, you’ll notice that SingleBase inherits from ActionBase – this is a class that holds basic info shared across all types of actions, like Groups and Sequences (more on those in a bit).


Singles are stored in a vector, and are updated individually:

namespace Action
{
    static std::vector<SingleBase*> singles   {};

    static void update()
    {
       for(int i = 0; i < singles.size(); i++)
       {
           singles.at(i)->update();

           // if the Action is completed, delete it
           if(singles.at(i)->isFinished() &&
              singles.at(i)->getLooptype() == LoopTypes::None)
           {
               delete singles.at(i); singles.at(i) = NULL;

               singles.erase(singles.begin() + i);
               ++i;
           }
       }
   }
}

Thus, the only setup needed from an external party is to #include 'Action.h' and to call Action::update() in its main update loop.

The blog post I linked earlier goes more into how to use Action.

Features

In addition to interpolating single values, Action supports interpolating groups of values in unison and in sequence. For Groups, if specified by the user, a group-wide delay is added to each of the Singles within a group, and then the group handles updating each single in unison. For Sequences, determining the delay for each of the Singles is a little more involved:

// in Sequence.h:
void start()
{
    for(int i = (int)singles.size() - 1; i >= 0; i--)
    {
        auto&& s = singles.at(i);

        float delayForS = delay + s->getDelay();

        for(int j = i - 1; j >= 0; j--)
        {
            delayForS += singles.at(j)->getDelay() + singles.at(j)->getDuration();
        }

        delays.push_back(delayForS);
        s->setDelay(delayForS);

        if(looptype > LoopTypes::None) s->setLooptype(looptype);

        s->start();
    }
}

Depending on the looptype of the Sequence, the delay for the Singles has to be recalculated:

// in Sequence.h:
if(looptype == LoopTypes::Yoyo)
{
    float delay = this->getDelay();

    if(heads)
    {
        for(int j = i + 1; j < singles.size(); j++)
        {
            delay += (singles.at(j)->getDuration() * 2.0f);
        }
    }
    else
    {
        for(int j = i - 1; j >= 0; j--)
        {
            delay += (singles.at(j)->getDuration() * 2.0f);
        }
    }
    singles.at(i)->setDelay(delay);
}

heads is a boolean that keeps track if the Sequence is in the ‘downwards’ or ‘upwards’ motion of the yo-yo. To get the correct delay, we take twice the length of the entire sequence, and add the current delay of the opposite Single: so if the entire sequence is 10 seconds long, and the current Single is second from the end, the delay is 20 seconds plus the delay of the second from the start.

Callbacks

Callbacks in Action are std::function<void()>, which means that they can be used with std::bind and lambda function notation:

Callback cb = std::bind([&](){ std::cout << "ACTION COMPLETE\n"; });

Callback cb2 = std::bind([&](){ t->someRandomFunction(temp); });

This allows the callbacks to be extremely flexible for the user.

Ease types

Action has all of the basic easing types built in, using std::function pointers:

std::function<float(float t)> CubicEaseIn = [](float t)
{
    return t * t * t;
};

In addition, cubic and quartic bezier curves are also supported:

typedef std::function<float(float t)> TimingFunction;

template <typename T>
TimingFunction GetCurveFromPoints(T x1, T y1, T x2, T y2)
{
    // clamp x values to [0,1]
    if(x1 > 1) x1 = 1; if(x1 < 0) x1 = 0;
    if(x2 > 1) x2 = 1; if(x2 < 0) x2 = 0;

    TimingFunction Curve = [x1, y1, x2, y2](float t) -> float
    {
        // get the x value for the given time
        float x = (3 * (1 - t) * (1 - t) * t * x1) + (3 * (1 - t) * t * t * x2) + t * t * t;

        // get the y value for the given x value
        float y = (3 * (1 - x) * (1 - x) * x * y1) + (3 * (1 - x) * x * x * y2) + x * x * x;

        return y;
    };

    return Curve;
}

These clamp the points from 0 - 1 along the x-axis to make sure the integrity of the mathematical function isn’t compromised.

Retrospection and Postmortem

WIP.