主頁 > 前端設計 > JavaScript 游戲開發:手把手實作碰撞物理引擎

JavaScript 游戲開發:手把手實作碰撞物理引擎

2021-02-28 10:59:37 前端設計

目錄

    • 基礎結構
    • 繪制小球
    • 移動小球
    • 重構代碼
    • 碰撞檢測
    • 邊界碰撞
    • 向量的基本操作
    • 碰撞處理
          • 動量守恒定律
          • 動能守恒定律
    • 非彈性碰撞
    • 重力
    • 總結

年前我看到合成大西瓜小游戲火了,想到之前從來沒有研究過游戲方面的開發,這次就想趁著這個機會看看 JavaScript 游戲開發,從原生角度上如何實作游戲里的物理特性,例如運動、碰撞,雖然之前研究過物理相關的影片庫,但是我打算試試不用框架撰寫一個簡單的 JavaScript 物理引擎,實作小球的碰撞效果,

為什么不用現成的游戲庫呢?因為我覺得在了解底層的實作原理之后,才能更有效的理解框架上的概念和使用方法,在解決 BUG 的時候能夠更有效率,同時對自己的編碼技能也是一種提升,在對 JavaScript 物理引擎的研究程序中,發現寫代碼是次要的,最主要的是理解相關的物理、數學公式和概念,雖然我是理科生,但是數學和物理從來不是我的強項,我不是把知識還給老師了,而是壓根就沒掌握過 o,過年期間花了有小半個月的時間在學習物理知識,現在仍然對某些概念和推導程序沒有太大的自信,不過最后還算是做出了一個簡單的、比較滿意的結果,見下圖,

gravity.gif

接下來看一下怎么實作這樣的效果,

基礎結構

我們這里使用 canvas 來實作 JavaScript 物理引擎,首先準備專案的基礎檔案和樣式,新建一個 index.html、index.js 和 style.css 檔案,分別用于撰寫 canvas 的 html 結構、引擎代碼和畫布樣式,

在 index.html 的 <head /> 標簽中引入樣式檔案:

<link rel="stylesheet" href="./style.css" />

<body /> 中,添加 canvas 元素、加載 index.js 檔案:

<main>
  <canvas id="gameboard"></canvas>
</main>
<script src="./index.js"></script>

這段代碼定義了 idgameboard<canvas /> 元素,并放在了 <main /> 元素下, <main /> 元素主要是用來設定背景色和畫布大小,在 <main/> 元素的下方引入 index.js 檔案,這樣可以在 DOM 加載完成之后再執行 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%);
}

樣式很簡單,去掉所有元素的外邊距、內間距,并把 <main/> 元素的寬高設定為與瀏覽器可視區域相同,背景色為深灰色,

hsl(hue, saturation, brightness) 為 css 顏色表示法之一,引數分別為色相,飽和度和亮度,可通過我之前出過的視頻進行學習,

繪制小球

接下來繪制小球,主要用到了 canvas 相關的 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 進行繪圖操作:

  • 通過 canvas 的 id 獲取 canvas 元素物件,
  • 通過 canvas 元素物件獲取繪圖 context, getContext() 需要一個引數,用于表明是繪制 2d 影像,還是使用 webgl 繪制 3d 圖象,這里選擇 2d,context 就類似是一支畫筆,可以改變它的顏色和繪制基本的形狀,
  • 給 canvas 的寬高設定為瀏覽器可視區域的寬高,并保存到 widthheight 變數中方便后續使用,
  • 給 context 設定顏色,然后呼叫 beginPath() 開始繪圖,
  • 使用 arc() 方法繪制圓形,它接收 5 個引數,前兩個為圓心的 x、y 坐標,第 3 個為半徑長度, 第 4 個和第 5 個分別是起始角度和結束角度,因為 arc() 其實是用來繪制一段圓弧,這里讓它畫一段 0 到 360 度的圓弧,就形成了一個圓形,這里的角度是使用 radian 形式表示的,0 到 360 度可以用 0 到 2 * Math.PI 來表示,
  • 最后使用 ctx.fill() 給圓形填上顏色,

這樣就成功的繪制了一個圓形,我們在這把它當作一個小球:

image.png

移動小球

不過,這個時候的小球還是靜止的,如果想讓它移動,那么得修改它的圓心坐標,具體修改的數值則與運動速度有關,在移動小球之前,先看一下 canvas 進行影片的原理:

Canvas 進行影片的原理與傳統的電影膠片類似,在一段時間內,繪制影像、更新影像位置或形狀、清除畫布,重新繪制影像,當在 1 秒內連續執行 60 次或以上這樣的操作時,即以 60 幀的速度,就可以產生連續的畫面,

那么在 JavaScript 中,瀏覽器提供了 window.requestAnimationFrame() 方法,它接收一個回呼函式作為引數,每一次執行回呼函式就相當于 1 幀影片,我們需要通過遞回或回圈連續呼叫它,瀏覽器會盡可能的在 1 秒內執行 60 次回呼函式,那么利用它,我們就可以對 canvas 進行重繪,以實作小球的移動效果,

由于 window.requestAnimationFrame() 的呼叫基本是持續進行的,所以我們也可以把它稱為游戲回圈(Game loop),

接下來我們來看如何撰寫影片的基礎結構:

function process() {
  window.requestAnimationFrame(process);
}
window.requestAnimationFrame(process);

這里的 process() 函式就是 1 秒鐘要執行 60 次的回呼函式,每次執行完畢后繼續呼叫 window.requestAnimationFrame(process)進行下一次回圈,如果要移動小球,那么就需要把繪制小球和修改圓心 x、y 坐標的代碼寫到 process() 函式中,

為了方便更新坐標,我們把小球的圓心坐標保存到變數中,以方便對它們進行修改,然后再定義兩個新的變數,分別表示在 x 軸方向上的速度 vx,和 y 軸方向上的速度 vy,然后把 context 相關的繪圖操作放到 process() 中:

let x = 100;
let y = 100;
let vx = 12;
let vy = 25;

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);

要計算圓心坐標 x、y 的移動距離,我們需要速度和時間,速度這里有了, 那么時間要怎么獲取呢? window.requestAnimationFrame() 會把當前時間的毫秒數(即時間戳)傳遞給回呼函式,我們可以把本次呼叫的時間戳保存起來,然后在下一次呼叫時計算出執行這 1 幀影片消耗了多少秒,然后根據這個秒數和 x、y 軸方向上的速度去計算移動距離,分別加到 x 和 y 上,以獲得最新的位置,注意這里的時間是上一次函式呼叫和本次函式呼叫的時間間隔,并不是第 1 次函式呼叫到當前函式呼叫總共過去了多少秒,所以相當于是時間增量,需要在之前 x 和 y 的值的基礎上進行相加,代碼如下:

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() 現在接收當前時間戳作為引數,然后做了下面這些操作:

  • 計算上次函式呼叫與本次函式呼叫的時間間隔,以秒計,記錄本次呼叫的時間戳用于下一次計算,
  • 根據 x、y 方向上的速度,和剛剛計算出來的時間,計算出移動距離,
  • 呼叫 clearRect() 清除矩形區域畫布,這里的引數,前兩個是左上角坐標,后兩個是寬高,把 canvas 的寬高傳進去就會把整個畫布清除,
  • 重新繪制小球,

現在小球就可以移動了:

moving-ball.gif

重構代碼

上邊的代碼適合只有一個小球的情況,如果有多個小球需要繪制,就得撰寫大量重復的代碼,這時我們可以把小球抽象成一個類,里邊有繪圖、更新位置等操作,還有坐標、速度、半徑等屬性,重構后的代碼如下:

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 畫筆屬性是通過建構式傳遞進來的,更新位置的代碼放到了 update() 方法中,

對于整個 canvas 的繪制程序,也可以抽象成一個類,當作是游戲或引擎控制器,例如把它放到一個叫 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 保存了上次函式執行的時間戳的屬性,放到了建構式中,
  • init() 方法創建了一個 circles 陣列,里邊放了兩個示例的小球,這里先不涉及碰撞問題,然后呼叫 window.requestAnimationFrame() 開啟影片,注意這里使用了 bind() 來把 Gameboard 的 this 系結到回呼函式中,以便于訪問 Gameboard 中的方法和屬性,
  • process() 方法也寫到了這里邊,每次執行時會遍歷小球陣列,對每個小球進行位置更新,然后清除畫布,再重新繪制每個小球,
  • 最后初始化 Gameboard 物件就可以開始執行影片了,

這個時候有兩個小球在移動了,

two-moving-balls.gif

碰撞檢測

