牛骨文教育服务平台(让学习变的简单)

坐标旋转和斜面反弹

坐标旋转,顾名思义,就是说围绕着某个点旋转坐标系。这一章就来介绍一下如何实现坐标旋转和坐标旋转的作用。
内容如下:

  • 坐标旋转
  • 斜面反弹

1、坐标旋转

1.1 简单旋转

在前面的三角函数一章中的实例“指红针”中,我们已经使用过坐标旋转技术。只需一个中心点,一个物体,还有半径和角度(弧度制),通过增减这个角度,然后用基本的三角函数计算位置,就能使物体围绕着中心点旋转。
初始化参数:

vr = 0.1;  //角度增量
angle = 0;
radius = 100;
centerX = 0;
centerY = 0;

在动画循环中做下列计算:

object.x = centerX + Math.cos(angle) * radius;
object.y = centerY + Math.sin(angle) * radius;
angle += vr;

实例: canvas-demo/rotate.html

每次旋转角度vr设置为0.05,根据上面的公式计算小球旋转后的位置。

如果只知道物体的位置和中心点,如何做旋转呢?其实也不难,我们只需根据两个点来计算出当前角度和半径即可:

var dx = ball.x - centerX;
var dy = ball.y - centerY;
var angle = Math.atan2(dy,dx);
var radius = Math.sqrt(dx * dx + dy * dy);

得到角度和半径,我们就可以像上面那样旋转了。

上面的方法比较适合单个物体旋转,对于多个物体的旋转,这种方法不是很高效,当然,我们有更好的方法。

1.2 高级坐标旋转

如果物体(x,y)围绕着一个点(x2,y2)旋转,而我们只知道物体的坐标和点的坐标,那如何计算旋转后物体的坐标呢?下面有一个很适合这种场景的公式:

x1 = (x - x2) * cos(rotation) - (y - y2) * sin(rotation);
y1 = (y - y2) * cos(rotation) + (x - x2) * sin(rotation);

我们可以认为(x-x2)、(y-y2)是物体相对于旋转点的坐标,rotation是旋转角度(旋转量,指当前角度和旋转后的角度的差值),x1、y1是物体旋转后的位置坐标。

注意:这里采取的依旧是弧度制。

这条公式是不是看的有点糊里糊涂的,不知道怎么来的,下面我们将介绍它是如何得出的。

先看图:

/*物体当前的坐标*/

x = radius * cos(angle);
y = radius * sin(angle);

/*物体旋转rotation后的坐标*/

x1 = radius * cos(angle + rotation);
y1 = radius * sin(angle + rotation);

下面又来介绍一个两个关于三角函数的数学公式了。
两角之和的余弦值:

cos(a + b) = cos(a) * cos(b) - sin(a) * sin(b);

两角之和的正弦值:

sin(a + b) = sin(a) * cos(b) + cos(a) * sin(b);

基于这两条推导公式,我们将x1和y1的公式展开:

x1 = radius * cos(angle) * cos(rotation) - radius * sin(angle) *sin(rotation);
y1 = radius * sin(angle) * cos(rotation) + radius * cos(angle) * sin(rotation);

最后将x、y变量代入公式,就会得到最初那条公式:

x1 = x * cos(rotation) - y * sin(rotation);
y1 = y * cos(rotation) + x * sin(rotation);

注意:这里的x、y是相对于旋转点的x、y坐标,也就是上面的(x-x2)、(y-y2),而不是相对于坐标系的坐标。

使用这个公式,我们不需要知道起始角度和旋转后的角度,只需要知道旋转角度即可。

(1)旋转单个物体

有了公式,当然要实践一下,我们先来试试旋转单个物体

这里的vr依旧是0.05,然后计算这个角度的正弦和余弦值,然后根据小球相对于中心点的位置计算出x1、y1,接着利用公式计算出小球旋转后的坐标。

sin = Math.sin(angle);   
cos = Math.cos(angle);

var x1 = ball.x - centerX;   
var y1 = ball.y - centerY;   
ball.x = centerX + (x1 * cos - y1 * sin);   
ball.y = centerY + (y1 * cos + x1 * sin);

还是要强制一句,这个公式传入的x、y是物体相对于旋转点的坐标,不是旋转点的坐标,也不是物体的坐标。

你可能会疑惑,这不是跟第一个例子的效果一样吗?为什么要用这个公式呢?不要急,接着看下面的旋转多个物体,看完后你就会明白这条公式的好处了。

(2)旋转多个物体

假如要旋转多个物体,我们将小球保存在变量balles的数组中,旋转代码如下:

balles.forEach(function(ball){
  var dx = ball.x - centerX;
  var dy = ball.y - centerY;
  var angle = Math.atan2(dy,dx);
  var dist = Math.sqrt(dx * dx + dy * dy);

  angle += vr;

  ball.x = centerX + Math.cos(angle) * dist;
  ball.y = centerY + Math.sin(angle) * dist;
});

使用高级坐标旋转是这样的:

var cos = Math.cos(vr);
var sin = Math.sin(vr);

