使用canvas實現碰撞反彈效果


碰撞反彈算法是小游戲開發中非常常用的一種算法,像是打磚塊、彈一彈等經典小游戲的核心算法都是碰撞的判斷與響應,那就讓我們通過一個簡單的例子來看一看在canvas上是怎么實現碰撞判斷與反彈的效果的

首先我們得有一個球

  1. 讓我們嘗試着將小球單獨封裝成一個類
// 封裝一個小球類
class Ball {
  constructor(x, y, radius) {
    this.x = x
    this.y = y
    this.radius = radius
    this.angle = Math.random() * 180
    this.speed = 5
  }

  draw(ctx) {
    ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
    ctx.fillStyle = 'blue'
    ctx.fill()
  }
}

這里的封裝很簡單,小球類僅暴露出了一個方法,用於將其繪制於指定的canvas畫布上,此外擁有自身的坐標、半徑、運動角度和速度屬性(現在的小球類肯定是存在問題的,至於什么問題,你猜( ̄︶ ̄)↗ 漲)
2. 然后我們需要將小球繪制到畫布上

// 獲取canvas畫布和context
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

ctx.strokeRect(0, 0, canvas.width, canvas.height)
const ball = new Ball(canvas.width / 2, canvas.height / 2, 20)
ball.draw(ctx)

這里的代碼也很簡單,我們生成了一個半徑為20的小球,並將其繪制在canvas畫布的中央位置,效果如下圖

image

球有了,該讓它動起來了

在canvas中實現的動畫的方法有很多,但原理無非都是通過不停的擦除和重繪,只要我重繪的速度足夠快,你的肉眼就跟不上我,從而也就實現了動畫的效果。一般而言,只要每秒重繪的次數達到24次,也即24幀/秒,人眼便感覺不到延遲了。

要實現這樣快速的擦除和重繪,我們很自然地能想到用setTimeout和setInterval方法,這確實可以實現動畫的效果,不過在制作canvas動畫時,我們更推薦的是使用window對象封裝的requestAnimationFrame方法,專為動畫而生。

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  // 計算小球下一幀的坐標
  ball.x++

  // 繪制
  ball.draw(ctx)
}

drawFrame()

requestAnimationFrame的使用方法也很簡單,只需要我們定義一個繪制函數,每一次調用都會刷新小球的坐標,然后作為回調函數傳遞給requestAnimationFrame即可,其實乍一看跟setTimeout的用法挺像的,只不過不再需要我們手動設置延遲時長

讓我們來看看現在的效果

image

很好,小球已經如我們預期的那樣動起來了,可現在的小球觸碰到邊界后就消失不見了,這顯然不是我們想看到的

接下來,讓我們的小球和牆壁親密碰撞一下吧

很簡單,只需要改一下drawFrame函數

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  // 計算小球下一幀的坐標
  if (ball.x > canvas.width || ball.x < 0) ball.speed = -ball.speed
  ball.x += ball.speed

  // 繪制
  ball.draw(ctx)
}

image

好玩吧,只需要將速度取一下負數,便實現了x方向的碰撞反彈效果。不過實際開發中的碰撞反彈當然不會是這么簡單,游戲中的碰撞不僅僅有x方向上的,還有y方向上的,甚至小球的運動方向還會帶有角度,這又該怎么實現呢?

帶有角度的自由碰撞與反彈

這里的原理我不再贅訴,直接給出代碼

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  // 判斷與牆壁的碰撞反彈
  if (ball.x + ball.radius > canvas.width) {
    ball.angle = 180 - ball.angle
  }
  if (ball.x - ball.radius < 0) {
    ball.angle = -(180 + ball.angle)
  }
  if (ball.y - ball.radius < 0 || ball.y + ball.radius > canvas.height) {
    ball.angle = -ball.angle
  }

  // 計算小球下一幀的坐標
  ball.x += ball.speed * Math.cos(ball.angle * Math.PI / 180)
  ball.y -= ball.speed * Math.sin(ball.angle * Math.PI / 180)

  // 繪制
  ball.draw(ctx)
}

