v1nAfter
和 v2nAfter
分別是兩小球碰撞后的速度,現(xiàn)在可以先判斷一下,如果 v1nAfter
小于 v2nAfter
,那么第
1 個(gè)小球和第 2 個(gè)小球會(huì)越來越遠(yuǎn),此時(shí)不用處理碰撞:年前我看到合成大西瓜小游戲火了,想到之前從來沒有研究過游戲方面的開發(fā),這次就想趁著這個(gè)機(jī)會(huì)看看 JavaScript 游戲開發(fā),從原生角度上如何實(shí)現(xiàn)游戲里的物理特性,例如運(yùn)動(dòng)、碰撞。雖然之前研究過物理相關(guān)的動(dòng)畫庫,但是我打算試試不用框架編寫一個(gè)簡單的 JavaScript 物理引擎,實(shí)現(xiàn)小球的碰撞效果。
為什么不用現(xiàn)成的游戲庫呢?因?yàn)槲矣X得在了解底層的實(shí)現(xiàn)原理之后,才能更有效的理解框架上的概念和使用方法,在解決 BUG 的時(shí)候能夠更有效率,同時(shí)對(duì)自己的編碼技能也是一種提升。在對(duì) JavaScript 物理引擎的研究過程中,發(fā)現(xiàn)寫代碼是次要的,最主要的是理解相關(guān)的物理、數(shù)學(xué)公式和概念,雖然我是理科生,但是數(shù)學(xué)和物理從來不是我的強(qiáng)項(xiàng),我不是把知識(shí)還給老師了,而是壓根就沒掌握過。過年期間花了有小半個(gè)月的時(shí)間在學(xué)習(xí)物理知識(shí),現(xiàn)在仍然對(duì)某些概念和推導(dǎo)過程沒有太大的自信,不過最后還算是做出了一個(gè)簡單的、比較滿意的結(jié)果,見下圖。
接下來看一下怎么實(shí)現(xiàn)這樣的效果。
基礎(chǔ)結(jié)構(gòu)
我們這里使用 canvas 來實(shí)現(xiàn) JavaScript 物理引擎。首先準(zhǔn)備項(xiàng)目的基礎(chǔ)文件和樣式,新建一個(gè) index.html、index.js 和 style.css 文件,分別用于編寫 canvas 的 html 結(jié)構(gòu)、引擎代碼和畫布樣式。
在 index.html 的 ?<head />
?標(biāo)簽中引入樣式文件:
<link rel="stylesheet" href="./style.css" />
在 <body />
中,添加 canvas 元素、加載 index.js 文件:
<main>
<canvas id="gameboard"></canvas>
</main>
<script src="./index.js"></script>
這段代碼定義了? id
? 為 ?gameboard
? 的 ?<canvas />
?元素,并放在了 ?<main />
? 元素下, ?<main />
? 元素主要是用來設(shè)置背景色和畫布大小。在 ?<main/>
? 元素的下方引入 index.js 文件,這樣可以在 DOM 加載完成之后再執(zhí)行 JS 中的代碼。
style.css 中的代碼如下:
* {
box-sizing: border-box;
padding: 0;
margin: 0;
font-family: sans-serif;
}
main {
width: 100vw;
height: 100vh;
background: hsl(0deg, 0%, 10%);
}
樣式很簡單,去掉所有元素的外邊距、內(nèi)間距,并把 ?<main/>
? 元素的寬高設(shè)置為與瀏覽器可視區(qū)域相同,背景色為深灰色。
hsl(hue, saturation, brightness) 為 css 顏色表示法之一,參數(shù)分別為色相,飽和度和亮度。
繪制小球
接下來繪制小球,主要用到了 canvas 相關(guān)的 api。
在 index.js 中,編寫如下代碼:
const canvas = document.getElementById("gameboard");
const ctx = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
let width = canvas.width;
let height = canvas.height;
ctx.fillStyle = "hsl(170, 100%, 50%)";
ctx.beginPath();
ctx.arc(100, 100, 60, 0, 2 * Math.PI);
ctx.fill();
代碼中主要利用了二維 context 進(jìn)行繪圖操作:
- 通過 canvas 的 id 獲取 canvas 元素對(duì)象。
- 通過 canvas 元素對(duì)象獲取繪圖 context, ?
getContext()
? 需要一個(gè)參數(shù),用于表明是繪制 2d 圖像,還是使用 webgl 繪制 3d 圖象,這里選擇 2d。context 就類似是一支畫筆,可以改變它的顏色和繪制基本的形狀。 - 給 canvas 的寬高設(shè)置為瀏覽器可視區(qū)域的寬高,并保存到 ?
width
? 和 ?height
? 變量中方便后續(xù)使用。 - 給 context 設(shè)置顏色,然后調(diào)用 ?
beginPath()
? 開始繪圖。 - 使用 ?
arc()
? 方法繪制圓形,它接收 5 個(gè)參數(shù),前兩個(gè)為圓心的 x、y 坐標(biāo),第 3 個(gè)為半徑長度, 第 4 個(gè)和第 5 個(gè)分別是起始角度和結(jié)束角度,因?yàn)?nbsp;?arc()
? 其實(shí)是用來繪制一段圓弧,這里讓它畫一段 0 到 360 度的圓弧,就形成了一個(gè)圓形。這里的角度是使用 radian 形式表示的,0 到 360 度可以用 0 到 2 * Math.PI 來表示。 - 最后使用 ?
ctx.fill()
? 給圓形填上顏色。
這樣就成功的繪制了一個(gè)圓形,我們?cè)谶@把它當(dāng)作一個(gè)小球:
移動(dòng)小球
不過,這個(gè)時(shí)候的小球還是靜止的,如果想讓它移動(dòng),那么得修改它的圓心坐標(biāo),具體修改的數(shù)值則與運(yùn)動(dòng)速度有關(guān)。在移動(dòng)小球之前,先看一下 canvas 進(jìn)行動(dòng)畫的原理:
Canvas 進(jìn)行動(dòng)畫的原理與傳統(tǒng)的電影膠片類似,在一段時(shí)間內(nèi),繪制圖像、更新圖像位置或形狀、清除畫布,重新繪制圖像,當(dāng)在 1 秒內(nèi)連續(xù)執(zhí)行 60 次或以上這樣的操作時(shí),即以 60 幀的速度,就可以產(chǎn)生連續(xù)的畫面。
那么在 JavaScript 中,瀏覽器提供了 ?window.requestAnimationFrame()
? 方法,它接收一個(gè)回調(diào)函數(shù)作為參數(shù),每一次執(zhí)行回調(diào)函數(shù)就相當(dāng)于 1 幀動(dòng)畫,我們需要通過遞歸或循環(huán)連續(xù)調(diào)用它,瀏覽器會(huì)盡可能的在 1 秒內(nèi)執(zhí)行 60 次回調(diào)函數(shù)。那么利用它,我們就可以對(duì) canvas 進(jìn)行重繪,以實(shí)現(xiàn)小球的移動(dòng)效果。
由于 ?
window.requestAnimationFrame()
?的調(diào)用基本是持續(xù)進(jìn)行的,所以我們也可以把它稱為游戲循環(huán)(Game loop)。
接下來我們來看如何編寫動(dòng)畫的基礎(chǔ)結(jié)構(gòu):
function process() {
window.requestAnimationFrame(process);
}
window.requestAnimationFrame(process);
這里的 ?process()
?函數(shù)就是 1 秒鐘要執(zhí)行 60 次的回調(diào)函數(shù),每次執(zhí)行完畢后繼續(xù)調(diào)用 ?window.requestAnimationFrame(process)
?進(jìn)行下一次循環(huán)。如果要移動(dòng)小球,那么就需要把繪制小球和修改圓心 x、y 坐標(biāo)的代碼寫到 ?process()
? 函數(shù)中。
為了方便更新坐標(biāo),我們把小球的圓心坐標(biāo)保存到變量中,以方便對(duì)它們進(jìn)行修改,然后再定義兩個(gè)新的變量,分別表示在 x 軸方向上的速度?vx
?,和 y 軸方向上的速度 ?vy
?,然后把 context 相關(guān)的繪圖操作放到 ?process()
?中:
let x = 100;
let y = 100;
let vx = 12;
let vy = 25;
function process() {
ctx.fillStyle = "hsl(170, 100%, 50%)";
ctx.beginPath();
ctx.arc(x, y, 60, 0, 2 * Math.PI);
ctx.fill();
window.requestAnimationFrame(process);
}
window.requestAnimationFrame(process);
要計(jì)算圓心坐標(biāo) x、y 的移動(dòng)距離,我們需要速度和時(shí)間,速度這里有了, 那么時(shí)間要怎么獲取呢? ?window.requestAnimationFrame()
?會(huì)把當(dāng)前時(shí)間的毫秒數(shù)(即時(shí)間戳)傳遞給回調(diào)函數(shù),我們可以把本次調(diào)用的時(shí)間戳保存起來,然后在下一次調(diào)用時(shí)計(jì)算出執(zhí)行這 1 幀動(dòng)畫消耗了多少秒,然后根據(jù)這個(gè)秒數(shù)和 x、y 軸方向上的速度去計(jì)算移動(dòng)距離,分別加到 x 和 y 上,以獲得最新的位置。注意這里的時(shí)間是上一次函數(shù)調(diào)用和本次函數(shù)調(diào)用的時(shí)間間隔,并不是第 1 次函數(shù)調(diào)用到當(dāng)前函數(shù)調(diào)用總共過去了多少秒,所以相當(dāng)于是時(shí)間增量,需要在之前 x 和 y 的值的基礎(chǔ)上進(jìn)行相加,代碼如下:
let startTime;
function process(now) {
if (!startTime) {
startTime = now;
}
let seconds = (now - startTime) / 1000;
startTime = now;
// 更新位置
x += vx * seconds;
y += vy * seconds;
// 清除畫布
ctx.clearRect(0, 0, width, height);
// 繪制小球
ctx.fillStyle = "hsl(170, 100%, 50%)";
ctx.beginPath();
ctx.arc(x, y, 60, 0, 2 * Math.PI);
ctx.fill();
window.requestAnimationFrame(process);
}
?process()
?現(xiàn)在接收當(dāng)前時(shí)間戳作為參數(shù),然后做了下面這些操作:
- 計(jì)算上次函數(shù)調(diào)用與本次函數(shù)調(diào)用的時(shí)間間隔,以秒計(jì),記錄本次調(diào)用的時(shí)間戳用于下一次計(jì)算。
- 根據(jù) x、y 方向上的速度,和剛剛計(jì)算出來的時(shí)間,計(jì)算出移動(dòng)距離。
- 調(diào)用 ?
clearRect()
? 清除矩形區(qū)域畫布,這里的參數(shù),前兩個(gè)是左上角坐標(biāo),后兩個(gè)是寬高,把 canvas 的寬高傳進(jìn)去就會(huì)把整個(gè)畫布清除。 - 重新繪制小球。
現(xiàn)在小球就可以移動(dòng)了:
重構(gòu)代碼
上邊的代碼適合只有一個(gè)小球的情況,如果有多個(gè)小球需要繪制,就得編寫大量重復(fù)的代碼,這時(shí)我們可以把小球抽象成一個(gè)類,里邊有繪圖、更新位置等操作,還有坐標(biāo)、速度、半徑等屬性,重構(gòu)后的代碼如下:
class Circle {
constructor(context, x, y, r, vx, vy) {
this.context = context;
this.x = x;
this.y = y;
this.r = r;
this.vx = vx;
this.vy = vy;
}
// 繪制小球
draw() {
this.context.fillStyle = "hsl(170, 100%, 50%)";
this.context.beginPath();
this.context.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
this.context.fill();
}
/**
* 更新畫布
* @param {number} seconds
*/
update(seconds) {
this.x += this.vx * seconds;
this.y += this.vy * seconds;
}
}
里邊的代碼跟之前的一樣,這里就不再贅述了,需要注意的是,Circle 類的 context 畫筆屬性是通過構(gòu)造函數(shù)傳遞進(jìn)來的,更新位置的代碼放到了 ?update()
?方法中。
對(duì)于整個(gè) canvas 的繪制過程,也可以抽象成一個(gè)類,當(dāng)作是游戲或引擎控制器,例如把它放到一個(gè)叫 ?Gameboard
? 的類中:
class Gameboard {
constructor() {
this.startTime;
this.init();
}
init() {
this.circles = [
new Circle(ctx, 100, 100, 60, 12, 25),
new Circle(ctx, 180, 180, 30, 70, 45),
];
window.requestAnimationFrame(this.process.bind(this));
}
process(now) {
if (!this.startTime) {
this.startTime = now;
}
let seconds = (now - this.startTime) / 1000;
this.startTime = now;
for (let i = 0; i < this.circles.length; i++) {
this.circles[i].update(seconds);
}
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < this.circles.length; i++) {
this.circles[i].draw(ctx);
}
window.requestAnimationFrame(this.process.bind(this));
}
}
new Gameboard();
在 Gameboard 類中:
- ?
startTime
? 保存了上次函數(shù)執(zhí)行的時(shí)間戳的屬性,放到了構(gòu)造函數(shù)中。 - ?
init()
? 方法創(chuàng)建了一個(gè) ?circles
? 數(shù)組,里邊放了兩個(gè)示例的小球,這里先不涉及碰撞問題。然后調(diào)用 ?window.requestAnimationFrame()
? 開啟動(dòng)畫。注意這里使用了 ?bind()
? 來把 ?Gameboard
? 的 this 綁定到回調(diào)函數(shù)中,以便于訪問 ?Gameboard
? 中的方法和屬性。 - ?
process()
? 方法也寫到了這里邊,每次執(zhí)行時(shí)會(huì)遍歷小球數(shù)組,對(duì)每個(gè)小球進(jìn)行位置更新,然后清除畫布,再重新繪制每個(gè)小球。 - 最后初始化 ?
Gameboard
? 對(duì)象就可以開始執(zhí)行動(dòng)畫了。
這個(gè)時(shí)候有兩個(gè)小球在移動(dòng)了。
碰撞檢測
為了實(shí)現(xiàn)仿真的物理特性,多個(gè)物體間碰撞會(huì)有相應(yīng)的反應(yīng),第一步就是要先檢測碰撞。我們先再多加幾個(gè)小球,以便于碰撞的發(fā)生,在 Gameboard 類的? init()
? 方法中再添加幾個(gè)小球:
this.circles = [
new Circle(ctx, 30, 50, 30, -100, 390),
new Circle(ctx, 60, 180, 20, 180, -275),
new Circle(ctx, 120, 100, 60, 120, 262),
new Circle(ctx, 150, 180, 10, -130, 138),
new Circle(ctx, 190, 210, 10, 138, -280),
new Circle(ctx, 220, 240, 10, 142, 350),
new Circle(ctx, 100, 260, 10, 135, -460),
new Circle(ctx, 120, 285, 10, -165, 370),
new Circle(ctx, 140, 290, 10, 125, 230),
new Circle(ctx, 160, 380, 10, -175, -180),
new Circle(ctx, 180, 310, 10, 115, 440),
new Circle(ctx, 100, 310, 10, -195, -325),
new Circle(ctx, 60, 150, 10, -138, 420),
new Circle(ctx, 70, 430, 45, 135, -230),
new Circle(ctx, 250, 290, 40, -140, 335),
];
然后給小球添加一個(gè)碰撞狀態(tài),在碰撞時(shí),給兩個(gè)小球設(shè)置為不同的顏色:
class Circle {
constructor(context, x, y, r, vx, vy) {
// 其它代碼
this.colliding = false;
}
draw() {
this.context.fillStyle = this.colliding
? "hsl(300, 100%, 70%)"
: "hsl(170, 100%, 50%)";
// 其它代碼
}
}
現(xiàn)在來判斷小球之間是否發(fā)生了碰撞,這個(gè)條件很簡單,判斷兩個(gè)小球圓心的距離是否小于兩個(gè)小球的半徑之和就可以了,如果小于等于則發(fā)生了碰撞,大于則沒有發(fā)生碰撞。圓心的距離即計(jì)算兩個(gè)坐標(biāo)點(diǎn)的距離,可以用公式:
x1、y1 和 x2、y2 分別兩個(gè)小球的圓心坐標(biāo)。在比較時(shí),可以對(duì)半徑和進(jìn)行平方運(yùn)算,進(jìn)而省略對(duì)距離的開方運(yùn)算,也就是可以用下方的公式進(jìn)行比較:
r1 和 r2 為兩球的半徑。
在 Circle 類中,先添加一個(gè)?isCircleCollided(other)
?方法,接收另一個(gè)小球?qū)ο笞鳛閰?shù),返回比較結(jié)果:
isCircleCollided(other) {
let squareDistance =
(this.x - other.x) * (this.x - other.x) +
(this.y - other.y) * (this.y - other.y);
let squareRadius = (this.r + other.r) * (this.r + other.r);
return squareDistance <= squareRadius;
}
再添加 checkCollideWith(other)
方法,調(diào)用 isCircleCollided(other)
判斷碰撞后,把兩球的碰撞狀態(tài)設(shè)置為 true:
checkCollideWith(other) {
if (this.isCircleCollided(other)) {
this.colliding = true;
other.colliding = true;
}
}
接著我們需要使用雙循環(huán)兩兩比對(duì)小球是否發(fā)生了碰撞,由于小球數(shù)組存放在 Gameboard 對(duì)象中,我們給它添加一個(gè) ?checkCollision()
? 方法來檢測碰撞:
checkCollision() {
// 重置碰撞狀態(tài)
this.circles.forEach((circle) => (circle.colliding = false));
for (let i = 0; i < this.circles.length; i++) {
for (let j = i + 1; j < this.circles.length; j++) {
this.circles[i].checkCollideWith(this.circles[j]);
}
}
}
因?yàn)樾∏蛟谂鲎埠缶蛻?yīng)立即彈開,所以我們一開始要把所有小球的碰撞狀態(tài)設(shè)置為 false,之后在循環(huán)中,對(duì)每個(gè)小球進(jìn)行檢測。這里注意到內(nèi)層循環(huán)是從 i + 1 開始的,這是因?yàn)樵谂袛?nbsp;1 球和 2 球是否碰撞后,就無須再判斷 2 球 和 1 球了。
之后在? process()
? 方法中,執(zhí)行檢測,注意檢測應(yīng)該發(fā)生在使用 for 循環(huán)更新小球位置的后邊才準(zhǔn)確:
for (let i = 0; i < this.circles.length; i++) {
this.circles[i].update(seconds);
}
this.checkCollision();
現(xiàn)在,可以看到小球在碰撞時(shí),會(huì)改變顏色了。
邊界碰撞
上邊的代碼在執(zhí)行之后,小球都會(huì)穿過邊界跑到外邊去,那么我們先處理一下邊界碰撞的問題。檢測邊界碰撞需要把四個(gè)面全部都處理到,根據(jù)圓心坐標(biāo)和半徑來判斷是否和邊界發(fā)生了碰撞。例如跟左邊界發(fā)生碰撞時(shí),圓心的 x 坐標(biāo)是小于或等于半徑長度的,而跟右邊界發(fā)生碰撞時(shí),圓心 x 坐標(biāo)應(yīng)該大于或等于畫布最右側(cè)坐標(biāo)(即寬度值)減去半徑的長度。上邊界和下邊界類似,只是使用圓心 y 坐標(biāo)和畫布的高度值。在水平方向上(即左右邊界)發(fā)生碰撞時(shí),小球的運(yùn)動(dòng)方向發(fā)生改變,只需要把垂直方向上的速度 vy 值取反即可,在垂直方向上碰撞則把 vx 取反。
現(xiàn)在看一下代碼的實(shí)現(xiàn),在 Gameboard 類中添加一個(gè) checkEdgeCollision()
方法,根據(jù)上邊描述的規(guī)則編寫如下代碼:
checkEdgeCollision() {
this.circles.forEach((circle) => {
// 左右墻壁碰撞
if (circle.x < circle.r) {
circle.vx = -circle.vx;
circle.x = circle.r;
} else if (circle.x > width - circle.r) {
circle.vx = -circle.vx;
circle.x = width - circle.r;
}
// 上下墻壁碰撞
if (circle.y < circle.r) {
circle.vy = -circle.vy;
circle.y = circle.r;
} else if (circle.y > height - circle.r) {
circle.vy = -circle.vy;
circle.y = height - circle.r;
}
});
}
在代碼中,碰撞時(shí),除了對(duì)速度進(jìn)行取反操作之外,還把小球的坐標(biāo)修改為緊臨邊界,防止超出。接下來在 process()
中添加對(duì)邊界碰撞的檢測:
this.checkEdgeCollision();
this.checkCollision();
這時(shí)候可以看到小球在碰到邊界時(shí),可以反彈了:
但是小球間的碰撞還沒有處理,在處理之前,先復(fù)習(xí)一下向量的基本操作,數(shù)學(xué)好的同學(xué)可以直接跳過,只看相關(guān)的代碼。
向量的基本操作
由于在碰撞時(shí),需要對(duì)速度向量(或稱為矢量)進(jìn)行操作,向量是使用類似坐標(biāo)的形式表示的,例如 < 3, 5 > (這里用 <> 表示向量),它有長度和方向,對(duì)于它的運(yùn)算有一定的規(guī)則,本教程中需要用到向量的加法、減法、乘法、點(diǎn)乘和標(biāo)準(zhǔn)化操作。
向量相加只需要把兩個(gè)向量的 x 坐標(biāo)和 y 坐標(biāo)相加即可,例如:< 3 , 5 > + < 1 , 2 > = < 4 , 7 > <3, 5> + <1, 2> = <4, 7><3,5>+<1,2>=<4,7>
減法與加法類似,把 x 坐標(biāo)和 y 坐標(biāo)相減,例如:< 3 , 5 > ? < 1 , 2 > = < 2 , 3 > <3, 5> - <1, 2> = <2, 3><3,5>?<1,2>=<2,3>
乘法,這里指的是向量和標(biāo)量的乘法,標(biāo)量指的就是普通的數(shù)字,結(jié)果是把 x 和 y 分別和標(biāo)量相乘,例如:3 × < 3 , 5 > = < 9 , 15 > 3\times<3, 5> = <9, 15>3×<3,5>=<9,15>。
點(diǎn)乘是兩個(gè)向量相乘的一種方式,類似的還有叉乘,但是在本示例中用不到,點(diǎn)乘其實(shí)計(jì)算的是一個(gè)向量在另一個(gè)向量上的投影,它的計(jì)算方式為兩個(gè)向量的 x 的積加上 y 的積,它返回的是一個(gè)標(biāo)量,即第 1 個(gè)向量在第 2 個(gè)向量上投影的長度,例如:< 3 , 5 > ? < 1 , 2 > = 3 × 1 + 5 × 2 = 13 <3, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13<3,5>?<1,2>=3×1+5×2=13
標(biāo)準(zhǔn)化是除掉向量的長度,只剩下方向,這樣的向量它的長度為 1,稱為單位向量,標(biāo)準(zhǔn)化的過程是讓 x 和 y 分別除以向量的長度,因?yàn)橄蛄勘硎镜氖呛驮c(diǎn)(0, 0)的距離,所以可以直接使用 計(jì)算長度,例如 < 3, 4 > 標(biāo)準(zhǔn)化后的結(jié)果為:< 3 , 5 > ? < 1 , 2 > = 3 × 1 + 5 × 2 = 13 <3, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13<3,5>?<1,2>=3×1+5×2=13。
了解了向量的基本運(yùn)算后,我們來創(chuàng)建一個(gè) Vector 工具類,來方便我們進(jìn)行向量的運(yùn)算,它的代碼就是實(shí)現(xiàn)了這些運(yùn)算規(guī)則:
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* 向量加法
* @param {Vector} v
*/
add(v) {
return new Vector(this.x + v.x, this.y + v.y);
}
/**
* 向量減法
* @param {Vector} v
*/
substract(v) {
return new Vector(this.x - v.x, this.y - v.y);
}
/**
* 向量與標(biāo)量乘法
* @param {Vector} s
*/
multiply(s) {
return new Vector(this.x * s, this.y * s);
}
/**
* 向量與向量點(diǎn)乘(投影)
* @param {Vector} v
*/
dot(v) {
return this.x * v.x + this.y * v.y;
}
/**
* 向量標(biāo)準(zhǔn)化(除去長度)
* @param {number} distance
*/
normalize() {
let distance = Math.sqrt(this.x * this.x + this.y * this.y);
return new Vector(this.x / distance, this.y / distance);
}
}
代碼中沒有什么特殊的語法和操作,這里就不再贅述了,接下來我們看一下小球的碰撞問題。
碰撞處理
碰撞處理最主要的部分就是計(jì)算碰撞后的速度和方向。通常最簡單的碰撞問題是在同一個(gè)水平面上的兩個(gè)物體的碰撞,稱為一維碰撞,因?yàn)榇藭r(shí)只需要計(jì)算同一方向上的速度,而我們現(xiàn)在的程序小球是在一個(gè)二維平面內(nèi)運(yùn)動(dòng)的,小球之間發(fā)生正面相碰(即在同一運(yùn)動(dòng)方向)的概率很小,大部分是斜碰(在不同運(yùn)動(dòng)方向上擦肩相碰),需要同時(shí)計(jì)算水平和垂直方向上的速度和方向,這就屬于是二維碰撞問題。不過,其實(shí)小球之間的碰撞,只有在連心線(兩個(gè)圓心的連線)上有作用力,而在碰撞接觸的切線方向上沒有作用力,那么我們只需要知道連心線方向的速度變化就可以了,這樣就轉(zhuǎn)換成了一維碰撞。
計(jì)算碰撞后的速度時(shí),遵守動(dòng)量守恒定律和動(dòng)能守恒定律,公式分別為:
動(dòng)量守恒定律
動(dòng)能守恒定律
m1、m2 分別為兩小球的質(zhì)量,v1 和 v2 為兩小球碰撞前的速度向量,v1' 和 v2' 為碰撞后的速度向量。根據(jù)這兩個(gè)公式可以推導(dǎo)出兩小球碰撞后的速度公式:
如果不考慮小球的質(zhì)量,或質(zhì)量相同,其實(shí)就是兩小球速度互換,即:
這里我們給小球加上質(zhì)量,然后套用公式來計(jì)算小球碰撞后速度,先在 Circle 類中給小球加上質(zhì)量 mass 屬性:
class Circle {
constructor(context, x, y, r, vx, vy, mass = 1) {
// 其它代碼
this.mass = mass;
}
}
然后在 Gameboard 類的初始化小球處,給每個(gè)小球添加質(zhì)量:
this.circles = [
new Circle(ctx, 30, 50, 30, -100, 390, 30),
new Circle(ctx, 60, 180, 20, 180, -275, 20),
new Circle(ctx, 120, 100, 60, 120, 262, 100),
new Circle(ctx, 150, 180, 10, -130, 138, 10),
new Circle(ctx, 190, 210, 10, 138, -280, 10),
new Circle(ctx, 220, 240, 10, 142, 350, 10),
new Circle(ctx, 100, 260, 10, 135, -460, 10),
new Circle(ctx, 120, 285, 10, -165, 370, 10),
new Circle(ctx, 140, 290, 10, 125, 230, 10),
new Circle(ctx, 160, 380, 10, -175, -180, 10),
new Circle(ctx, 180, 310, 10, 115, 440, 10),
new Circle(ctx, 100, 310, 10, -195, -325, 10),
new Circle(ctx, 60, 150, 10, -138, 420, 10),
new Circle(ctx, 70, 430, 45, 135, -230, 45),
new Circle(ctx, 250, 290, 40, -140, 335, 40),
];
在 Circle 類中加上 ?changeVelocityAndDirection(other)
? 方法來計(jì)算碰撞后的速度,它接收另一個(gè)小球?qū)ο笞鳛閰?shù),同時(shí)計(jì)算這兩個(gè)小球碰撞厚的速度和方向,這個(gè)是整個(gè)引擎的核心,我們一點(diǎn)一點(diǎn)的來看它是如何實(shí)現(xiàn)的。首先把兩個(gè)小球的速度使用 Vector 向量來表示:
changeVelocityAndDirection(other) {
// 創(chuàng)建兩小球的速度向量
let velocity1 = new Vector(this.vx, this.vy);
let velocity2 = new Vector(other.vx, other.vy);
}
因?yàn)槲覀儽旧砭鸵呀?jīng)使用 vx 和 vy 來表示水平和垂直方向上的速度向量了,所以直接把它們傳給 Vector 的構(gòu)造函數(shù)就可以了。?velocity1
? 和 ?velocity2
? 分別代表當(dāng)前小球和碰撞小球的速度向量。
接下來獲取連心線方向的向量,也就是兩個(gè)圓心坐標(biāo)的差:
let vNorm = new Vector(this.x - other.x, this.y - other.y);
接下來獲取連心線方向的單位向量和切線方向上的單位向量,這些單位向量代表的是連心線和切線的方向:
let unitVNorm = vNorm.normalize();
let unitVTan = new Vector(-unitVNorm.y, unitVNorm.x);
unitVNorm
是連心線方向單位向量,unitVTan
是切線方向單位向量,切線方向其實(shí)就是把連心線向量的 x、y 坐標(biāo)互換,并把 y 坐標(biāo)取反。根據(jù)這兩個(gè)單位向量,使用點(diǎn)乘計(jì)算小球速度在這兩個(gè)方向上的投影:
let v1n = velocity1.dot(unitVNorm);
let v1t = velocity1.dot(unitVTan);
let v2n = velocity2.dot(unitVNorm);
let v2t = velocity2.dot(unitVTan);
計(jì)算結(jié)果是一個(gè)標(biāo)量,也就是沒有方向的速度值。v1n
和 v1t
表示當(dāng)前小球在連心線和切線方向的速度值,v2n
和 v2t
則表示的是碰撞小球 的速度值。在計(jì)算出兩小球的速度值之后,我們就有了碰撞后的速度公式所需要的變量值了,直接用代碼把公式套用進(jìn)去:
let v1nAfter = (v1n * (this.mass - other.mass) + 2 * other.mass * v2n) / (this.mass + other.mass);
let v2nAfter = (v2n * (other.mass - this.mass) + 2 * this.mass * v1n) / (this.mass + other.mass);
v1nAfter
和 v2nAfter
分別是兩小球碰撞后的速度,現(xiàn)在可以先判斷一下,如果 v1nAfter
小于 v2nAfter
,那么第 1 個(gè)小球和第 2 個(gè)小球會(huì)越來越遠(yuǎn),此時(shí)不用處理碰撞:
if (v1nAfter < v2nAfter) {
return;
}
然后再給碰撞后的速度加上方向,計(jì)算在連心線方向和切線方向上的速度,只需要讓速度標(biāo)量跟連心線單位向量和切線單位向量相乘:
let v1VectorNorm = unitVNorm.multiply(v1nAfter);
let v1VectorTan = unitVTan.multiply(v1t);
let v2VectorNorm = unitVNorm.multiply(v2nAfter);
let v2VectorTan = unitVTan.multiply(v2t);
這樣有了兩個(gè)小球連心線上的新速度向量和切線方向上的新速度向量,最后把連心線上的速度向量和切線方向的速度向量進(jìn)行加法操作,就能獲得碰撞后小球的速度向量:
let velocity1After = v1VectorNorm.add(v1VectorTan);
let velocity2After = v2VectorNorm.add(v2VectorTan);
之后我們把向量中的 x 和 y 分別還原到小球的 vx 和 vy 屬性中:
this.vx = velocity1After.x;
this.vy = velocity1After.y;
other.vx = velocity2After.x;
other.vy = velocity2After.y;
最后在 checkCollideWith()
方法的 if 語句中調(diào)用此方法,即在檢測到碰撞時(shí):
checkCollideWith(other) {
if (this.isCircleCollided(other)) {
this.colliding = true;
other.colliding = true;
this.changeVelocityAndDirection(other); // 在這里調(diào)用
}
}
這時(shí),小球的碰撞效果就實(shí)現(xiàn)了。
非彈性碰撞
現(xiàn)在小球之間的碰撞屬于完全彈性碰撞,即碰撞之后不會(huì)有能量損失,這樣小球永遠(yuǎn)不會(huì)停止運(yùn)動(dòng),我們可以讓小球在碰撞之后損失一點(diǎn)能量,來模擬更真實(shí)的物理效果。要讓小球碰撞后有能量損失,可以使用恢復(fù)系數(shù),它是一個(gè)取值范圍為 0 到 1 的數(shù)值,每次碰撞后,乘以它就可以減慢速度,如果恢復(fù)系數(shù)為 1 則為完全彈性碰撞,為 0 則是完全非彈性碰撞,之間的數(shù)值為非彈性碰撞,現(xiàn)實(shí)生活中的碰撞都是非彈性碰撞。
先看一下邊界碰撞,這個(gè)比較簡單,假設(shè)邊界的恢復(fù)系數(shù)為 0.8,然后在每次對(duì)速度取反的時(shí)候乘以它就可以了,把 Gameboard ?checkEdgeCollision()
?方法作如下改動(dòng):
checkEdgeCollision() {
const cor = 0.8; // 設(shè)置恢復(fù)系統(tǒng)
this.circles.forEach((circle) => {
// 左右墻壁碰撞
if (circle.x < circle.r) {
circle.vx = -circle.vx * cor; // 加上恢復(fù)系數(shù)
circle.x = circle.r;
} else if (circle.x > width - circle.r) {
circle.vx = -circle.vx * cor; // 加上恢復(fù)系數(shù)
circle.x = width - circle.r;
}
// 上下墻壁碰撞
if (circle.y < circle.r) {
circle.vy = -circle.vy * cor; // 加上恢復(fù)系數(shù)
circle.y = circle.r;
} else if (circle.y > height - circle.r) {
circle.vy = -circle.vy * cor; // 加上恢復(fù)系數(shù)
circle.y = height - circle.r;
}
});
}
接下來設(shè)置小球的恢復(fù)系數(shù),給 Circle 類再加上一個(gè)恢復(fù)系數(shù) cor 屬性,每個(gè)小球可以設(shè)置不同的數(shù)值,來讓它們有不同的彈性,然后在初始化小球時(shí)設(shè)置隨意的恢復(fù)系數(shù):
class Circle {
constructor(context, x, y, r, vx, vy, mass = 1, cor = 1) {
// 其它代碼
this.cor = cor;
}
}
class Gameboard {
init() {
this.circles = [
new Circle(ctx, 30, 50, 30, -100, 390, 30, 0.7),
new Circle(ctx, 60, 180, 20, 180, -275, 20, 0.7),
new Circle(ctx, 120, 100, 60, 120, 262, 100, 0.3),
new Circle(ctx, 150, 180, 10, -130, 138, 10, 0.7),
new Circle(ctx, 190, 210, 10, 138, -280, 10, 0.7),
new Circle(ctx, 220, 240, 10, 142, 350, 10, 0.7),
new Circle(ctx, 100, 260, 10, 135, -460, 10, 0.7),
new Circle(ctx, 120, 285, 10, -165, 370, 10, 0.7),
new Circle(ctx, 140, 290, 10, 125, 230, 10, 0.7),
new Circle(ctx, 160, 380, 10, -175, -180, 10, 0.7),
new Circle(ctx, 180, 310, 10, 115, 440, 10, 0.7),
new Circle(ctx, 100, 310, 10, -195, -325, 10, 0.7),
new Circle(ctx, 60, 150, 10, -138, 420, 10, 0.7),
new Circle(ctx, 70, 430, 45, 135, -230, 45, 0.7),
new Circle(ctx, 250, 290, 40, -140, 335, 40, 0.7),
];
}
}
加上恢復(fù)系數(shù)之后,小球碰撞后的速度計(jì)算也需要改變一下,可以簡單的讓 v1nAfter
和 v2nAfter
乘以小球的恢復(fù)系數(shù),也可以使用帶有恢復(fù)系數(shù)的速度公式(這兩種方式我暫時(shí)還不太清楚區(qū)別,有興趣的小伙伴可以自己研究一下),公式如下:
接著把公式轉(zhuǎn)換為代碼,在 Circle 類的 changeVelocityAndDirection()
方法中,替換掉 v1nAfter
和 v2nAfter
的計(jì)算公式:
let cor = Math.min(this.cor, other.cor);
let v1nAfter =
(this.mass * v1n + other.mass * v2n + cor * other.mass * (v2n - v1n)) /
(this.mass + other.mass);
let v2nAfter =
(this.mass * v1n + other.mass * v2n + cor * this.mass * (v1n - v2n)) /
(this.mass + other.mass);
這里要注意的是兩小球碰撞時(shí)的恢復(fù)系數(shù)應(yīng)取兩者的最小值,按照常識(shí),彈性小的無論是去撞別人還是別人撞它,都會(huì)有同樣的效果?,F(xiàn)在小球碰撞后速度會(huì)有所減慢,不過還差一點(diǎn),我們可以加上重力來讓小球自然下落。
重力
添加重力比較簡單,先在全局定義重力加速度常量,然后在小球更新垂直方向上的速度時(shí),累計(jì)重力加速度就可以了:
const gravity = 980;
class Circle {
update(seconds) {
this.vy += gravity * seconds; // 重力加速度
this.x += this.vx * seconds;
this.y += this.vy * seconds;
}
}
重力加速度大約是 ,但是由于我們的畫布是以象素為單位的,所以使用 9.8 看起來會(huì)像是沒有重力,或者像是從很遠(yuǎn)的地方觀察小球,這時(shí)候可以把重力加速度放大一定倍數(shù)來達(dá)到更逼真的效果。
總結(jié)
現(xiàn)在我們這個(gè)簡單的 JavaScript 物理引擎就完成了,實(shí)現(xiàn)了物理引擎最基本的部分,可以有一個(gè)完整的掉落和碰撞的效果,要做一個(gè)更逼真的物理引擎還需要考慮更多的因素和更復(fù)雜的公式,例如考慮一下摩擦力、空氣阻力、碰撞后的旋轉(zhuǎn)角度等,并且這個(gè) canvas 的幀率也會(huì)有一定的問題,如果有的小球速度過快,但是如果來不及執(zhí)行下一次回調(diào)函數(shù)更新它的位置,那么它可能就直接穿過碰撞的小球到另一邊了。
來總結(jié)一下開發(fā)過程:
- 使用 context 繪制小球。
- 搭建 Canvas 動(dòng)畫基礎(chǔ)結(jié)構(gòu),主要使用 ?
window.requestAnimationFrame
?方法反復(fù)執(zhí)行回調(diào)函數(shù)。 - 移動(dòng)小球,通過小球的速度和函數(shù)執(zhí)行時(shí)的時(shí)間戳來計(jì)算移動(dòng)距離。
- 碰撞檢測,通過比對(duì)兩個(gè)小球的距離和它們半徑的和。
- 邊界碰撞的檢測和方向改變。
- 小球之間的碰撞,應(yīng)用速度公式和向量操作計(jì)算出碰撞后的速度和方向。
- 利用恢復(fù)系數(shù)實(shí)現(xiàn)非彈性碰撞。
- 添加重力效果。
代碼可以在以下地址中查看:
https://github.com/zxuqian/html-css-examples/tree/master/35-collision-physics
推薦好課:JavaScript微課、JavaScript基礎(chǔ)實(shí)戰(zhàn)、JavaScript面向?qū)ο缶幊?/a>