App下載

JavaScript 游戲開發(fā):手把手實(shí)現(xiàn)碰撞物理引擎

猿友 2021-03-02 13:33:29 瀏覽數(shù) (9329)
反饋

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í)對自己的編碼技能也是一種提升。在對 JavaScript 物理引擎的研究過程中,發(fā)現(xiàn)寫代碼是次要的,最主要的是理解相關(guān)的物理、數(shù)學(xué)公式和概念,雖然我是理科生,但是數(shù)學(xué)和物理從來不是我的強(qiáng)項(xiàng),我不是把知識(shí)還給老師了,而是壓根就沒掌握過。過年期間花了有小半個(gè)月的時(shí)間在學(xué)習(xí)物理知識(shí),現(xiàn)在仍然對某些概念和推導(dǎo)過程沒有太大的自信,不過最后還算是做出了一個(gè)簡單的、比較滿意的結(jié)果,見下圖。

gravity.gif

接下來看一下怎么實(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 元素對象。
  • 通過 canvas 元素對象獲取繪圖 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è)圓形,我們在這把它當(dāng)作一個(gè)小球:

image.png

移動(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ù)。那么利用它,我們就可以對 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)保存到變量中,以方便對它們進(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)了:

moving-ball.gif

重構(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() ?方法中。

對于整個(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ù)組,對每個(gè)小球進(jìn)行位置更新,然后清除畫布,再重新繪制每個(gè)小球。
  • 最后初始化 ?Gameboard? 對象就可以開始執(zhí)行動(dòng)畫了。

這個(gè)時(shí)候有兩個(gè)小球在移動(dòng)了。

two-moving-balls.gif

碰撞檢測

為了實(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)的距離,可以用公式:

微信截圖_20210302094152

x1、y1 和 x2、y2 分別兩個(gè)小球的圓心坐標(biāo)。在比較時(shí),可以對半徑和進(jìn)行平方運(yùn)算,進(jìn)而省略對距離的開方運(yùn)算,也就是可以用下方的公式進(jìn)行比較:

微信截圖_20210302094200

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)兩兩比對小球是否發(fā)生了碰撞,由于小球數(shù)組存放在 Gameboard 對象中,我們給它添加一個(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)中,對每個(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ì)改變顏色了。

collision-detect.gif

邊界碰撞

上邊的代碼在執(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 取反。

6dbee00a60c8d050a5d677040091c1b7

現(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í),除了對速度進(jìn)行取反操作之外,還把小球的坐標(biāo)修改為緊臨邊界,防止超出。接下來在 process() 中添加對邊界碰撞的檢測:

this.checkEdgeCollision();
this.checkCollision();

這時(shí)候可以看到小球在碰到邊界時(shí),可以反彈了:

edge-collision.gif

但是小球間的碰撞還沒有處理,在處理之前,先復(fù)習(xí)一下向量的基本操作,數(shù)學(xué)好的同學(xué)可以直接跳過,只看相關(guān)的代碼。

向量的基本操作

由于在碰撞時(shí),需要對速度向量(或稱為矢量)進(jìn)行操作,向量是使用類似坐標(biāo)的形式表示的,例如 < 3, 5 > (這里用 <> 表示向量),它有長度和方向,對于它的運(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)的距離,所以可以直接使用 微信截圖_20210302094517 計(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)換成了一維碰撞。

e8938f413922027339bd013a0d061e77

計(jì)算碰撞后的速度時(shí),遵守動(dòng)量守恒定律和動(dòng)能守恒定律,公式分別為:

動(dòng)量守恒定律

微信截圖_20210302094633

動(dòng)能守恒定律

微信截圖_20210302094637

m1、m2 分別為兩小球的質(zhì)量,v1 和 v2 為兩小球碰撞前的速度向量,v1' 和 v2' 為碰撞后的速度向量。根據(jù)這兩個(gè)公式可以推導(dǎo)出兩小球碰撞后的速度公式:

微信截圖_20210302094641

如果不考慮小球的質(zhì)量,或質(zhì)量相同,其實(shí)就是兩小球速度互換,即:

微信截圖_20210302094645

這里我們給小球加上質(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)了。

ball-collision.gif

非彈性碰撞

現(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,然后在每次對速度取反的時(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ū)別,有興趣的小伙伴可以自己研究一下),公式如下:

微信截圖_20210302095120

接著把公式轉(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ì)有同樣的效果。現(xiàn)在小球碰撞后速度會(huì)有所減慢,不過還差一點(diǎn),我們可以加上重力來讓小球自然下落。 coefficient-of-restitution.gif

重力

添加重力比較簡單,先在全局定義重力加速度常量,然后在小球更新垂直方向上的速度時(shí),累計(jì)重力加速度就可以了:

const gravity = 980;

class Circle {
  update(seconds) {
    this.vy += gravity * seconds; // 重力加速度
    this.x += this.vx * seconds;
    this.y += this.vy * seconds;
  }
}

重力加速度大約是  微信截圖_20210302095209,但是由于我們的畫布是以象素為單位的,所以使用 9.8 看起來會(huì)像是沒有重力,或者像是從很遠(yuǎn)的地方觀察小球,這時(shí)候可以把重力加速度放大一定倍數(shù)來達(dá)到更逼真的效果。

gravity.gif

總結(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)距離。
  • 碰撞檢測,通過比對兩個(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>


1 人點(diǎn)贊