這里涉及到一些角度的計算,不過都是很基礎的數學幾何知識,用紙筆仔細畫一下便能想通,讓我們看看效果

image

很好,現在我們已經實現了最簡單的碰撞效果,此時的我內心不免有些膨脹,一個小球撞來撞去的多沒意思,不如多來幾個?

這里便體現出了之前用類來封裝小球的好處了,想要幾個new幾個就是啦~

先來兩個

const balls = []
for (let i = 0; i < 2; i++) {
  // 我們讓小球的半徑和坐標都隨機一下
  const radius = Math.random() * 20 + 10 // 10 ~ 30
  const x = Math.random() * (canvas.width - radius - radius) + radius
  const y = Math.random() * (canvas.height - radius - radius) + radius

  balls.push(new Ball(x, y, radius))
}

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  for (let i in balls) {
    const ball = balls[i]

    // 判斷與牆壁的碰撞反彈
    if (ball.x + ball.radius > canvas.width) {
      ball.angle = 180 - ball.angle
    }
    if (ball.x - ball.radius < 0) {
      ball.angle = -(180 + ball.angle)
    }
    if (ball.y - ball.radius < 0 || ball.y + ball.radius > canvas.height) {
      ball.angle = -ball.angle
    }

    // 計算小球下一幀的坐標
    ball.x += ball.speed * Math.cos(ball.angle * Math.PI / 180)
    ball.y -= ball.speed * Math.sin(ball.angle * Math.PI / 180)

    // 繪制
    ball.draw(ctx)
  }
}

image

效果不錯嘛~

再加一個

image

嗯???這是什么鬼,這跟說好的完全不一樣啊,雖然看着還挺酷的,不過我要的不是這效果呀,為什么會變成這樣呢?

琢磨了半天,終於發現了這里有個小坑,在使用canvas繪圖時路徑沒有閉合路徑導致的,我們只需要對小球類Ball的draw方法稍作修改

draw(ctx) {
  ctx.beginPath()
  ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
  ctx.closePath()
  ctx.fillStyle = 'blue'
  ctx.fill()
}

image

現在好啦,再來幾個都沒問題

image

球球之間也來個親密碰撞吧

小球與牆壁的碰撞與反彈我們已經實現了,不過這么多的小球,就這么擦肩而過也不行啊,我們要怎么實現球與球之間的碰撞呢?

在實現這個效果之前,我們得首先分析一下,要實現球與球之間的碰撞判斷,肯定得兩兩進行比對,不過這里有個性能上的問題,我們要明白A與B的碰撞和B與A的碰撞是一樣的,因此我們只需判斷一次即可,所以此處我們可參考選擇排序的過程,利用雙重循環來實現梯形比較。

而如何判斷兩個小球是否碰撞,就再簡單不過了,只需要對兩個小球的圓心坐標做一下勾股定理,再和兩個小球的半徑和進行比較,即可

碰撞之后的反彈,在這個案例中,我們只用最簡單的方法來實現,直接讓小球的運動方向旋轉180度(實際場景當然不會這么簡單,這里是偷了個懶,大家別學我= =)

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]
    
    // 判斷小球間的碰撞
    for (let j = i + 1; j < balls.length; j++) {
      const dx = ball.x - balls[j].x
      const dy = ball.y - balls[j].y
      const dl = Math.sqrt(dx * dx + dy * dy)
      if (dl <= ball.radius + balls[j].radius) {
        ball.angle = ball.angle - 180
        balls[j].angle = balls[j].angle - 180
      }
    }

    // 判斷與牆壁的碰撞反彈
    if (ball.x + ball.radius > canvas.width) {
      ball.angle = 180 - ball.angle
    }
    if (ball.x - ball.radius < 0) {
      ball.angle = -(180 + ball.angle)
    }
    if (ball.y - ball.radius < 0 || ball.y + ball.radius > canvas.height) {
      ball.angle = -ball.angle
    }

    // 計算小球下一幀的坐標
    ball.x += ball.speed * Math.cos(ball.angle * Math.PI / 180)
    ball.y -= ball.speed * Math.sin(ball.angle * Math.PI / 180)

    // 繪制
    ball.draw(ctx)
  }
}

