碰撞反彈算法是小游戲開發中非常常用的一種算法,像是打磚塊、彈一彈等經典小游戲的核心算法都是碰撞的判斷與響應,那就讓我們通過一個簡單的例子來看一看在canvas上是怎么實現碰撞判斷與反彈的效果的
首先我們得有一個球
- 讓我們嘗試着將小球單獨封裝成一個類
// 封裝一個小球類
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畫布的中央位置,效果如下圖
球有了,該讓它動起來了
在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的用法挺像的,只不過不再需要我們手動設置延遲時長
讓我們來看看現在的效果
很好,小球已經如我們預期的那樣動起來了,可現在的小球觸碰到邊界后就消失不見了,這顯然不是我們想看到的
接下來,讓我們的小球和牆壁親密碰撞一下吧
很簡單,只需要改一下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)
}
好玩吧,只需要將速度取一下負數,便實現了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)
}
這里涉及到一些角度的計算,不過都是很基礎的數學幾何知識,用紙筆仔細畫一下便能想通,讓我們看看效果
很好,現在我們已經實現了最簡單的碰撞效果,此時的我內心不免有些膨脹,一個小球撞來撞去的多沒意思,不如多來幾個?
這里便體現出了之前用類來封裝小球的好處了,想要幾個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)
}
}
效果不錯嘛~
再加一個
嗯???這是什么鬼,這跟說好的完全不一樣啊,雖然看着還挺酷的,不過我要的不是這效果呀,為什么會變成這樣呢?
琢磨了半天,終於發現了這里有個小坑,在使用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()
}
現在好啦,再來幾個都沒問題
球球之間也來個親密碰撞吧
小球與牆壁的碰撞與反彈我們已經實現了,不過這么多的小球,就這么擦肩而過也不行啊,我們要怎么實現球與球之間的碰撞呢?
在實現這個效果之前,我們得首先分析一下,要實現球與球之間的碰撞判斷,肯定得兩兩進行比對,不過這里有個性能上的問題,我們要明白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)
}
}
像這樣,我們便實現了小球間的碰撞與反彈,效果如下:
有沒有發現什么不對勁的地方?咦,上面那個小球為什么一直黏附着上牆壁不下來,這里主要是因為小球間的碰撞和小球與牆壁的碰撞同時發生導致的,因為運動角度改變了兩次而發生了一些奇怪的變化,因此我們需要進行一下優化,只要避免運動角度在同一幀內發生多次改變即可,在這里我們通過給每個小球定義一個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()
現在,我們的效果已經很棒了,不是嘛~
當然,實際游戲開發中的碰撞場景會更為復雜,會涉及到很多不規則場景的碰撞,反彈角度也不會那么單一,但實現的原理大同小異,所以,只要掌握最基本的原理,即便再復雜的場景也能迎刃而解了。