為了實作仿真的物理特性,多個物體間碰撞會有相應的反應,第一步就是要先檢測碰撞,我們先再多加幾個小球,以便于碰撞的發生,在 Gameboard 類的 init() 方法中再添加幾個小球:

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),
];

然后給小球添加一個碰撞狀態,在碰撞時,給兩個小球設定為不同的顏色:

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%)";
    // 其它代碼
  }
}

現在來判斷小球之間是否發生了碰撞,這個條件很簡單,判斷兩個小球圓心的距離是否小于兩個小球的半徑之和就可以了,如果小于等于則發生了碰撞,大于則沒有發生碰撞,圓心的距離即計算兩個坐標點的距離,可以用公式:

( x 1 ? x 2 ) 2 + ( y 1 ? y 2 ) 2 \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} (x1??x2?)2+(y1??y2?)2 ?

x1、y1 和 x2、y2 分別兩個小球的圓心坐標,在比較時,可以對半徑和進行平方運算,進而省略對距離的開方運算,也就是可以用下方的公式進行比較:

( x 1 ? x 2 ) 2 + ( y 1 ? y 2 ) 2 ≤ ( r 1 + r 2 ) 2 (x_1 - x_2)^2 + (y_1 - y_2)^2 \leq (r_1 + r_2)^2 (x1??x2?)2+(y1??y2?)2(r1?+r2?)2

r1 和 r2 為兩球的半徑,

在 Circle 類中,先添加一個isCircleCollided(other)方法,接收另一個小球物件作為引數,回傳比較結果:

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) 方法,呼叫 isCircleCollided(other) 判斷碰撞后,把兩球的碰撞狀態設定為 true:

checkCollideWith(other) {
  if (this.isCircleCollided(other)) {
    this.colliding = true;
    other.colliding = true;
  }
}

接著我們需要使用雙回圈兩兩比對小球是否發生了碰撞,由于小球陣列存放在 Gameboard 物件中,我們給它添加一個 checkCollision() 方法來檢測碰撞:

checkCollision() {
  // 重置碰撞狀態
  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]);
    }
  }
}

因為小球在碰撞后就應立即彈開,所以我們一開始要把所有小球的碰撞狀態設定為 false,之后在回圈中,對每個小球進行檢測,這里注意到內層回圈是從 i + 1 開始的,這是因為在判斷 1 球和 2 球是否碰撞后,就無須再判斷 2 球 和 1 球了,

之后在 process() 方法中,執行檢測,注意檢測應該發生在使用 for 回圈更新小球位置的后邊才準確:

for (let i = 0; i < this.circles.length; i++) {
  this.circles[i].update(seconds);
}
this.checkCollision();

現在,可以看到小球在碰撞時,會改變顏色了,

collision-detect.gif

邊界碰撞

上邊的代碼在執行之后,小球都會穿過邊界跑到外邊去,那么我們先處理一下邊界碰撞的問題,檢測邊界碰撞需要把四個面全部都處理到,根據圓心坐標和半徑來判斷是否和邊界發生了碰撞,例如跟左邊界發生碰撞時,圓心的 x 坐標是小于或等于半徑長度的,而跟右邊界發生碰撞時,圓心 x 坐標應該大于或等于畫布最右側坐標(即寬度值)減去半徑的長度,上邊界和下邊界類似,只是使用圓心 y 坐標和畫布的高度值,在水平方向上(即左右邊界)發生碰撞時,小球的運動方向發生改變,只需要把垂直方向上的速度 vy 值取反即可,在垂直方向上碰撞則把 vx 取反,

edge-collision-diagram.png

現在看一下代碼的實作,在 Gameboard 類中添加一個 checkEdgeCollision() 方法,根據上邊描述的規則撰寫如下代碼:

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;
    }
  });
}

在代碼中,碰撞時,除了對速度進行取反操作之外,還把小球的坐標修改為緊臨邊界,防止超出,接下來在 process() 中添加對邊界碰撞的檢測:

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

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

edge-collision.gif

但是小球間的碰撞還沒有處理,在處理之前,先復習一下向量的基本操作,數學好的同學可以直接跳過,只看相關的代碼,

向量的基本操作

由于在碰撞時,需要對速度向量(或稱為矢量)進行操作,向量是使用類似坐標的形式表示的,例如 < 3, 5 > (這里用 <> 表示向量),它有長度和方向,對于它的運算有一定的規則,本教程中需要用到向量的加法、減法、乘法、點乘和標準化操作,