像這樣,我們便實現了小球間的碰撞與反彈,效果如下:

image

有沒有發現什么不對勁的地方?咦,上面那個小球為什么一直黏附着上牆壁不下來,這里主要是因為小球間的碰撞和小球與牆壁的碰撞同時發生導致的,因為運動角度改變了兩次而發生了一些奇怪的變化,因此我們需要進行一下優化,只要避免運動角度在同一幀內發生多次改變即可,在這里我們通過給每個小球定義一個flag屬性,用以標記在當前幀小球的運動方向是否已經發生變化,優化后的完整代碼如下:

// 獲取canvas畫布和context
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

// 封裝一個小球類
class Ball {
  constructor(x, y, radius) {
    this.x = x
    this.y = y
    this.radius = radius
    this.angle = Math.random() * 180
    this.speed = 5
    this.flag = false
  }

  draw(ctx) {
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
    ctx.closePath()
    ctx.fillStyle = 'blue'
    ctx.fill()
  }
}

// 隨機生成若干個小球
const balls = []
while (balls.length < 10) {
  const radius = Math.random() * 20 + 10 // 10 ~ 30
  const x = Math.random() * (canvas.width - radius - radius) + radius
  const y = Math.random() * (canvas.height - radius - radius) + radius

  let flag = true
  for (let i = 0; i < balls.length; i++) {
    const dx = x - balls[i].x
    const dy = y - balls[i].y
    const dl = Math.sqrt(dx * dx + dy * dy)
    if (dl <= radius + balls[i].radius) {
      flag = false
    }
  }
  if (flag) {
    balls.push(new Ball(x, y, radius))
  }
}

function drawFrame () {
  window.requestAnimationFrame(drawFrame, canvas)
  canvas.height = canvas.height // 清空畫布

  // 繪制牆壁
  ctx.strokeRect(0, 0, canvas.width, canvas.height)

  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]

    // 判斷小球間的碰撞
    for (let j = i + 1; j < balls.length; j++) {
      const dx = ball.x - balls[j].x
      const dy = ball.y - balls[j].y
      const dl = Math.sqrt(dx * dx + dy * dy)
      if (dl <= ball.radius + balls[j].radius) {
        ball.flag === false ? ball.angle = ball.angle - 180 : ''
        balls[j].flag === false ? balls[j].angle = balls[j].angle - 180 : ''
        ball.flag = balls[j].flag = true
      }
    }

    // 判斷與牆壁的碰撞反彈
    if (ball.flag === false) {
      if (ball.x + ball.radius > canvas.width) {
        ball.angle = 180 - ball.angle
      }
      if (ball.x - ball.radius < 0) {
        ball.angle = -(180 + ball.angle)
      }
      if (ball.y - ball.radius < 0 || ball.y + ball.radius > canvas.height) {
        ball.angle = -ball.angle
      }
    }

    // 計算小球下一幀的坐標
    ball.x += ball.speed * Math.cos(ball.angle * Math.PI / 180)
    ball.y -= ball.speed * Math.sin(ball.angle * Math.PI / 180)

    // 繪制
    ball.draw(ctx)
    ball.flag = false
  }
}

drawFrame()

image

現在,我們的效果已經很棒了,不是嘛~

當然,實際游戲開發中的碰撞場景會更為復雜,會涉及到很多不規則場景的碰撞,反彈角度也不會那么單一,但實現的原理大同小異,所以,只要掌握最基本的原理,即便再復雜的場景也能迎刃而解了。

——by Suevily


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM