游戏中的路径动画设计与实现

路径动画让对象沿着指定路径运动,在游戏中用着广泛的应用,比如塔防类游戏就经常使用路径动画。前几天在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

你可能感兴趣的:(游戏引擎/GUI的设计与实现)