一看就懂 - 從零開始的遊戲開發
「題圖:The Elder Scroll VI」
0x00 寫在最前面
對於開發而言,瞭解一下如何從零開始做遊戲是一個非常有趣且有益的過程(並不)。這裏我先以大家對遊戲開發一無所知作爲前提,以一個簡單的遊戲開發作爲🌰,跟大家一起從零開始做一個遊戲,淺入淺出地瞭解一下游戲的開發
此外,諸君如果有遊戲製作方面的經驗,也希望能不吝賜教,畢竟互相交流學習,進步更快~
這次的分享,主要有幾個點:
-
Entity Component System 思想,以及它在遊戲開發中能起的作用 (important!)
-
一個簡單的 MOBA 遊戲,是如何一步步開發出來的
Entity Component System: https://en.wikipedia.org/wiki/Entity_component_system
「由於時間關係內容沒有仔細校對,難免存在疏漏,還請各位予以指正~」
製作遊戲的開始
在動手做遊戲之前,最重要的事情當然是先決定要做一個什麼樣的遊戲。作爲一個教程的遊戲,我希望它的玩法比較簡單,是可以一眼就看出來的;在此基礎上,又要有可以延展的深度,這樣才利於後面教程後面的拓展
一番思索,腦子裏的遊戲大致是:
-
類型:MOBA(Multiplayer Online Battle Arena)
-
主要玩法:動作 - 射擊類
-
畫面:2d(因爲 3d 遊戲開發需要的前置知識點更多,光渲染都可以出本書了,不太適合作爲教程)
之所以這麼選擇,是因爲 moba 遊戲屬於比較火的類型,而且玩法上有非常多可擴展的點
遊戲開發
在決定遊戲類型玩法之後,我們就可以開始動手了。對於上面提出來的需求,實現起來需要:
-
可以管理複雜的對象交互邏輯的框架
-
能夠檢測、處理碰撞的物理引擎
-
渲染遊戲場景、對象所需的渲染器
-
資源,各種各樣的資源,包括美術、音樂等各種各樣的方面
0x01 創世的開始 - 引擎 / 框架與遊戲
先說一下爲什麼要取這麼個中二的標題... 實際上最早的電子遊戲(Pong),就是源於對現實的模擬,隨着技術的發展,遊戲畫面越發的精緻,遊戲系統也越發的複雜,還有像 VR 這樣希望更進一步仿真的發展方向。因此,我覺得,做一個遊戲,在一定程度上,可以看做是創造一個世界
首先,要做一個遊戲,或者說,要創造一個世界,第一步需要什麼?按照一些科學家的說法,是一些最基礎的**「宇宙常數」**(eg: 萬有引力常數、光速、絕對零度...etc)在這些常數的基礎上,進一步延伸出各種規則。而這個宇宙,便在這一系列規則的基礎上演變,直到成爲如今的模樣
對於我們的遊戲來說,同樣如此。我們所選用的遊戲引擎與框架,便是我們遊戲世界中的法則
遊戲引擎 & 框架
那麼,什麼是遊戲引擎 / 框架呢?其實跟我們平時寫前端一樣。引擎,本質上就是一個盒子,接受我們的輸入提供輸出(比如渲染引擎接受位置 / 大小 / 貼圖等信息,輸出圖像...etc)而框架呢,我個人認爲更多的是一種思想,決定我們要如何組織功能
類比一下:我們使用的 react 框架,可以看作是一套組件化編程的範式,它會爲組件生成 react element;而 react-dom 則是引擎,負責把我們寫的組件轉換成 HTML,再交由瀏覽器做進一步的工作
那麼,作爲從零開始的創世,我們就先從遊戲框架這裏開始第一步——
框架的選擇
對於這個遊戲,我決定選用 ECS(Entity Component System) 框架。ECS 的思想早已有之,在 17 年的 GDC 上因爲 Blz OW 團隊的分享而變得流行。在介紹 ECS 之前,我們先來與熟悉的 OOP 對比一下:
Procedural Programming & Object Oriented Programming
國內很多高校,都是以 C 語言開始第一門編程語言的教學的,對應的編程範式,一般被稱爲「「面向過程」」;而到了 C++ 這裏,引入了「類 / 對象」的概念,因此也被稱爲「「面向對象」」編程
Eg: 「我喫午飯」
// Procedural Programming
eat(me, lunch)
// OOP
me.eat(lunch)
前者強調的是「喫」這個過程,「我」與「午飯」都只是參數;後者強調的是「我」這個對象,「喫」只是「我」的一個動作
對於更復雜的情況,OOP 發展出了繼承、多態這一套規則,用於抽象共有的屬性與方法,以實現代碼與邏輯的複用
class People {
void eat()
}
class He extends People {}
class She extends People {}
const he = new He()
const she = new She()
he.eat()
she.eat()
可以看出,我們關注的點是:He 和 She 都是「人」,都具有「喫」這個共通的動作
ECS - 三相之力
那麼,換作 ECS 則如何呢?
我們首先需要有一個 Entity(它可以理解爲一個組件 Component 的集合,僅此而已)
class Entity {
components: {}
addComponent(c: Component) {
this.components[c.name] = component
}
}
然後,在 ECS 中,一個 Entity 能幹嘛,取決於所擁有的 Component:我們需要標識它可以「喫」
class Mouth {
name: 'mouth'
}
最後,需要引入一個 System 來統一執行 「喫」這個動作
class EatSystem {
update(list: Entity[]) {
list.forEach(e => e.eat)
}
}
OK,現在 E C S 三者已經集齊,他們如何組合起來運行呢?
function run() {
const he = (new Entity()).addComponent(Mouth)
const she = (new Entity()).addComponent(Mouth)
const eatSystem = new EatSystem()
eatSystem.update([he, she])
}
在 ECS 中,我們關注的重點在於,Entity 都具有 Mouth
這個 Component
,那麼對應的 EatSystem
就會認爲它可以「喫」
說到這裏,大家可能都要罵坑爹了:整的這麼複雜,就爲了實現上面這簡單的功能?其實說的沒錯...ECS 的引入,確實讓代碼變得更加多了,但這也正是它的核心思想所在:「組合優於繼承」
當然,實際的 ECS 並沒有這麼簡單,它需要大量的 utils 以及 輔助數據結構來實現 Entity、Component 的管理,比如說:
-
需要設計數據結構以方便 Entity 的查詢
-
需要引入 Component 的狀態管理、屬性變化追蹤等機制,參考資料:
-
ECS ReactiveSystem:https://www.effectiveunity.com/ecs/06-how-to-build-reactive-systems-with-unity-ecs-part-1/
-
ECS 檢測 Component 狀態變化:https://www.effectiveunity.com/ecs/07-how-to-build-reactive-systems-with-unity-ecs-part-2/
-
ECS SystemStateComponent:https://docs.unity3d.com/Packages/com.unity.entities@0.0/manual/system_state_components.html
-
真正工業級的 ECS 框架還需要優化內存管理機制,用來加速 System 的執行
這裏比比了這麼多,只是爲了先給大家留下一個大概印象,具體的機制以及實現等內容,後面會結合項目的功能以及迭代來講解 ECS 在其中的作用,這樣也更有利於理解
ECS Pros and Cons
長處
-
「組合優於繼承」:Entity 所具有的表現,僅取決於它所擁有的 Component,這意味着完全解耦對象的屬性與方法;另外,不存在繼承關係,也就意味着不需要再爲基類子類的各種問題所頭疼(eg:菱形繼承、基類修改影響所有子類...etc)
-
「數據與邏輯的完全抽離」:Entity 由 Component 組成,Component 之中只有數據,沒有方法;而 System 只有方法,沒有數據。這也就意味着,我們可以簡單地把當前整個遊戲的狀態生成快照,也可以簡單地將快照還原到整個遊戲當中(這點對於多人實時網遊而言,非常重要)
-
「表現與邏輯的抽離」:組件分離的方式天生適合邏輯和表現分離。通過一些組件來控制表現,以此實現同一份代碼,同時運行於服務端與客戶端
-
「組織方式更加友好」:真實的 ECS 中,Entity 本身僅具有 id 屬性,剩下完全由 Component 所組成,這意味着可以輕鬆做到遊戲內對象與數據、文檔之間的序列化、表格化轉換
不足之處
-
「System 之間存在執行順序上的耦合」:容易因爲 System 的某些副作用行爲(刪除 Entity、移除 Component)而影響到後續 System 的執行。這需要一些特殊的機制來儘量避免
-
「C 與 S 之間分離」:導致 S 難以跟蹤 C 的屬性變化(因爲 S 中沒有任何狀態;可以參考 unity 引入 SystemStateComponent / GlobalSystemVersion 等,見 「擴展閱讀」 部分 1/2/3)
-
「邏輯內聚,也更分散」:比如 A 對 B 攻擊,傳統 OOP 中很容易糾結傷害計算這件事情需要在 A 的方法還是 B 的方法中處理;而 ECS 中可以有專門的 System 處理這件事。但同樣的,System 也容易造成邏輯的分散,導致單獨看某些 System 代碼難以把握到完整的邏輯
引擎各部分
相比負責遊戲邏輯的框架,引擎更多的是注重提供某一方面的功能。比如:
-
渲染引擎
-
物理引擎
-
AI 引擎
-
...etc
這些引擎,每一部分都很複雜;爲了省事,我們這個項目,將使用現成的渲染引擎以及現成的資源管理加載器(Layabox,一個 JS 的 H5 遊戲引擎)
這裏各部分的內容,跟遊戲本身的內容關聯比較緊密,我會在後面講到的時候詳細說明,這裏就先不展開了。免得大家帶着太多的問題,影響思考
0x02 創世的次日
在整個遊戲世界的基礎確定了之後,我們可以開始着手遊戲的開發了。當然,在這之前,我們需要先準備一些美術方面的資源
大地與水 - Tilemap
作爲一個 moba 遊戲,地圖設計是必不可少的。而沒有設計技能,沒有美術基礎的我們,要怎麼才能比較輕鬆的將腦子裏的思路轉換爲對應的素材呢?
這裏我推薦一個被很多獨立遊戲使用的工具:Tilemap Editor。它是一個開源且免費的 tilemap 編輯器,非常好用;此外,整個圖形化的編輯過程也非常的簡單易上手,資源也可以在網上比較簡單的找到,這裏就不贅述過多
Tilemap Editor:https://www.mapeditor.org/
如此這般,一番操作之後,我們得到了一個簡單的地圖。現在我們可以開始整個遊戲開發的第一步了
場景 & 角色 - 大地創生
我們需要有兩個 Entity,其中一個對應場景 —— initArena
,一個對應我們的人物 —— initPlayer
,核心代碼:
initArena.ts
function initArena() {
const arena = new Entity()
world.addEntity(
arena
.addComponent<Position>('position', { x: 0, y: 0 })
.addComponent<RectangularSprite>('sprite', {
width,
height,
texture: resource
})
)
}
initPlayer.ts
function initPlayer() {
const player = new Entity()
player
.addComponent('player')
.addComponent<Position>('position', new Point(64 * 7, 64 * 7))
.addComponent<RectangularSprite>('sprite', {
pivot: { x: 32, y: 32 },
width: 64,
height: 64,
texture: ASSETS.PIXEL_TANK
})
world.addEntity(player)
}
在把這兩個 Entity 加入遊戲之後,我們還需要一個 System 幫助我們把它們渲染出來。我將它起名爲 RenderSystem
,由它專門負責所有的渲染工作(這裏我們直接使用現成的是渲染引擎,如果大家對這方面有興趣的話,回頭也可以再做一個延伸的分享與介紹... 渲染其實也是很有意思的事情並不)
renderSystem.ts
class RenderSystem extends System {
update() {
const entities = this.getEntities('position', 'sprite')
for (const i in entities) {
const entity = entities[i]
const position = new Point(entity.getComponent<Position>('position'))
const sprite = entity.getComponent<RectangularSprite>('sprite')
if (!sprite.layaSprite) {
// init laya sprite... ignore
}
const { layaSprite } = sprite
const { x, y } = position
layaSprite.pos(x, y)
}
}
}
Position & Sprite
上面的代碼,其實就是 ECS 思想的體現:Position
儲存位置信息,Sprite
儲存渲染相關的寬高以及貼圖、軸心點等信息;而 RenderSystem
會在每一幀中遍歷所有具有這兩個 Component 的 Entity,並渲染他們
然後,我們有了 E 與 S,還需要一個東西把它們串聯起來。這裏引入了一個 World
的概念,E 與 S 均是 W 裏面的成員。然後 W 每一幀調用一次 update 方法,更新並推進整個世界的狀態。這樣我們整個邏輯就能跑通了!
world.ts
class World {
update(dt: number) {
this.systems.forEach(s => s.update(dt))
}
addSystem(system: System) {}
addEntity(entity: Entity) {}
addComponent(component: Component) {}
}
萬事俱備,讓我們來運行一下代碼:
這樣,我們創造遊戲世界的第一步:簡單的場景 + 角色 就渲染出來了~
輸入組件 - 賦予生命
衆所周知,遊戲的核心在於交互,遊戲需要根據玩家的輸入(操作)實時產生輸出(反饋),玩遊戲的過程本質上就是一個跟遊戲互動的過程。這也正是遊戲與傳統藝術作品的區別:不僅僅是被動的接受,還可以通過自己的行爲,影響它的走向發展
要實現這點,我們離不開輸入。對於 moba 遊戲而言,比較自然的操作方式是**「輪盤」**。輪盤其實可以看做是虛擬搖桿:處理玩家在屏幕上的觸控操作,輸出方向信息
對於遊戲而言,這個輪盤應該只是 UI 部分,不應該與其他遊戲邏輯相關對象存在耦合。這裏我們考慮引入一個 UIComponent
的全局 UI 組件機制,用於處理遊戲世界中的一些 UI 對象
搖桿組件 joyStick.ts
abstract class JoyStick extends UIComponent {
protected touchStart(e: TouchEvent)
protected touchMove(e: TouchEvent)
protected touchEnd(e: TouchEvent)
}
虛擬搖桿主要的邏輯是:
其中我們需要:
-
從屏幕對應的全局座標系轉換到搖桿的局部座標系(線性變換)
-
判斷落點是否在搖桿內(點在圓內)
-
跟手移動(向量縮放)
通過一些簡單的向量運算,我們可以獲取到玩家觸控所對應的搖桿內的點,並實現搖桿的跟手交互
但是,這離讓坦克動起來,還是有點差距的。我們要怎麼把這個輪盤的操作轉換成小車的移動指令呢?
事件系統 - 控制的中樞
因爲遊戲是以固定的幀率運行的,所以我們需要一個實時的事件系統來收集各種各樣的指令,等待每幀的 update 時統一執行。因此我們需要引入名爲 BackgroundSystem
的後臺系統(區別於普通系統)來輔助處理用戶輸入、網絡請求等實時數據
BackgroundSystem.ts
class BackgroundSystem {
start() {}
stop() {}
}
它與普通 System 不同,不具有 update
方法;取而代之的是 start
與 stop
。它在整個遊戲開始時,便會執行 start
方法以監聽某些事件,並在 stop
的時候移除監聽
SendCMDSystem.ts
class SendCMDSystem extends BackgroundSystem {
start() {
emitter.on(events.SEND_CMD, this.sendCMD)
}
stop() {
emitter.off(events.SEND_CMD, this.sendCMD)
}
sendCMD(cmd: any) {
const queue: any[] = this.world.getComponent('cmdQueue')
// 離線模式下直接把指令塞進隊列
if (!this.world.online) {
queue.push(cmd)
} else {
// 走 socket 把指令發到服務端
}
}
}
(此處留待之後做在線模式擴展用)
注意,我們在這裏引入了「全局組件」的概念,某些 Component
,比如這裏的命令序列,又或者是輸入組件,它不應該從屬於某個具體的 Entity
;取而代之的,我們讓他作爲整個 World
之中的單例而存在,以此實現全局層面的數據共享
RunCMDSystem.ts
class RunCMDSystem extends BackgroundSystem {
start() {
emitter.on(events.RUN_CMD, this.runCMD)
}
stop() {
emitter.off(events.RUN_CMD, this.runCMD)
}
runCMD() {
const queue: any[] = this.world.getComponent('cmdQueue')
queue.forEach(this.handleCMD)
}
handleCMD(cmd: any) {
const type: Command = cmd.type
const handler: CMDHandler = CMD_HANDLER[type]
if (handler) {
handler(cmd, this.world)
}
}
}
由於指令可能會非常多,因此我們需要引入一系列的 helper 來輔助該系統執行命令,這並不與 ECS 的設計思路有衝突
另外,雖然爲了執行指令而引入這兩個 BackgroundSystem
的行爲看似麻煩,但長遠來看,其實是爲了方便之後的擴展~ 因爲多人遊戲時候,我們的操作很多時候並不能馬上被執行,而是需要發送到服務器,由它收集排序之後返回給客戶端。這時候,客戶端才能依次執行這序列中的指令
joyStick.ts #2
class MoveWheel extends JoyStick {
touchStart(e: TouchEvent) {
const e = super.touchStart(e)
emitter.emit(events.SEND_CMD, /* 指令數據 */)
}
// 各種方法 ...
}
這時,我們就可以對搖桿簡單擴展,把操作事件轉換成指令交由 BackgroundSystem
去執行了
運動
折騰了這麼多之後,我們已經有了移動的指令,那麼要怎麼才能讓角色動起來呢?仍然是通過 ECS 之間的配合:我們需要一個在 RunCMDSystem
中執行指令的 helper,以及處理運動的 MoveSystem
playerCMD.ts
function moveHandler(cmd: MoveCMD, world: World) {
const { data, id } = cmd
const entity = world.getEntityById(id)
if (entity) {
const { speed } = entity.components
const velocity = new Point(data.point).normalize().scale(speed)
const degree = (Math.atan2(velocity.y, velocity.x) / Math.PI) * 180
entity
.addComponent('velocity', velocity)
.addComponent('orientation', degree > 0 ? degree - 360 : degree + 360)
}
}
moveSystem.ts
class MoveSystem extends System {
update(dt: number) {
const entities = this.getEntities('velocity')
for (const i in entities) {
const entity = entities[i]
const position = entity.getComponent<Point>('position')
const velocity = entity.getComponent<Velocity>('velocity')
position.addSelf(velocity * dt)
}
}
}
我們先獲取到移動指令,然後根據該指令解算出速度對應的單位向量,然後結合 Entity 對應的 Speed
組件放縮這個向量,便是我們需要的 Velocity
,同時根據速度對應方向,可以獲取角色的朝向;
這之後,我們只需要在 MoveSystem
中做簡單的向量運算,便能計算出下一幀的角色所處位置了!
跟隨相機
雖然目前我們已經可以實現全方向的自由移動了,但是總感覺少了點什麼... 唔,我們缺少一個相機!沒有相機的話,我們只能以固定的視角觀察這個場景,這顯然是不合理的...
那麼,所謂的相機,又應該如何實現呢?最常見的相機,是以跟隨的形式存在的。也就是說,不管我們操控的角色如何行動,相機總會把它放在視野範圍的最中心
(換句話說,相機的實現本質上就是個矩陣,用於將世界座標映射到相機座標... 這個是 3D 遊戲裏面的邏輯,對此感興趣回頭可以再做個渲染器的實現,展開來講...)
想清楚了這點,其實就不難了:我們的相機的視口尺寸,與屏幕的寬高相等;然後我們這裏只是一個 2D 界面,從世界座標到相機座標只需要一個簡單的平移變換即可:
cameraSystem.ts
class CameraSystem extends System {
start() {
this.updateCamera()
}
update() {
this.updateCamera()
}
updateCamera() {
const camera = this.world.getComponent('camera') as Rect
const me = this.world.getEntityById(this.world.userId)
if (me) {
const position = me.getComponent('position') as Position
camera.pos(position.x - camera.w / 2, position.y - camera.h / 2)
}
}
}
renderSystem.ts # 2
class RenderSystem extends System {
update() {
const camera = this.world.getComponent('camera') as Rect
for (const i in entities) {
// ignore other code...
const position = new Point(entity.getComponent<Position>('position'))
const sprite = entity.getComponent<RectangularSprite>('sprite')
// 不在可見範圍 就不更新了
if (
!camera.intersection({
x: position.x,
y: position.y,
w: sprite.width,
h: sprite.height
})
) {
continue
}
position.subSelf(camera.topLeft)
}
}
}
CameraSystem
之中每一幀更新一次相機的位置(重新定位相機,使其以主角爲中心),然後 RenderSystem
之中針對別的物體做一次平移變換即可;另外,這裏還增加了相交檢測,如果待渲染的物體不位於相機可見範圍之內的話,則不作更新
這裏插入視頻
0x03 地形 & 碰撞檢測 / 處理
現在我們可以自由行走在遊戲世界內了!但是我們... 嗯,目前還與缺乏一些與世界內元素的互動。比如不允許穿越地圖的邊界;我們繪製在地圖內的牆壁,也應該是不能穿越的地形... 此外,可能還需要更復雜的玩法,比如河流(角色不能穿越,但是子彈可以..)沼澤(進入減速)所以,我們下一步要做的,就是加入這一套與地形有關的交互邏輯
地形系統
各種各樣的地形,可以一定程度上豐富遊戲的玩法與深度。我們以常見的 moba 遊戲爲例,一般會包括以下幾種地形:
-
平地:即沒有任何特殊效果的地形
-
牆壁:不允許通過,可能會對視野有阻礙(Dota 中的樹林)
-
草叢:進入之後可以隱蔽(LOL、王者)
-
高地:高地上的單位能看見同樣位於高地,或者外部地形上的單位;但外部地形上的單位無法看見高地上的單位
-
...
爲了簡單演示,我們這裏只做一下簡單的牆壁:阻礙玩家的移動,也不會被子彈摧毀。由於牆壁的貼圖已經在編輯地圖的時候加入了,我們目前需要做的只有
-
加入牆壁對應的 Entity
-
每幀檢測玩家的位置,接觸到牆壁的時候不允許移動
爲了實現這個玩法,我們需要引入專門檢測並處理碰撞的 System
「Attention」:下面這裏的碰撞相關邏輯,其實不應該直接放在 system 內,而是應該抽象出一個單獨的,類似渲染引擎那樣的物理引擎,然後纔是在 system 中每幀調用
碰撞檢測 / 處理
首先,讓我們從最簡單的情況開始:矩形與矩形之間的碰撞。由於我們使用了 Tilemap ,這導致我們的碰撞檢測情況比較簡單:兩個水平和垂直方向上對稱矩形碰撞
這裏並不會展開來講太多關於數學上的東西,具體可以參考一個簡單的幾何庫 rect.ts
參考:https://aotu.io/notes/2017/02/16/2d-collision-detection/index.html
rect.ts
相交判定部分.. 具體規律(比如 rect1.topLeft.x 總是小於 rect2.topRight.x etc...)可以對照上圖找
class Rect {
intersection(rect: Rect) {
return (
this._x < rect.x + rect.w &&
this._x + this._w > rect.x &&
this._y < rect.y + rect.h &&
this._y + this._h > rect.y
)
}
}
collisionTestSystem.ts
有了相交判定方法之後,我們就能簡單的實現一個碰撞檢測系統了
class CollisionTestSystem extends System {
update() {
const entities = this.world.getEntities('collider', 'velocity')
const allEntities = this.world.getEntities('collider')
const map: { [key: number]: { [key: number]: boolean } } = {}
for (let i in entities) {
const entityA = entities[i]
const colliderA = entityToRect(entityA, true)
const colliders: Entity[] = []
map[i] = {}
for (let j in allEntities) {
if (i === j) {
continue
}
map[j] || (map[j] = {})
if (map[i][j] || map[j][i]) {
continue
}
map[i][j] = map[j][i] = true
const entityB = allEntities[j]
const colliderB = entityToRect(entityB)
if (colliderA.intersection(colliderB)) {
colliders.push(entityB)
}
}
if (colliders.length) {
entityA.addComponent<Entity[]>('colliders', colliders)
}
}
}
}
我們這裏採用了比較簡單的兩重循環暴力遍歷,但還是儘可能的去降低運算量:
-
沒有
Velocity
的Entity
不會動,因此第一重循環不需要考慮他們 -
使用兩層字典,避免重複運算已經判定過的物體
然後,我們便可以根據這個檢測到的碰撞信息,進行下一步的碰撞處理
collisionHandleSystem.ts
class CollisionHandleSystem extends System {
update() {
const entities = this.world.getEntities('colliders', 'velocity')
for (const i in entities) {
const entity = entities[i]
const colliders = entity.getComponent<Entity[]>('colliders')
const typeA = entity.getComponent<Collider>('collider').type
colliders.forEach(e => {
const typeB = e.getComponent<Collider>('collider').type
const handler = handlerMap[typeA][typeB]
if (handler) {
handler(entity, e, this.world)
}
})
entity.removeComponent('colliders')
}
}
}
這裏我們做了一個 handler 的字典,因爲碰撞處理系統也需要大量的 helper 來輔助處理各種物體之間碰撞的情況(比如目前僅有 「角色與牆壁」,之後會引入更多的地形,以及更多的 Entity),之後就可以方便擴展
最後,我們只需要往世界裏面加入幾個空氣牆對應的 Entity 即可:
initArena.ts
[top, right, bottom, left].forEach((e: Rect) => {
const { x, y, w, h } = e
world.addEntity(
new Entity()
.addComponent<Position>('position', {
x,
y
})
.addComponent<Collider>('collider', {
width: w,
height: h,
type: ColliderType.Obstacle
})
)
})
同理,牆壁也可以這樣加入到我們的遊戲世界中,具體代碼就不貼了,同樣在 initArena.ts
文件內
展示一下...
攻擊 & 子彈
ok,在引入了碰撞檢測與處理的系統之後,是時候更進一步引入攻擊系統了。首先,我們要設計一個攻擊模式:
-
使用輪盤搓方向,這樣可以支持 360° 射擊
-
攻擊之間存在間隔
先加入一個輪盤:它只關心滑動結束時候的方向,並根據該方向生成一個攻擊指令:
joyStick.ts #3
class AttackWheel extends JoyStick {
constructor(params: JoyStickParams) {
super(params)
}
touchEnd(e: TouchEvent): undefined {
const event = super.touchEnd(e)
emitter.emit(events.SEND_CMD, {
type: Command.Attack,
...event
})
return undefined
}
}
但是在新加了這個輪盤之後,我們會很驚喜的遇到一個新問題:全局的觸摸事件衝突了... 回想一下,我們的 addEventListener
是直接往 document
上面添加的監聽方法,因此每一個觸摸事件,都會觸發兩個輪盤的 handler
。這裏我們引入一個變量 identifier
用於解決這個問題
joystick.ts #4
class JoyStick extends UIComponent {
touchMove(e: TouchEvent): Event | undefined {
// ignore ...
const point = this.getPointInWheel(changedTouches[0])
if (this.identifier === changedTouches[0].identifier) {
// ignore ...
}
return undefined
}
}
指令有了,再加入攻擊指令的處理方法:
playerCMD.ts #2
function attackHandler(cmd: AttackCMD, world: World) {
const { id, data, ts } = cmd
const entity = world.getEntityById(id)
if (entity) {
const attackConfig = entity.getComponent<Attack>('attack')
const lastAttackTS = entity.getComponent<number>('lastAttack') || 0
if (attackConfig.cooldown < ts - lastAttackTS) {
entity.addComponent('attacking', data.point)
entity.addComponent('lastAttackTS', ts)
}
}
}
我們根據攻擊指令的發起 id,獲取對應 Entity 的 Attack Component
,它裏面包含了關於攻擊的信息(傷害、間隔、子彈...),併爲對應對象增加一個 Attacking Component
用以指示狀態
attackSystem.ts
class AttackSystem extends System {
update() {
const entities = this.getEntities('attacking')
for (const i in entities) {
const entity = entities[i]
const position = entity.getComponent<Point>('position').clone
const attackingDirection = entity.getComponent<Point>('attacking')
const attackConfig = entity.getComponent<Attack>('attack')
const velocity = attackingDirection.normalize()
const { width, height } = attackConfig.bullet
position.addSelf(width / 2, height / 2)
velocity.scaleSelf(attackConfig.speed)
const bullet = new Entity()
bullet
.addComponent<Bullet>('bullet', { /* ... */ })
.addComponent<Point>('position', position)
.addComponent<Point>('velocity', velocity)
.addComponent<RectangularSprite>('sprite', { /* ... */ })
.addComponent<Collider>('collider', { /* ... */ })
this.world.addEntity(bullet)
entity.removeComponent('attacking')
}
}
}
AttackSystem
會遍歷所有具有 Attacking
的對象,並根據它的一系列信息生成一個子彈。然後這個子彈會在 MoveSystem
中不斷地按照發射方向移動
攻擊判定 & Entity 的銷燬
當然,上面這個無限射程的子彈,其實並不是我們所希望的;同時,子彈在打到障礙物的時候也不應該穿透過去。這裏我們稍微修改一下原有的系統,使得子彈在擊中敵人或者牆壁時消失:
moveSystem #2
// 增加以下代碼
if (entity.has('bullet')) {
const { range, origin } = entity.getComponent<Bullet>('bullet')
if (range * range < position.distance2(origin)) {
entity.addComponent('destroy')
}
}
超出了射程範圍的子彈,應該被移除... 其實這個邏輯,應該另外再加一個 BulletSystem
之類的系統用於處理的,這裏我偷懶了... 我們會給超出了射程範圍的子彈加一個 Destroy
的標記,之後銷燬它。原因在下面的 DestroySystem
處有提到
creatureBullet.ts
function creatureBullet(
entityA: Entity,
entityB: Entity,
world: World
) {
const aIsBullet =
entityA.getComponent<Collider>('collider').type === ColliderType.Bullet
const bullet = aIsBullet ? entityA : entityB
const creature = aIsBullet ? entityB : entityA
const { generator: generatorID } = bullet.getComponent<Bullet>('bullet')
if (generatorID === creature.id) {
return
}
bullet.addComponent('destroy')
}
與障礙物 / 角色碰撞的子彈,也需要移除。但是忽略子彈與自身的碰撞(因爲子彈是從角色當前位置被髮射出去的)
destroySystem.ts
class DestroySystem extends System {
update() {
const entities = this.getEntities('destroy')
for (const i in entities) {
this.world.removeEntity(entities[i])
}
}
}
這裏做的還比較簡單,如果是完整的實現,還可以補充上子彈銷燬時候的「爆炸動畫效果」。我們可以藉助 ECS 中的 Entity 上面的 removeFromWorld
回調實現之
*ps:這裏的 DestroySystem
執行順序應該位於所有 System
之後。這也是 ECS 應該遵循的設計:推遲所有會影響其他 System
的行爲,放在最後統一執行
**pps:這裏可以再增加一個池化的機制,減少子彈這類需要反覆創建 / 銷燬的對象的維護開銷
AI 的引入
到目前爲止,我們已經有一個比較完整的地圖,以及可自由移動、攻擊的角色。但只有一個角色,遊戲是玩不起來的,下一步我們就需要往遊戲內加入一個個的 AI 角色
我們將隨機生成 Position (x, y) 的位置,如果該位置對應的是空地,那麼則把 AI 玩家放置在此處
initPlayer.ts # 2
function initAI(world: World, arena: TransformedArena) {
for (let i = 0; i < count; i++) {
let x, y
do {
x = random(left, right)
y = random(top, bottom)
} while (tilemap[x + y * width] !== -1)
const enemy = generatePlayer({
player: true,
creature: true,
position: new Point(cellPixel * x, cellPixel * y),
collider: { /* ... */ },
speed,
sprite: { /* ... */ },
hp: 1
})
world.addEntity(enemy)
}
}
但是,這些 AI 角色,他們都莫得靈魂!
在我們創造 AI 角色之後,下一步就需要給他們賦予生命,讓他們能夠移動,能夠攻擊,甚至給他們更加真實的一些反應,比如捱打了會逃跑,會追殺玩家...etc。要實現這樣的 AI,讓我們先來了解一下游戲 AI 的一種比較常用的實現方式——決策樹(或者叫 行爲樹)
行爲樹
整個行爲樹,由一系列的節點所組成,每個節點都具有一個 execute
方法,它返回一個 boolean
,我們將根據這個返回值來決定下一步的動作。節點可以分爲以下幾類:
-
選擇節點:執行所有子節點,當遇到第一個爲 true 的返回值時結束
-
順序節點:執行所有子節點,當遇到第一個爲 false 的返回值時結束
-
條件節點:一般用來作爲葉子節點與順序節點、行爲節點組合,實現條件執行動作的功能
-
行爲節點:具體執行動作的節點,比如移動、攻擊...etc
更具體的解釋可參考 https://www.cnblogs.com/KillerAery/p/10007887.html
tankTree.ts
這裏我們構建了幾個 AI 最基本的動作,作爲葉子節點
-
移動
-
索敵
-
攻擊
省略了大部分邏輯相關代碼,具體可見 systems/ai
目錄下相關文件
class RandomMovingNode extends ActionNode {
execute() {
// 尋路...
return true
}
}
class SearchNode extends ConditionNode {
condiction() {
// 檢測範圍內是否存在敵人
}
}
class AttackNode extends ActionNode {
execute() {
// 向敵人發起攻擊
return true
}
}
// Tree Component 有方法, 不太好, 想想怎麼改
export class TankAITree extends BehaviorTree {
constructor(world: World, entity: Entity) {
this.root = new ParallelNode(this).addChild(
new RandomMovingNode(this),
new SequenceNode(this).addChild(
new SearchNode(this),
new AttackNode(this)
)
)
}
}
在這幾個基礎的葉子節點上,搭配上文提到的 並行、順序 等節點,就可以組成一棵簡單的 AI 行爲樹:AI 一邊隨機移動,一邊搜索當前範圍內是否存在敵人
然後我們把行爲樹附加到 AI 角色身上,他們就可以動起來了!
運行展示一下...
0x04 總結
到這裏,我們已經做出來一個簡單的遊戲了!第一部分的內容,到這裏就暫告一段落了。回顧一下,在這部分裏面,我們:
-
實現了一套邏輯層相關的 ECS 框架,用於管理複雜的遊戲對象的更新交互邏輯
-
實現了簡單的事件系統,以及 UI 組件相關邏輯
-
簡單實現了遊戲中的大部分邏輯:移動、攻擊、相機跟隨...
當然,它也還差一些未完成的部分:
-
多人遊戲支持
-
遊戲選單(Game Menu):包括重新開始、退出遊戲等
-
更豐富的玩法:比如守家 / 佔點 / 奪旗... 多種模式
-
更多的遊戲元素:技能、升級成長、地形...
-
...
這只是一個作爲教程的示例,並不能做到盡善盡美,但還是希望大家能在整個分享裏面,對「如何從零開始做一個遊戲」這件事,有一個或多或少的認知。如果能讓大家感覺到,「做一個遊戲,其實很簡單」 的話,那今天的分享就算是成功了~
說起來... 後面如果有時間,可以把這些點都補充上去,實際上,都還挺有趣的..
0x05 擴展閱讀
-
ECS ReactiveSystem:https://www.effectiveunity.com/ecs/06-how-to-build-reactive-systems-with-unity-ecs-part-1/
-
ECS 檢測 Component 狀態變化:https://www.effectiveunity.com/ecs/07-how-to-build-reactive-systems-with-unity-ecs-part-2/
-
ECS SystemStateComponent:https://docs.unity3d.com/Packages/com.unity.entities@0.0/manual/system_state_components.html
-
2d 碰撞檢測:https://aotu.io/notes/2017/02/16/2d-collision-detection/index.html
-
遊戲 AI 行爲樹:https://www.cnblogs.com/KillerAery/p/10007887.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ELEDfKjnCFGanxxPWT_xDg