## 坐標旋轉和斜面反彈
坐標旋轉,顧名思義,就是說圍繞著某個點旋轉坐標系。這一章就來介紹一下如何實現坐標旋轉和坐標旋轉的作用。
內容如下:
- 坐標旋轉
- 斜面反彈
**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);
```