balles.forEach(function(ball){
  var x1 = ball.x - centerX;
  var y1 = ball.y - centerY;
  var x2 = x1 * cos - y1 * sin;
  var y2 = y2 * cos + x1 * sin;

  ball.x = centerX + x2;
  ball.y = centerY + y2;
});

我们来对比一下这两种方式,在第一种方式中,每次循环都调用了4次Math函数,也就是说,旋转每一个小球都要调用4次Math函数,而第二种方式,只调用了两次Math函数,而且都位于循环之外,不管增加多少小球,它们都只会执行一次。

实例:canvas-demo/rotate3.htmll

我们用鼠标来控制多个球的旋转速度,如果鼠标位置在canvas的中央,那么它们都静止不动,如果鼠标向左移动,这些小球就沿逆时针方向旋转,如果向右移动,小球就沿顺时针方法越转越快。

2、斜面反弹

前面我们学习了如何让物体反弹,不过都是基于垂直或水平的反弹面,如果是一个斜面,我们该如何反弹呢?

处理斜面反弹,我们要做的是:旋转整个系统使反弹面水平,然后做反弹,最后再旋转回来,这意味着反弹面、物体的坐标位置和速度向量都发生了旋转。

图1是小球撞向斜面,向量箭头表示小球的方向
图2中,整个场景旋转了,反弹面处于水平位置,就像前面碰撞示例中的底部障碍一样。在这里,速度向量也随着整个场景向右旋转了。
图3中,我们就可以实现反弹了,也就是改变y轴上的速度
图4中,就是整个场景旋转回到最初的角度。

什么,你还看不明白,那我再给你画个图吧:

斜面和小球的旋转都是相对于(x,y)。

经历了上图,你应该明白,如果还不明白,请自己画图看看,画出每一步。

2.1 旋转起来

为了斜面反弹的真实性,我们需要创建一个斜面,在canvas中,我们只需画一条斜线,这样我们就可以看到小球在哪里反弹了。

相信画直线对你来说不难,下面创建一个Line类:

function Line(x1, y1, x2, y2) {   
  this.x = 0;   
  this.y = 0;   
  this.x1 = (x1 === undefined) ? 0 : x1;   
  this.y1 = (y1 === undefined) ? 0 : y1;   
  this.x2 = (x2 === undefined) ? 0 : x2;   
  this.y2 = (y2 === undefined) ? 0 : y2;   
  this.rotation = 0;   
  this.scaleX = 1;   
  this.scaleY = 1;   
  this.lineWidth = 1;   
};

/*绘制直线*/

Line.prototype.draw = function(context) {   
  context.save();   
  context.translate(this.x, this.y); //平移   
  context.rotate(this.rotation); // 旋转   
  context.scale(this.scaleX, this.scaleY);   
  context.lineWidth = this.lineWidth;   
  context.beginPath();   
  context.moveTo(this.x1, this.y1);   
  context.lineTo(this.x2, this.y2);   
  context.closePath();   
  context.stroke();   
  context.restore();   
};

先看实例(点击一下按钮看看):canvas-demo/rotateBevel.html

在上面的例子中,我创建的小球是随机位置的,不过都位于斜线的上方。

一开始,我们首先声明ball、line、gravity和bounce,然后初始化ball和line的位置,接着计算直线旋转角度的cos和sin值

line = new Line(0, 0, 300, 0);   
line.x = 50;   
line.y = 200;   
line.rotation = (10 * Math.PI / 180); //设置线的倾斜角度

cos = Math.cos(line.rotation);   
sin = Math.sin(line.rotation);

接下来,用小球的位置减去直线的位置(50,100),就会得到小球相对于直线的位置:

var x1 = ball.x - line.x;   
var y1 = ball.y - line.y;

完成了上面这些,我们现在可以开始旋转,获取旋转后的位置和速度:

var x2 = x1 * cos + y1 * sin;   
var y2 = y1 * cos - x1 * sin;   

如果你够仔细,可能你也发现了,这里的代码好像和坐标旋转公式有点区别:

x1 = x * cos(rotation) - y * sin(rotation);  
y1 = y * cos(rotation) + x * sin(rotation);

加号变减号,减号变加号了,写错了吗?其实没有,这是因为现在直线的斜度是10,那要将它旋转成水平的话,就不是旋转10,而是-10才对:

sin(-10) = - sin(10)
cos(-10) = cos(10)

当你旋转后获得相对于直线的坐标和速度后,你就可以使用位置x2、y2和速度vx1、vy1来执行反弹了,根据什么来判断球碰撞直线呢?用y2,因为此时y2是相对直线的位置的,所以“底边”就是line自己,也就是0,还要考虑小球的大小,需要判断y2是否大于0-ball.radius:

if(y2 > -ball.radius) {   
  y2 = -ball.radius;   
  vy1 *= bounce;   
};

最后,你还要将整个系统旋转归位,计算原始角度的正余弦值:

x1 = x2 * cos - y2 * sin;   
y1 = y2 * cos + x2 * sin;

求得ball实例的绝对位置:

ball.x = line.x + x1;   
ball.y = line.y + y1;

2.2 优化代码

在上面的例子中,有些代码在反弹之前是没必要执行的,所以我们可以将它们放到if语句中:

if(y2 > -ball.radius) {   
  var x2 = x1 * cos + y1 * sin;
  var vx1 = ball.vx * cos + ball.vy * sin;   
  var vy1 = ball.vy * cos - ball.vx * sin;

  y2 = -ball.radius;   
  vy1 *= bounce;   

  //旋转回来,计算坐标和速度   
  x1 = x2 * cos - y2 * sin;   
  y1 = y2 * cos + x2 * sin;   
  ball.vx = vx1 * cos - vy1 * sin;   
  ball.vy = vy1 * cos + vx1 * sin;   
  ball.x = line.x + x1;   
  ball.y = line.y + y1;
};

2.3 修复“不从边缘落下”的问题

如果你试过上面的例子,现在你也看到了,即使小球到了直线的边缘,它还是会沿着直线方向滚动,这不科学,原因在于我们是模拟,并不是真实的碰撞,小球并不知道线的起点和终点在哪里。

2.3.1 碰撞检测

在前面的碰撞检测中,我们介绍过一个方法tool.intersects(),可用来检测直线的边界框是否与小球的边界框重叠。

当然,我们还需要获得直线的边界框,这里给Line类添加一个方法getBound:

Line.prototype.getBound = function() {   
  if(this.rotation === 0) {   
    var minX = Math.min(this.x1, this.x2);   
    var minY = Math.min(this.y1, this.y2);   
    var maxX = Math.max(this.x1, this.x2);   
    var maxY = Math.max(this.y1, this.y2);   

      return {   
        x: this.x + minX,   
        y: this.y + minY,   
        width: maxX - minX,   
        height: maxY - minY   
      };   
  } else {   

  //基于坐标系原点旋转   

    var sin = Math.sin(this.rotation);   
    var cos = Math.cos(this.rotation);   

    var x1r = cos * this.x1 + sin * this.y1;   
    var x2r = cos * this.x2 + sin * this.y2;   
    var y1r = cos * this.y1 + sin * this.x1;   
    var y2r = cos * this.y2 + sin * this.x2;   

    return {   
      x: this.x + Math.min(x1r, x2r),   
      y: this.y + Math.min(y1r, y2r),   
      width: Math.max(x1r, x2r) - Math.min(x1r, x2r),   
      height: Math.max(y1r, y2r) - Math.min(y1r, y2r)   
    };   
  }   
};

返回一个包含有x、y、width和height属性的矩形对象。

使用如下:

if(tool.intersects(ball.getBound(), line.getBound()){
}

下面介绍一个更精确的方法。

2.3.2 边界检查

var bounds = line.getBound();

if(ball.x + ball.radius > bounds.x && ball.x - ball.radius <bounds.x + bounds.width){
  //执行反弹
}

如上代码所示,如果小球的边界框小于bounds.x(左边缘),或者大于bounds.x+bounds.width(右边缘),就说明它已经从线段上掉落了。

注意:因为小球的圆心是中心点,左边框和上边框就是圆心位置减去小球的半径,有边框和下边框就是圆心位置加上小球的半径。

2.4 多个斜面反弹

要实现多个斜面反弹其实也不难,只需要创建多个斜面并循环即可。

实例:canvas-demo/rotateBevel2.html

上面的例子中,我们已经实现了多个斜面反弹,可似乎有一个问题,当小球从第二个斜面掉落时,并没有掉落到第三个斜面上,而是在半空中就反弹回去了,这是为什么呢?下面我们就来修复这个问题。

2.5 修复“线下”的问题

在上面的检测碰撞时,首先要判断小球是否在直线附近,然后进行坐标旋转,得到旋转后的位置和速度,接着,判断小球旋转后的纵坐标y2是否越过了直线,如果超过了,则执行反弹。

if(y2 > -ball.radius){}

上面的代码也是导致2.4中例子没有掉落到下面的原因,因为当小球从第二个斜面掉落下,却是落到了第一个斜面的下面,也就会触发第一个斜面和小球的反弹,这不是我们想要的,如何解决呢?先看下图:

左边小球在y轴上的速度大于它与直线的相对距离,这表示它刚刚从直线上穿越下来;右边小球的速度向量小于它与直线的相对距离,这表示,它在这一帧和上一帧都位于线下,因此它此时只是在线下运动,所以我们需要的是在小球穿过直线的那一瞬间才执行反弹。

也就是:比较vy1和y2,仅当vy1大于y2时才执行反弹:

if(y2 > -ball.radius && y2 < vy1) {}

看看修复后的例子:canvas-demo/rotateBevel3.html

总结

这一章,我们介绍了坐标旋转和斜面反弹,其中不遗余力的分析了坐标旋转公式,并且修复了“不从边缘落下”和“线下”两个问题,一定要掌握坐标旋转,后面我们还将多处用到。

附录

重要公式:

(1)坐标旋转

x1 = x * Math.cos(rotation) - y * Math.sin(rotation);
y1 = y * Math.cos(rotation) + x * Math.sin(rotation);

(2)反向坐标旋转

x1 = x * Math.cos(rotation) + y * Math.sin(rotation);
y1 = y * Math.cos(rotation) - x * Math.sin(rotation);