向量相加只需要把兩個向量的 x 坐標和 y 坐標相加即可,例如: < 3 , 5 > + < 1 , 2 > = < 4 , 7 > <3, 5> + <1, 2> = <4, 7> <3,5>+<1,2>=<4,7>
減法與加法類似,把 x 坐標和 y 坐標相減,例如: < 3 , 5 > ? < 1 , 2 > = < 2 , 3 > <3, 5> - <1, 2> = <2, 3> <3,5>?<1,2>=<2,3>

乘法,這里指的是向量和標量的乘法,標量指的就是普通的數字,結果是把 x 和 y 分別和標量相乘,例如: 3 × < 3 , 5 > = < 9 , 15 > 3\times<3, 5> = <9, 15> 3×<3,5>=<9,15>

點乘是兩個向量相乘的一種方式,類似的還有叉乘,但是在本示例中用不到,點乘其實計算的是一個向量在另一個向量上的投影,它的計算方式為兩個向量的 x 的積加上 y 的積,它回傳的是一個標量,即第 1 個向量在第 2 個向量上投影的長度,例如: < 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

標準化是除掉向量的長度,只剩下方向,這樣的向量它的長度為 1,稱為單位向量,標準化的程序是讓 x 和 y 分別除以向量的長度,因為向量表示的是和原點(0, 0)的距離,所以可以直接使用 ( x 2 + y 2 ) \sqrt{(x^2 + y^2)} (x2+y2) ? 計算長度,例如 < 3, 4 > 標準化后的結果為: < 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

