hazzens scribe

links

RenderPipeline: Handling Animation for a Turn-Based Game

27 Nov 2013

My latest game project is a turn-based affair, far removed from my area of expertise in games with real-time clocks. I’ve had to do away with the standard update/render loop used in previous games for an event-based system and a simple rendering pipeline. In this post, I’ll be going over just the animation portion and not the event-system.

Disclaimer: I did roughly 0 research on the right way of doing a turn-based game and just jumped in; this may either be common knowledge or out of left-field.

The RenderPipeline works as a queue with some additional functionality. The most basic call - move a unit from one position to another over 500ms - uses the following code:

var startPosition = ...;
var endPosition = ...;
renderPipeline.enqueue(function(ts) {
  this.sprite.position = Vector.lerp(startPosition, endPosition, ts.alpha);
}.bind(this), 500);

If the RenderPipeline was empty at the time of the call, the unit will smoothly animate over 500ms from the start to the end position. If the pipeline was not empty, the movement will run after all animations enqueue-d before.

The ts object passed to the animation callback has quite a few properties:

  • alpha: Number on range [0, 1] for how far the animation has progressed.
  • start: True if this is the first call of the callback.
  • end: True if this will be the last call of the callback.
  • duration: The total duration of the animation, in ms.
  • elapsed: The elapsed time of the animation, in ms.
  • remaining: The remaining time of the animation, in ms.

In addition, it has two methods for storing/receiving callback-specific data:

  • addData(name, obj): Sets the data for name equal to obj.
  • getData(name): Retrieves the last data obj set for name.

Strictly, not all of these fields are necessary. However, more of them are necessary than you might think. Naively, it seems like start and end aren’t necessary if you have alpha - if alpha is 0, then the animation must be starting, and if it is 1 it must be ending. But what about an animation with a duration of 0? The callback should only be called once (as the animation only lasts a single frame), and that frame is both the start and the end. The first frame of the animation is also not at alpha=0, but rather however long a frame is.

The actual “move” callback is1:

function(ts) {
  if (ts.start) {
    this.playAnim('walk');
  }
  this.sprite.position = Vector.lerp(startPosition, endPosition, ts.alpha);
  if (ts.end) {
    this.playAnim('idle');
  }
}

This allows for some really nice code. When the AI moves a unit, for instance, it is possible the unit is under the fog of war. If we waited 500ms for that unit to move when the player can’t see it at all, they would quickly get bored. Instead, we can change the running time of the animation from 500ms to 0ms, and no other code needs to change. Callbacks with a duration of 0 are immediately run when it is their turn and then discarded.

The RenderPipeline doesn’t simply run each function, sequentially, until it has nothing more to run. Instead, it has an idea of “epochs”. Each callback enqueued belongs to an epoch, and every callback within an epoch runs simultaneously. When every callback in epoch N has run, they are discarded and epoch N + 1 is run. This can happen in the middle of the frame - if the current epoch has N time left, but the frame duration is N + M, the current epoch is called as if N had elapsed, then the next epoch is called with a duration of M.

Consider a unit attacking another - you might want to show the attacker bumping towards the defender, some floating numbers to show the damage, and then do the same for the defender if it retaliates. This is easily accomplished by enqueuing 4 separate callbacks in two epochs:

var attackInfo = ...;
var attackerPos = attackInfo.attacker.pos;
var defenderPos = attackInfo.defender.pos;
RenderPipeline.enqueue(function(ts) {
  // Animate the unit moving towards and then back to its starting pos.
  var alpha = 0.5 - Math.abs(0.5 - ts.alpha);
  attackInfo.attacker.sprite.position = Vector.lerp(
    attackerPos, defenderPos, alpha);
}, 500);

var floatingTextCallback = makeFloatTextCb(
  attackInfo.damage, defenderPos, '#f00');
RenderPipeline.concurrently(floatingTextCallback, 500);

if (attackInfo.counterDamage) {
  // Start a new epoch for the counter-attack.
  RenderPipeline.enqueue(function(ts) {
    var alpha = 0.5 - Math.abs(0.5 - ts.alpha);
    attackInfo.defender.sprite.position = Vector.lerp(
      defenderPost, attackerPos, alpha);
  }, 500);

  var floatingTextCallback = makeFloatTextCb(
    attackInfo.counterDamage, attackerPos, '#f00');
  RenderPipeline.concurrently(floatingTextCallback, 500);
};

By calling concurrently instead of enqueue, a new epoch will not be started and the function will run in sync with any other in the epoch. There is one more method of requesting an animation - consider the case where an animation should play after some others, but needn’t block any future animations from playing.

I use this for displaying “toasts” to notify the player of some event. I’d like to make sure the toast doesn’t display until the event has happened, but the toast shouldn’t block any subsequent animations from happening. This is done with the background method2:

