一看就懂 - 從零開始的游戲開發
0x00 寫在最前面
對于開發而言,了解一下如何從零開始做游戲是一個非常有趣且有益的過程(并不)。這里我先以大家對游戲開發一無所知作為前提,以一個簡單的游戲開發作為🌰,跟大家一起從零開始做一個游戲,淺入淺出地了解一下游戲的開發
此外,諸君如果有游戲制作方面的經驗,也希望能不吝賜教,畢竟互相交流學習,進步更快~
這次的分享,主要有幾個點:
- Entity Component System 思想,以及它在游戲開發中能起的作用(important!)
- 一個簡單的 MOBA 游戲,是如何一步步開發出來的
Entity Component System: https://en.wikipedia.org/wiki/Entity_component_system
「由于時間關系內容沒有仔細校對,難免存在疏漏,還請各位予以指正~」
文章有點長,建議 PC 端閱讀
制作游戲的開始
在動手做游戲之前,最重要的事情當然是先決定要做一個什么樣的游戲。作為一個教程的游戲,我希望它的玩法比較簡單,是可以一眼就看出來的;在此基礎上,又要有可以延展的深度,這樣才利于后面教程后面的拓展
一番思索,腦子里的游戲大致是:
- 類型: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,以及處理運動的
MoveSystemplayerCMD.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
- 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
- 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):包括重新開始、退出游戲等
- 更豐富的玩法:比如守家 / 占點 / 奪旗...多種模式
- 更多的游戲元素:技能、升級成長、地形...
- ...
這只是一個作為教程的示例,并不能做到盡善盡美,但還是希望大家能在整個分享里面,對「如何從零開始做一個游戲」這件事,有一個或多或少的認知。如果能讓大家感覺到,「做一個游戲,其實很簡單」 的話,那今天的分享就算是成功了~
說起來...后面如果有時間,可以把這些點都補充上去,實際上,都還挺有趣的..