了解了向量的基本運算后,我們來創建一個 Vector 工具類,來方便我們進行向量的運算,它的代碼就是實作了這些運算規則:

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);
  }

  /**
   * 向量與標量乘法
   * @param {Vector} s
   */
  multiply(s) {
    return new Vector(this.x * s, this.y * s);
  }

  /**
   * 向量與向量點乘(投影)
   * @param {Vector} v
   */
  dot(v) {
    return this.x * v.x + this.y * v.y;
  }

  /**
   * 向量標準化(除去長度)
   * @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);
  }
}

代碼中沒有什么特殊的語法和操作,這里就不再贅述了,接下來我們看一下小球的碰撞問題,

碰撞處理

碰撞處理最主要的部分就是計算碰撞后的速度和方向,通常最簡單的碰撞問題是在同一個水平面上的兩個物體的碰撞,稱為一維碰撞,因為此時只需要計算同一方向上的速度,而我們現在的程式小球是在一個二維平面內運動的,小球之間發生正面相碰(即在同一運動方向)的概率很小,大部分是斜碰(在不同運動方向上擦肩相碰),需要同時計算水平和垂直方向上的速度和方向,這就屬于是二維碰撞問題,不過,其實小球之間的碰撞,只有在連心線(兩個圓心的連線)上有作用力,而在碰撞接觸的切線方向上沒有作用力,那么我們只需要知道連心線方向的速度變化就可以了,這樣就轉換成了一維碰撞,

collision-diagram.png

計算碰撞后的速度時,遵守動量守恒定律和動能守恒定律,公式分別為:

動量守恒定律

m 1 v 1 + m 2 v 2 = m 1 v 1 ′ + m 2 v 2 ′ m_1v_1 + m_2v_2 = m_1v_1' + m_2v_2' m1?v1?+m2?v2?=m1?v1?+m2?v2?

動能守恒定律

1 2 m 1 v 1 2 + 1 2 m 2 v 2 2 = 1 2 m 1 v 1 ′ 2 + 1 2 m 2 v 2 ′ 2 \frac{1}{2}m_1v_1^2+\frac{1}{2}m_2v_2^2=\frac{1}{2}m_1v_1'^2+\frac{1}{2}m_2v_2'^2 21?m1?v12?+21?m2?v22?=21?m1?v12?+21?m2?v22?

m1、m2 分別為兩小球的質量,v1 和 v2 為兩小球碰撞前的速度向量,v1’ 和 v2’ 為碰撞后的速度向量,根據這兩個公式可以推匯出兩小球碰撞后的速度公式:

v 1 ′ = v 1 ( m 1 ? m 2 ) + 2 m 2 v 2 m 1 + m 2 v_1'=\frac{v_1(m_1-m_2)+2m_2v_2}{m_1+m_2} v1?=m1?+m2?v1?(m1??m2?)+2m2?v2??

v 2 ′ = v 2 ( m 2 ? m 1 ) + 2 m 1 v 1 m 1 + m 2 v_2'=\frac{v_2(m_2-m_1)+2m_1v_1}{m_1+m_2} v2?=m1?+m2?v2?(m2??m1?)+2m1?v1??

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

v 1 ′ = v 2 v_1'=v_2 v1?=v2?

v 2 ′ = v 1 v_2'=v_1 v2?=v1?

這里我們給小球加上質量,然后套用公式來計算小球碰撞后速度,先在 Circle 類中給小球加上質量 mass 屬性:

class Circle {
  constructor(context, x, y, r, vx, vy, mass = 1) {
    // 其它代碼
    this.mass = mass;
  }
}

然后在 Gameboard 類的初始化小球處,給每個小球添加質量:

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) 方法來計算碰撞后的速度,它接收另一個小球物件作為引數,同時計算這兩個小球碰撞厚的速度和方向,這個是整個引擎的核心,我們一點一點的來看它是如何實作的,首先把兩個小球的速度使用 Vector 向量來表示:

  changeVelocityAndDirection(other) {
    // 創建兩小球的速度向量
    let velocity1 = new Vector(this.vx, this.vy);
    let velocity2 = new Vector(other.vx, other.vy);
  }

因為我們本身就已經使用 vx 和 vy 來表示水平和垂直方向上的速度向量了,所以直接把它們傳給 Vector 的建構式就可以了,velocity1velocity2 分別代表當前小球和碰撞小球的速度向量,

接下來獲取連心線方向的向量,也就是兩個圓心坐標的差:

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 是切線方向單位向量,切線方向其實就是把連心線向量的 x、y 坐標互換,并把 y 坐標取反,根據這兩個單位向量,使用點乘計算小球速度在這兩個方向上的投影:

let v1n = velocity1.dot(unitVNorm);
let v1t = velocity1.dot(unitVTan);

let v2n = velocity2.dot(unitVNorm);
let v2t = velocity2.dot(unitVTan);

計算結果是一個標量,也就是沒有方向的速度值,v1nv1t 表示當前小球在連心線和切線方向的速度值,v2nv2t 則表示的是碰撞小球 的速度值,在計算出兩小球的速度值之后,我們就有了碰撞后的速度公式所需要的變數值了,直接用代碼把公式套用進去:

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);

v1nAfterv2nAfter 分別是兩小球碰撞后的速度,現在可以先判斷一下,如果 v1nAfter 小于 v2nAfter,那么第 1 個小球和第 2 個小球會越來越遠,此時不用處理碰撞:

if (v1nAfter < v2nAfter) {
  return;
}

然后再給碰撞后的速度加上方向,計算在連心線方向和切線方向上的速度,只需要讓速度標量跟連心線單位向量和切線單位向量相乘:

let v1VectorNorm = unitVNorm.multiply(v1nAfter);
let v1VectorTan = unitVTan.multiply(v1t);

let v2VectorNorm = unitVNorm.multiply(v2nAfter);
let v2VectorTan = unitVTan.multiply(v2t);

這樣有了兩個小球連心線上的新速度向量和切線方向上的新速度向量,最后把連心線上的速度向量和切線方向的速度向量進行加法操作,就能獲得碰撞后小球的速度向量:

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 陳述句中呼叫此方法,即在檢測到碰撞時:

checkCollideWith(other) {
  if (this.isCircleCollided(other)) {
    this.colliding = true;
    other.colliding = true;
    this.changeVelocityAndDirection(other); // 在這里呼叫
  }
}

這時,小球的碰撞效果就實作了,

ball-collision.gif

非彈性碰撞

現在小球之間的碰撞屬于完全彈性碰撞,即碰撞之后不會有能量損失,這樣小球永遠不會停止運動,我們可以讓小球在碰撞之后損失一點能量,來模擬更真實的物理效果,要讓小球碰撞后有能量損失,可以使用恢復系數,它是一個取值范圍為 0 到 1 的數值,每次碰撞后,乘以它就可以減慢速度,如果恢復系數為 1 則為完全彈性碰撞,為 0 則是完全非彈性碰撞,之間的數值為非彈性碰撞,現實生活中的碰撞都是非彈性碰撞,

先看一下邊界碰撞,這個比較簡單,假設邊界的恢復系數為 0.8,然后在每次對速度取反的時候乘以它就可以了,把 Gameboard checkEdgeCollision()方法作如下改動:

  checkEdgeCollision() {
    const cor = 0.8;                  // 設定恢復系統
    this.circles.forEach((circle) => {
      // 左右墻壁碰撞
      if (circle.x < circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復系數
        circle.x = circle.r;
      } else if (circle.x > width - circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復系數
        circle.x = width - circle.r;
      }

      // 上下墻壁碰撞
      if (circle.y < circle.r) {
        circle.vy = -circle.vy * cor; // 加上恢復系數
        circle.y = circle.r;
      } else if (circle.y > height - circle.r) {
        circle.vy = -circle.vy * cor; // 加上恢復系數
        circle.y = height - circle.r;
      }
    });
  }

接下來設定小球的恢復系數,給 Circle 類再加上一個恢復系數 cor 屬性,每個小球可以設定不同的數值,來讓它們有不同的彈性,然后在初始化小球時設定隨意的恢復系數:

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),
    ];
  }
}

加上恢復系數之后,小球碰撞后的速度計算也需要改變一下,可以簡單的讓 v1nAfterv2nAfter 乘以小球的恢復系數,也可以使用帶有恢復系數的速度公式(這兩種方式我暫時還不太清楚區別,有興趣的小伙伴可以自己研究一下),公式如下:

v 1 ′ = m 1 v 1 + m 2 v 2 + m 2 C R ( v 2 ? v 1 ) m 1 + m 2 v_1'=\frac{m_1v_1+m_2v_2+m2C_R(v_2-v_1)}{m_1+m_2} v1?=m1?+m2?m1?v1?+m2?v2?+m2CR?(v2??v1?)?

v 2 ′ = m 1 v 1 + m 2 v 2 + m 1 C R ( v 1 ? v 2 ) m 1 + m 2 v_2'=\frac{m_1v_1+m_2v_2+m1C_R(v_1-v_2)}{m_1+m2} v2?=m1?+m2m1?v1?+m2?v2?+m1CR?(v1??v2?)?

接著把公式轉換為代碼,在 Circle 類的 changeVelocityAndDirection() 方法中,替換掉 v1nAfterv2nAfter 的計算公式:

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);

這里要注意的是兩小球碰撞時的恢復系數應取兩者的最小值,按照常識,彈性小的無論是去撞別人還是別人撞它,都會有同樣的效果,現在小球碰撞后速度會有所減慢,不過還差一點,我們可以加上重力來讓小球自然下落,

coefficient-of-restitution.gif

重力

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

const gravity = 980;

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

重力加速度大約是 9.8 m / s 2 9.8m/s^2 9.8m/s2,但是由于我們的畫布是以象素為單位的,所以使用 9.8 看起來會像是沒有重力,或者像是從很遠的地方觀察小球,這時候可以把重力加速度放大一定倍數來達到更逼真的效果,

gravity.gif

總結

現在我們這個簡單的 JavaScript 物理引擎就完成了,實作了物理引擎最基本的部分,可以有一個完整的掉落和碰撞的效果,要做一個更逼真的物理引擎還需要考慮更多的因素和更復雜的公式,例如考慮一下摩擦力、空氣阻力、碰撞后的旋轉角度等,并且這個 canvas 的幀率也會有一定的問題,如果有的小球速度過快,但是如果來不及執行下一次回呼函式更新它的位置,那么它可能就直接穿過碰撞的小球到另一邊了,

來總結一下開發程序:

  • 使用 context 繪制小球,
  • 搭建 Canvas 影片基礎結構,主要使用 window.requestAnimationFrame方法反復執行回呼函式,
  • 移動小球,通過小球的速度和函式執行時的時間戳來計算移動距離,
  • 碰撞檢測,通過比對兩個小球的距離和它們半徑的和,
  • 邊界碰撞的檢測和方向改變,
  • 小球之間的碰撞,應用速度公式和向量操作計算出碰撞后的速度和方向,
  • 利用恢復系數實作非彈性碰撞,
  • 添加重力效果,

代碼可以在以下地址中查看:

https://github.com/zxuqian/html-css-examples/tree/master/35-collision-physics

希望這篇文章對你有所幫助,如果文章中的代碼、公式或表述有不正確的地方,請留言指正,感謝閱讀!

峰華前端工程師 CSDN認證博客專家 前端工程師 CSS React
前端工程師,B站 @峰華前端工程師, 畢業于美國斯帝文斯理工學院,專注于前端與全堆疊技術分享,html, css, javascript, react, vue, node.js.

轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/264504.html

標籤:其他

上一篇:web前端學習(四十五)——JavaScript BOM-Location物件、BOM-Cookie實體

下一篇:js型別轉換

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • vue移動端上拉加載

    可能做得過于簡單或者比較low,請各位大佬留情,一起探討技術 ......

    uj5u.com 2020-09-10 04:38:07 more
  • 優美網站首頁,頂部多層導航

    一個個人用的瀏覽器首頁,可以把一下常用的網站放在這里,平常打開會比較方便。 第一步,HTML代碼 <script src=https://www.cnblogs.com/szharf/p/"js/jquery-3.4.1.min.js"></script> <div id="navigate"> <ul> <li class="labels labels_1"> ......

    uj5u.com 2020-09-10 04:38:47 more
  • 頁面為要加<!DOCTYPE html>

    最近因為寫一個js函式,需要用到$(window).height(); 由于手寫demo的時候,過于自信,其實對前端方面的認識也不夠體系,用文本檔案直接敲出來的html代碼,第一行沒有加上<!DOCTYPE html> 導致了$(window).height();的結果直接是整個document的高 ......

    uj5u.com 2020-09-10 04:38:52 more
  • WordPress網站程式手動升級要做好資料備份

    WordPress博客網站程式在進行升級前,必須要做好網站資料的備份,這個問題良家佐言是遇見過的;在剛開始接觸WordPress博客程式的時候,因為升級問題和博客網站的修改的一些嘗試,良家佐言是吃盡了苦頭。因為購買的是西部數碼的空間和域名,每當佐言把自己的WordPress博客網站搞到一塌糊涂的時候 ......

    uj5u.com 2020-09-10 04:39:30 more
  • WordPress程式不能升級為5.4.2版本的原因

    WordPress是一款個人博客系統,受到英文博客愛好者和中文博客愛好者的追捧,并逐步演化成一款內容管理系統軟體;它是使用PHP語言和MySQL資料庫開發的,用戶可以在支持PHP和MySQL資料庫的服務器上使用自己的博客。每一次WordPress程式的更新,就會牽動無數WordPress愛好者的心, ......

    uj5u.com 2020-09-10 04:39:49 more
  • 使用CSS3的偽元素進行首字母下沉和首行改變樣式

    網頁中常見的一種效果,首字改變樣式或者首行改變樣式,效果如下圖。 代碼: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, ......

    uj5u.com 2020-09-10 04:40:09 more
  • 關于a標簽的講解

    什么是a標簽? <a> 標簽定義超鏈接,用于從一個頁面鏈接到另一個頁面。 <a> 元素最重要的屬性是 href 屬性,它指定鏈接的目標。 a標簽的語法格式:<a href=https://www.cnblogs.com/summerxbc/p/"指定要跳轉的目標界面的鏈接">需要展示給用戶看見的內容</a> a標簽 在所有瀏覽器中,鏈接的默認外觀如下: 未被訪問的鏈接帶 ......

    uj5u.com 2020-09-10 04:40:11 more
  • 前端輪播圖

    在需要輪播的頁面是引入swiper.min.js和swiper.min.css swiper.min.js地址: 鏈接:https://pan.baidu.com/s/15Uh516YHa4CV3X-RyjEIWw 提取碼:4aks swiper.min.css地址 鏈接:https://pan.b ......

    uj5u.com 2020-09-10 04:40:13 more
  • 如何設定html中的背景圖片(全屏顯示,且不拉伸)

    1 <style>2 body{background-image:url(https://uploadbeta.com/api/pictures/random/?key=BingEverydayWallpaperPicture); 3 background-size:cover;background ......

    uj5u.com 2020-09-10 04:40:16 more
  • Java學習——HTML詳解(上)

    HTML詳解 初識HTML Hyper Text Markup Language(超文本標記語言) 1 <!--DOCTYPE:告訴瀏覽器我們要使用什么規范--> 2 <!DOCTYPE html> 3 <html lang="en"> 4 <head> 5 <!--meta 描述性的標簽,描述一些 ......

    uj5u.com 2020-09-10 04:40:33 more
最新发布
  • 我的第一個NPM包:panghu-planebattle-esm(胖虎飛機大戰)使用說明

    好家伙,我的包終于開發完啦 歡迎使用胖虎的飛機大戰包!! 為你的主頁添加色彩 這是一個有趣的網頁小游戲包,使用canvas和js開發 使用ES6模塊化開發 效果圖如下: (覺得圖片太sb的可以自己改) 代碼已開源!! Git: https://gitee.com/tang-and-han-dynas ......

    uj5u.com 2023-04-20 07:59:23 more
  • 生產事故-走近科學之消失的JWT

    入職多年,面對生產環境,盡管都是小心翼翼,慎之又慎,還是難免捅出簍子。輕則滿頭大汗,面紅耳赤。重則系統停擺,損失資金。每一個生產事故的背后,都是寶貴的經驗和教訓,都是專案成員的血淚史。為了更好地防范和遏制今后的各類事故,特開此專題,長期更新和記錄大大小小的各類事故。有些是親身經歷,有些是經人耳傳口授 ......

    uj5u.com 2023-04-18 07:55:04 more
  • 記錄--Canvas實作打飛字游戲

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 打開游戲界面,看到一個畫面簡潔、卻又富有挑戰性的游戲。螢屏上,有一個白色的矩形框,里面不斷下落著各種單詞,而我需要迅速地輸入這些單詞。如果我輸入的單詞與螢屏上的單詞匹配,那么我就可以獲得得分;如果我輸入的單詞錯誤或者時間過長,那么我就會輸 ......

    uj5u.com 2023-04-04 08:35:30 more
  • 了解 HTTP 看這一篇就夠

    在學習網路之前,了解它的歷史能夠幫助我們明白為何它會發展為如今這個樣子,引發探究網路的興趣。下面的這張圖片就展示了“互聯網”誕生至今的發展歷程。 ......

    uj5u.com 2023-03-16 11:00:15 more
  • 藍牙-低功耗中心設備

    //11.開啟藍牙配接器 openBluetoothAdapter //21.開始搜索藍牙設備 startBluetoothDevicesDiscovery //31.開啟監聽搜索藍牙設備 onBluetoothDeviceFound //30.停止監聽搜索藍牙設備 offBluetoothDevi ......

    uj5u.com 2023-03-15 09:06:45 more
  • canvas畫板(滑鼠和觸摸)

    <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>canves</title> <style> #canvas { cursor:url(../images/pen.png),crosshair; } #canvasdiv{ bo ......

    uj5u.com 2023-02-15 08:56:31 more
  • 手機端H5 實作自定義拍照界面

    手機端 H5 實作自定義拍照界面也可以使用 MediaDevices API 和 <video> 標簽來實作,和在桌面端做法基本一致。 首先,使用 MediaDevices.getUserMedia() 方法獲取攝像頭媒體流,并將其傳遞給 <video> 標簽進行渲染。 接著,使用 HTML 的 < ......

    uj5u.com 2023-01-12 07:58:22 more
  • 記錄--短視頻滑動播放在 H5 下的實作

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 短視頻已經無數不在了,但是主體還是使用 app 來承載的。本文講述 H5 如何實作 app 的視頻滑動體驗。 無聲勝有聲,一圖頂百辯,且看下圖: 網址鏈接(需在微信或者手Q中瀏覽) 從上圖可以看到,我們主要實作的功能也是本文要講解的有: ......

    uj5u.com 2023-01-04 07:29:05 more
  • 一文讀懂 HTTP/1 HTTP/2 HTTP/3

    從 1989 年萬維網(www)誕生,HTTP(HyperText Transfer Protocol)經歷了眾多版本迭代,WebSocket 也在期間萌芽。1991 年 HTTP0.9 被發明。1996 年出現了 HTTP1.0。2015 年 HTTP2 正式發布。2020 年 HTTP3 或能正... ......

    uj5u.com 2022-12-24 06:56:02 more
  • 【HTML基礎篇002】HTML之form表單超詳解

    ??一、form表單是什么

    ??二、form表單的屬性

    ??三、input中的各種Type屬性值

    ??四、標簽 ......

    uj5u.com 2022-12-18 07:17:06 more