游戏中的路径动画设计与实现
路径动画让对象沿着指定路径运动,在游戏中用着广泛的应用,比如塔防类游戏就经常使用路径动画。前几天在cantk里实现了路径动画(源码在github上),路径动画实现起来并不难,实际上写起来挺有意思的,这里和大家分享一下。
先说下路径动画的基本需求:
- 1.支持基本的路径类型:直线,弧线,抛物线,二次贝塞尔曲线,三次贝塞尔曲线,正弦(余弦)和其它曲线。
- 2.对象沿路径运动的速度是可以控制的。
- 3.对象沿路径运动的加速度是可以控制的。
- 4.对象沿路径运动的角度(切线方向或不旋转)是可以控制的。
- 5.可以通过几条基本的路径组合成一条复合的路径。
- 6.多个对象可以沿同一条路径运动。
- 7.同一个对象也可以多次沿同一条路径运动。
- 8.对象到达终点时能触发一个事件通知游戏。
看起来是不是很复杂呢? 呵呵,其实一点也不难,不过也有点挑战:
1.计算任意时刻对象所在的位置。不是通过x计算y的值,而是通过时间t计算x和y的值。所以需要使用参数方程,时间就是参数,x和y各对应一个方程。
2.计算任意时刻对象的方向。这个确实有点考验我(数学不怎么好:(),开始是打算通过对曲线的方程求导数得到切线方程,但是发现计算量很大,而且atan只能得到0到180度的角度,要得到0到360的角度还要进一步计算。后来一想,导数不是dy/dx的极限吗,只有dx极小就可以得到近似的结果了。所以决定取当前时刻的点和下一个邻近时刻的点来计算角度。
3.控制对象的速度很容易,我们可以指定通过此路径的总时间来控制对象的速度。
4.控制对象的加速度需要点技巧。对于用过缓动作(Tween)动画的朋友来说是很简单的,可以使用不同的Ease来实现。cantk沿用了android里的术语,叫插值算法(Interpolator),常见的有加速,减速,匀速和回弹(Bounce)。cantk里有缺省的实现,你也可以自己实现不同的插值算法。
5.复合路径当然很简单了,用Composite模式就行了,不过这里我并没有严格使用Composite模式。
6.路径的实现并不关联沿着它运动的对象,由更上一次的模块去管理对象吧,好让路径算法本身是独立的。
现在我们来实现各种路径吧:
注:duration是通过此路径的时间,interpolator是插值算法。
- 0.定义一个基类BasePath,实现一些缺省的行为。
function BasePath() {
return;
}
BasePath.prototype.getPosition = function(t) {
return {x:0, y:0};
}
BasePath.prototype.getDirection = function(t) {
var p1 = this.getPosition(t);
var p2 = this.getPosition(t+0.1);
return BasePath.angleOf(p1, p2);
}
BasePath.prototype.getStartPoint = function() {
return this.startPoint ? this.startPoint : this.getPosition(0);
}
BasePath.prototype.getEndPoint = function() {
return this.endPoint ? this.endPoint : this.getPosition(this.duration);
}
BasePath.prototype.getSamples = function() {
return this.samples;
}
BasePath.prototype.draw = function(ctx) {
var n = this.getSamples();
var p = this.getStartPoint();
ctx.moveTo(p.x, p.y);
for(var i = 0; i <= n; i++) {
var t = this.duration*i/n;
var p = this.getPosition(t);
ctx.lineTo(p.x, p.y);
}
return this;
}
BasePath.angleOf = function(from, to) {
var dx = to.x - from.x;
var dy = to.y - from.y;
var d = Math.sqrt(dx * dx + dy * dy);
if(dx == 0 && dy == 0) {
return 0;
}
if(dx == 0) {
if(dy < 0) {
return 1.5 * Math.PI;
}
else {
return 0.5 * Math.PI;
}
}
if(dy == 0) {
if(dx < 0) {
return Math.PI;
}
else {
return 0;
}
}
var angle = Math.asin(Math.abs(dy)/d);
if(dx > 0) {
if(dy > 0) {
return angle;
}
else {
return 2 * Math.PI - angle;
}
}
else {
if(dy > 0) {
return Math.PI - angle;
}
else {
return Math.PI + angle;
}
}
}
- 1.直线。两点决定一条直线,从一个点运动到另外一个点。
function LinePath(duration, interpolator, x1, y1, x2, y2) {
this.dx = x2 - x1;
this.dy = y2 - y1;
this.x1 = x1;
this.x2 = x2;
this.y1 = y1;
this.y2 = y2;
this.duration = duration;
this.interpolator = interpolator;
this.angle = BasePath.angleOf({x:x1,y:y1}, {x:x2, y:y2});
this.startPoint = {x:this.x1, y:this.y1};
this.endPoint = {x:this.x2, y:this.y2};
return;
}
LinePath.prototype = new BasePath();
LinePath.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
var x = this.x1 + this.dx * percent;
var y = this.y1 + this.dy * percent;
return {x:x, y:y};
}
LinePath.prototype.getDirection = function(t) {
return this.angle;
}
LinePath.prototype.draw = function(ctx) {
ctx.moveTo(this.x1, this.y1);
ctx.lineTo(this.x2, this.y2);
return this;
}
LinePath.create = function(duration, interpolator, x1, y1, x2, y2) {
return new LinePath(duration, interpolator, x1, y1, x2, y2);
}
- 2.弧线,由圆心,半径,起始幅度和结束幅度决定一条弧线。
function ArcPath(duration, interpolator, xo, yo, r, sAngle, eAngle) {
this.xo = xo;
this.yo = yo;
this.r = r;
this.sAngle = sAngle;
this.eAngle = eAngle;
this.duration = duration;
this.interpolator = interpolator;
this.angleRange = eAngle - sAngle;
this.startPoint = this.getPosition(0);
this.endPoint = this.getPosition(duration);
return;
}
ArcPath.prototype = new BasePath();
ArcPath.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
var angle = this.sAngle + percent * this.angleRange;
var x = this.xo + this.r * Math.cos(angle);
var y = this.yo + this.r * Math.sin(angle);
return {x:x, y:y};
}
ArcPath.prototype.getDirection = function(t) {
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
var angle = this.sAngle + percent * this.angleRange + Math.PI * 0.5;
return angle;
}
ArcPath.prototype.draw = function(ctx) {
ctx.arc(this.xo, this.yo, this.r, this.sAngle, this.eAngle, this.sAngle > this.eAngle);
return this;
}
ArcPath.create = function(duration, interpolator, xo, yo, r, sAngle, eAngle) {
return new ArcPath(duration, interpolator, xo, yo, r, sAngle, eAngle);
}
- 3.抛物线。这里的抛物线不是数学上严格的抛物线,也不是物理上严格的抛物线,而是游戏中的抛物线。游戏中的抛物线允在X/Y方向指定不同的加速度(即重力),它由初始位置,X/Y方向的加速度和初速度决定。
function ParaPath(duration, interpolator, x1, y1, ax, ay, vx, vy) {
this.x1 = x1;
this.y1 = y1;
this.ax = ax;
this.ay = ay;
this.vx = vx;
this.vy = vy;
this.duration = duration;
this.interpolator = interpolator;
this.startPoint = this.getPosition(0);
this.endPoint = this.getPosition(duration);
var dx = Math.abs(this.endPoint.x-this.startPoint.x);
var dy = Math.abs(this.endPoint.y-this.startPoint.y);
this.samples = Math.max(dx, dy);
return;
}
ParaPath.prototype = new BasePath();
ParaPath.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
t = (percent * this.duration)/1000;
var x = 0.5 * this.ax * t * t + this.vx * t + this.x1;
var y = 0.5 * this.ay * t * t + this.vy * t + this.y1;
return {x:x, y:y};
}
ParaPath.create = function(duration, interpolator, x1, y1, ax, ay, vx, vy) {
return new ParaPath(duration, interpolator, x1, y1, ax, ay, vx, vy);
}
- 4.正弦和余弦曲线其实一样,正弦偏移90度就是余弦。它由初始位置,波长,波速,振幅和角度偏移决定。
function SinPath(duration, interpolator, x1, y1, waveLenth, v, amplitude, phaseOffset) {
this.x1 = x1;
this.y1 = y1;
this.v = v;
this.amplitude = amplitude;
this.waveLenth = waveLenth;
this.duration = duration;
this.phaseOffset = phaseOffset ? phaseOffset : 0;
this.interpolator = interpolator;
this.range = 2 * Math.PI * (v * duration * 0.001)/waveLenth;
this.startPoint = this.getPosition(0);
this.endPoint = this.getPosition(duration);
var dx = Math.abs(this.endPoint.x-this.startPoint.x);
var dy = Math.abs(this.endPoint.y-this.startPoint.y);
this.samples = Math.max(dx, dy);
return;
}
SinPath.prototype = new BasePath();
SinPath.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
t = percent * this.duration;
var x = (t * this.v)/1000 + this.x1;
var y = this.amplitude * Math.sin(percent * this.range + this.phaseOffset) + this.y1;
return {x:x, y:y};
}
SinPath.create = function(duration, interpolator, x1, y1, waveLenth, v, amplitude, phaseOffset) {
return new SinPath(duration, interpolator, x1, y1, waveLenth, v, amplitude, phaseOffset);
}
- 5.三次贝塞尔曲线。它由4个点决定,公式请参考百度文库。
function Bezier3Path(duration, interpolator, x1, y1, x2, y2, x3, y3, x4, y4) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
this.x3 = x3;
this.y3 = y3;
this.x4 = x4;
this.y4 = y4;
this.duration = duration;
this.interpolator = interpolator;
this.startPoint = this.getPosition(0);
this.endPoint = this.getPosition(duration);
return;
}
Bezier3Path.prototype = new BasePath();
Bezier3Path.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
t = percent;
var t2 = t * t;
var t3 = t2 * t;
var t1 = 1 - percent;
var t12 = t1 * t1;
var t13 = t12 * t1;
//http://wenku.baidu.com/link?url=HeH8EMcwvOjp-G8Hc-JIY-RXAvjRMPl_l4ImunXSlje-027d01NP8SkNmXGlbPVBioZdc_aCJ19TU6t3wWXW5jqK95eiTu-rd7LHhTwvATa
//P = P0*(1-t)^3 + 3*P1*(1-t)^2*t + 3*P2*(1-t)*t^2 + P3*t^3;
var x = (this.x1*t13) + (3*t*this.x2*t12) + (3*this.x3*t1*t2) + this.x4*t3;
var y = (this.y1*t13) + (3*t*this.y2*t12) + (3*this.y3*t1*t2) + this.y4*t3;
return {x:x, y:y};
}
Bezier3Path.prototype.draw = function(ctx) {
ctx.moveTo(this.x1, this.y1);
ctx.bezierCurveTo(this.x2, this.y2, this.x3, this.y3, this.x4, this.y4);
}
Bezier3Path.create = function(duration, interpolator, x1, y1, x2, y2, x3, y3, x4, y4) {
return new Bezier3Path(duration, interpolator, x1, y1, x2, y2, x3, y3, x4, y4);
}
- 6.二次贝塞尔曲线。它由3个点决定,公式请参考百度文库。
function Bezier2Path(duration, interpolator, x1, y1, x2, y2, x3, y3) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
this.x3 = x3;
this.y3 = y3;
this.duration = duration;
this.interpolator = interpolator;
this.startPoint = this.getPosition(0);
this.endPoint = this.getPosition(duration);
return;
}
Bezier2Path.prototype = new BasePath();
Bezier2Path.prototype.getPosition = function(time) {
var t = time;
var timePercent = Math.min(t/this.duration, 1);
var percent = this.interpolator ? this.interpolator.get(timePercent) : timePercent;
t = percent;
var t2 = t * t;
var t1 = 1 - percent;
var t12 = t1 * t1;
//P = (1-t)^2 * P0 + 2 * t * (1-t) * P1 + t^2*P2;
var x = (this.x1*t12) + 2 * this.x2 * t * t1 + this.x3 * t2;
var y = (this.y1*t12) + 2 * this.y2 * t * t1 + this.y3 * t2;
return {x:x, y:y};
}
Bezier2Path.prototype.draw = function(ctx) {
ctx.moveTo(this.x1, this.y1);
ctx.quadraticCurveTo(this.x2, this.y2, this.x3, this.y3);
}
Bezier2Path.create = function(duration, interpolator, x1, y1, x2, y2, x3, y3) {
return new Bezier2Path(duration, interpolator, x1, y1, x2, y2, x3, y3);
}
现在我们把它们包装一下:
function PathAnimation(x, y) {
this.startPoint = {x:x, y:y};
this.endPoint = {x:x, y:y};
this.duration = 0;
this.paths = [];
return;
}
PathAnimation.prototype.getStartPoint = function() {
return this.startPoint;
}
PathAnimation.prototype.getEndPoint = function() {
return this.endPoint;
}
PathAnimation.prototype.addPath = function(path) {
this.paths.push({path:path, startTime:this.duration});
this.endPoint = path.getEndPoint();
this.duration += path.duration;
return this;
}
PathAnimation.prototype.addLine = function(duration, interpolator, p1, p2) {
return this.addPath(LinePath.create(duration, interpolator, p1.x, p1.y, p2.x, p2.y));
}
PathAnimation.prototype.addArc = function(duration, interpolator, origin, r, sAngle, eAngle) {
return this.addPath(ArcPath.create(duration, interpolator, origin.x, origin.y, r, sAngle, eAngle));
}
PathAnimation.prototype.addPara = function(duration, interpolator, p, a, v) {
return this.addPath(ParaPath.create(duration, interpolator, p.x, p.y, a.x, a.y, v.x, v.y));
}
PathAnimation.prototype.addSin = function(duration, interpolator, p, waveLenth, v, amplitude, phaseOffset) {
return this.addPath(SinPath.create(duration, interpolator, p.x, p.y, waveLenth, v, amplitude, phaseOffset));
}
PathAnimation.prototype.addBezier = function(duration, interpolator, p1, p2, p3, p4) {
return this.addPath(Bezier3Path.create(duration, interpolator, p1.x,p1.y, p2.x,p2.y, p3.x,p3.y, p4.x,p4.y));
}
PathAnimation.prototype.addQuad = function(duration, interpolator, p1, p2, p3) {
return this.addPath(Bezier2Path.create(duration, interpolator, p1.x,p1.y, p2.x,p2.y, p3.x,p3.y));
}
PathAnimation.prototype.getDuration = function() {
return this.duration;
}
PathAnimation.prototype.getPathInfoByTime = function(elapsedTime) {
var t = 0;
var paths = this.paths;
var n = paths.length;
for(var i = 0; i < n; i++) {
var iter = paths[i];
var path = iter.path;
var startTime = iter.startTime;
if(elapsedTime >= startTime && elapsedTime < (startTime + path.duration)) {
return iter;
}
}
return null;
}
PathAnimation.prototype.getPosition = function(elapsedTime) {
var info = this.getPathInfoByTime(elapsedTime);
return info ? info.path.getPosition(elapsedTime - info.startTime) : this.endPoint;
}
PathAnimation.prototype.getDirection = function(elapsedTime) {
var info = this.getPathInfoByTime(elapsedTime);
return info ? info.path.getDirection(elapsedTime - info.startTime) : 0;
}
PathAnimation.prototype.draw = function(ctx) {
var paths = this.paths;
var n = paths.length;
for(var i = 0; i < n; i++) {
var iter = paths[i];
ctx.beginPath();
iter.path.draw(ctx);
ctx.stroke();
}
return this;
}
PathAnimation.prototype.forEach = function(visit) {
var paths = this.paths;
var n = paths.length;
for(var i = 0; i < n; i++) {
visit(paths[i]);
}
return this;
}
Cantk里做了进一步包装,使用起来非常简单:先放一个UIPath对象到场景中,然后在onInit事件里增加路径,在任何时间都可以向UIPath增加对象或删除对象。
参考:
* 1.PathAnimation源代码: https://github.com/drawapp8/PathAnimation
* 2.UIPath接口描述https://github.com/drawapp8/cantk/wiki/ui_path_zh
* 3.Cantk项目: https://github.com/drawapp8/cantk