var displayToast = function(text) {
  RenderPipeline.background(function(ts) {
    if (ts.start) {
      ts.addData('toast', makeToast(text);
    }
    var toast = ts.getData('toast');
    toast.position(ts.alpha);

    if (ts.end) {
      toast.destroy();
    }
  };
};

In addition to simplifying animation code and allowing me to ignore explicit ordering of animations, there are a few other benefits to the system. For one, by enqueue-ing a 0-duration callback, you can schedule some code to run after all animations have completed - say, update the UI after a unit moves to show its new options.

Additionally, the clock used by RenderPipeline is not the real clock. It advances via a tick method which takes a duration in milliseconds. The pipeline clock can run faster or slower than real time. This can be used to trivially implement an “animation speed” as seen in many turn-based games without impacting any other updates. For debugging, the clock can also be stopped completely and stepped forward a single step on command - either to debug issues, or to take screenshots.

The full code:

var RenderPipeline = function() {
  this.epochs = [];
  this.inBackground = [];
};

// Start a new epoch with the given function, running over 'duration' ms.
// The epoch will not expire until this function has completed.
RenderPipeline.prototype.enqueue = function(fn, duration) {
  this.enqueueImpl_(fn, duration, {});
};

// Enqueue a function in the current epoch, running over 'duration' ms.
// The epoch will not expire until this function has completed.
RenderPipeline.prototype.concurrently = function(fn, duration) {
  this.enqueueImpl_(fn, duration, {concurrent: true});
};

// Enqueue a function in the current epoch, running over 'duration' ms,
// that will run in the background once started and not stop the epoch from
// expiring.
RenderPipeline.prototype.background = function(fn, duration) {
  this.enqueueImpl_(fn, duration, {background: true});
};

// Force a new epoch to be created.
RenderPipeline.prototype.newEpoch = function() {
  var epoch = {
    fns: [],
    background: [],
  };
  this.epochs.push(epoch);
};

// Run the pipeline forward 't' milliseconds.
RenderPipeline.prototype.tick = function(t) {
  var left = t;
  while (left > 0 && this.epochs.length) {
    var toRun = this.epochs[0].fns;
    if (this.epochs[0].background.length) {
      this.inBackground.push.apply(
          this.inBackground, this.epochs[0].background);
      this.epochs[0].background = [];
    }
    // tick() returns how much time was not used - if a function had
    // 20ms remaining and t=30, it will return 10. We need to bank this
    // time for the next epoch, but want the _smallest_ amount returned by any
    // function. 
    var epochRemainder = toRun.length ? 0 : left;
    for (var i = 0; i < toRun.length;) {
      var toRunLeft = toRun[i].tick(left);
      if (i == 0) {
        epochRemainder = toRunLeft;
      } else {
        epochRemainder = Math.min(epochRemainder, toRunLeft);
      }
      // Some leftover, so the function finished.
      if (toRunLeft > 0) {
        toRun.splice(i, 1);
      } else {
        i++;
      }
    }
    left = epochRemainder;
    if (toRun.length == 0) {
      this.epochs.shift();
    }
  }
  for (var i = 0; i < this.inBackground.length;) {
    if (this.inBackground[i].tick(t)) {
      this.inBackground.splice(i, 1);
    } else {
      i++;
    }
  }
};

// Throw everything away without running it.
RenderPipeline.prototype.flush = function() {
  this.epochs = [];
  this.inBackground = [];
};

RenderPipeline.TimeStruct = function(duration, fn) {
  var data = {};

  this.start = true;
  this.end = false;
  this.duration = duration;
  this.elapsed = 0;
  this.left = duration;
  this.alpha = 0;
  this.addData = function(key, obj) { data[key] = obj; };
  this.getData = function(key) { return data[key]; };

  this.tick = function(t) {
    if (this.end) return t;
    var leftover = Math.max(0, t - this.left);
    this.elapsed += t;
    this.elapsed = Math.min(this.elapsed, this.duration);
    this.left = this.duration - this.elapsed;
    this.alpha = this.duration == 0 ? 1 : this.elapsed / this.duration;
    if (this.left == 0) {
      this.end = true;
    }
    fn(this);
    this.start = false;
    return leftover;
  };
};

RenderPipeline.prototype.enqueueImpl_ = function(fn, duration, opts) {
  duration = duration || 0;
  var ts = new RenderPipeline.TimeStruct(duration, fn);

  if (!(opts.concurrent || opts.background) || !this.epochs.length) {
    this.newEpoch();
  }
  var addTo = this.epochs[this.epochs.length - 1];
  if (opts.background) {
    addTo.background.push(ts);
  } else {
    addTo.fns.push(ts);
  }
};
  1. Sprite-sheet based animation is handled outside of the RenderPipeline, but in-sync with the progress. That makes running an animation (that may loop) as easy as playAnim(name) with no further work.

  2. background doesn’t start a new epoch, but the newEpoch method can be used if required.