- 原文鏈接:點擊跳轉
- 作者:Sébastien Bénard,《死亡細胞》的主創
- 譯者:mnikn
在上篇文章中,我實現了一個任何語言都可以實現的(我用的是 Haxe),簡單但有效的 2D 引擎,這個引擎已經可以處理基本的物理邏輯和關卡中的碰撞。
但是 entity 之間的碰撞又要怎麼處理?
如果你的遊戲不需要非常嚴格的碰撞檢測的話,其實實現起來沒有想象中的那麼難。
1. Demo
接下來我們實現的 demo 並不是一個 platformer,而是一個 top-down 風格的 demo,其實這相當於去掉重力因素的 platformer。我的一個遊戲 Atomic Creep Spawner 就是使用這個引擎實現的,在這個教程中我會教你如何一步步來實現它。
使用方向鍵來移動周圍的黃色球。(譯註:我也不知道作者說的什麼意思……原話就是 Use arrow keys to move the yellow ball around)
注意:因為我們實現關卡碰撞的方式,有時候可能 entity 的顯示位置會有點奇怪。與其把 xr 的下限設置成 0.3,不如讓 xr 的下限可以小於 0.3,這樣如果你想要通過修改 dx 讓 entity 和牆不要重疊,效果會更好(同時把 xr 的下限設置為 0.1)。
2. 開始實現
我們先回憶下之前實現的功能,下面是 Entity 類的偽代碼:
class Entity { // 圖形對象 public var sprite : flash.display.Sprite; // 基本座標 public var cx : Int; public var cy : Int; public var xr : Float; public var yr : Float; // 結果座標 public var xx : Float; public var yy : Float; // 移動相關 public var dx : Float; public var dy : Float; public function new() { //... } public function update() { // X 座標處理 (例如,移動處理和關卡碰撞檢測) //... // Y 座標處理 (例如,移動處理和關卡碰撞檢測) //... // 更新圖形 //... } }
3. 檢測 entity 間的碰撞
實現的思路和之前一樣儘可能簡單,每個 entity 會有個 radius 屬性,這個屬性決定了 entity 的碰撞體積(hitbox)。需要注意的是,對於關卡碰撞,我們還是使用之前的檢測方式。
public var radius : Float;
這樣一個檢測方式能夠滿足大部分情況,除非你的遊戲對碰撞的精度要求非常嚴格(例如主打物理的遊戲)。同時需要注意的是這樣一個實現方式並非是最完整的:因為它實現的粒度足夠小,所以很容易做後續的拓展和維護。
下面的截圖(來源:arkadeo.com)中,黃色的圈圈表示的是物體的碰撞面。簡單來說,遊戲中通過這些碰撞面來檢測 player 是否能夠摧毀敵人(這感覺不錯):)。
下面我們來實現一個簡單的碰撞檢測函數:
public inline function overlaps(e:Entity) { var maxDist = radius + e.radius; // 經典的距離公式 var distSqr = (e.xx-xx)*(e.xx-xx) + (e.yy-yy)*(e.yy-yy); return distSqr<=maxDist*maxDist; // 注意: 用平方根做比較不是必須的 }
注意我把方法標記成了 inlined,因為一些平臺例如 Flash,對於函數的調用會有細微的性能損耗。
好了!現在對於“我們的 hero 對象有沒有和其他 entity 發生碰撞”這類問題,我們都可以通過上面的函數回答。很酷的是我們其實已經可以用它來做遊戲了。
4. 互斥(repel)
在一個遊戲中,大部分有關兩個 entity 之間的碰撞最後都會導致其中一個 entity 被摧毀(例如:撿起物品,被子彈擊中...)。但是有時候你想要讓 entity 之間不維持碰撞(也就是:互斥,repel)。
我們假設有兩個 entity 發生相交:藍色的圈和黃色的圈。我們之所以知道它們處於相交狀態,是因為它們之間的距離小於它們半徑的和。
接下來我們要做的就是讓藍圈向左下角移動,讓右圈向右上角移動。我們有很多種實現方式:
- 給每個 entity 的 dx、dy 添加互斥力,這樣它們就能分開了。
- 直接更改每個 entity 的位置,這樣它們的移動方向會保持不變,它們也不會再重疊了。
- 每一幀更新都讓 xr,yr 輕微地從碰撞中心移出去,這樣就不需要去更改當前的外力(dx,dy)
- ...還有無數種其他可能的實現方案
採用哪種方案取決於你想要什麼效果,和你認為發生重疊的容忍值(tolerance)。例如對於方案 1 來說,如果兩個 entity 突然重疊(例如黃圈快速向藍圈衝過去),實際上有幾幀它們還是會處於重疊狀態,直到互斥力把它們分開。
通常我會使用方案 1,因為它最簡單(你知道我的風格的)。如果這個方案不符合我的預想,我會添加一些新的東西來修正它(例如強制更改位置,方案二)。
注意:上述的例子中紅線代表重疊的距離,如果你想要根據重疊情況來添加互斥力,你大概率會用到它。或者如果重疊的程度太多了,你也可以直接用這個值來對 entity 強制更新位置。
碰撞相關的代碼都會放在 update 函數里,在 X 和 Y 座標處理代碼的上方。為什麼要這樣處理?因為通常我們想要讓關卡碰撞優先於 entity 間的碰撞(你不想要 entity 卡在牆上吧)。雖然加上了互斥力後這種情況應該不會發生,不過隨著遊戲的開發,我不知道接下來會往我的互斥算法拓展什麼東西。
對於每個 entity,你都需要去檢測是否和其他 entity 發生碰撞。
理論上的警告:如果你在遊戲中會有大量的 entity,這樣的做法可能會消耗大量的性能。
實際上:通常你不會有這麼多 entity 會發生碰撞並且還需要互斥。例如:通常來說子彈不會做這樣的互斥處理,當子彈發生碰撞時就會被摧毀了。即使你真的有這麼多 entity,你總會找到方案去過濾一些根本沒可能發生碰撞的 entity。
例如比較 cx,cy 座標。
for( e in ALL ) { // 先來一個簡易的距離檢測 if( e!=this && Math.abs(cx-e.cx) <= 2 && Math.abs(cy-e.cy) <= 2 ) { // 真正的距離檢測 var dist = Math.sqrt( (e.xx-xx)*(e.xx-xx) + (e.yy-yy)*(e.yy-yy) ); if( dist <= radius+e.radius ) { var ang = Math.atan2(e.yy-yy, e.xx-xx); var force = 0.2; var repelPower = (radius+e.radius - dist) / (radius+e.radius); dx -= Math.cos(ang) * repelPower * force; dy -= Math.sin(ang) * repelPower * force; e.dx += Math.cos(ang) * repelPower * force; e.dy += Math.sin(ang) * repelPower * force; } } }
第一個 IF 用來做初步判斷“我是否真的需要檢查這個 entity?它的距離足夠近嗎?”
通過後我們會做一些真正的平方根距離檢查。如果我們真的發現有碰撞,我們就根據神奇的 ATan2 公式去計算 entity 的移動角度。
同時我們會計算 repelPower,這個值(0 到 1)取決於 entity 之間的重疊程度。
現在我們有移動角度,我們有“重疊的係數”,我們就可以用 cos(移動角度) 和 sin(dy),計算出實際需要施加的互斥力,然後把結果反加(變為負數)到其他 entity 上。
這就是所有的實現過程了。