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 forname
equal toobj
.getData(name)
: Retrieves the last dataobj
set forname
.
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);
}
};