一看就懂 - 從零開始的游戲開發(fā)
0x00 寫在最前面
對(duì)于開發(fā)而言,了解一下如何從零開始做游戲是一個(gè)非常有趣且有益的過程(并不)。這里我先以大家對(duì)游戲開發(fā)一無所知作為前提,以一個(gè)簡(jiǎn)單的游戲開發(fā)作為🌰,跟大家一起從零開始做一個(gè)游戲,淺入淺出地了解一下游戲的開發(fā)
此外,諸君如果有游戲制作方面的經(jīng)驗(yàn),也希望能不吝賜教,畢竟互相交流學(xué)習(xí),進(jìn)步更快~
這次的分享,主要有幾個(gè)點(diǎn):
- Entity Component System 思想,以及它在游戲開發(fā)中能起的作用(important!)
- 一個(gè)簡(jiǎn)單的 MOBA 游戲,是如何一步步開發(fā)出來的
Entity Component System: https://en.wikipedia.org/wiki/Entity_component_system
「由于時(shí)間關(guān)系內(nèi)容沒有仔細(xì)校對(duì),難免存在疏漏,還請(qǐng)各位予以指正~」
文章有點(diǎn)長(zhǎng),建議 PC 端閱讀
制作游戲的開始
在動(dòng)手做游戲之前,最重要的事情當(dāng)然是先決定要做一個(gè)什么樣的游戲。作為一個(gè)教程的游戲,我希望它的玩法比較簡(jiǎn)單,是可以一眼就看出來的;在此基礎(chǔ)上,又要有可以延展的深度,這樣才利于后面教程后面的拓展
一番思索,腦子里的游戲大致是:
- 類型:MOBA(Multiplayer Online Battle Arena)
- 主要玩法:動(dòng)作 - 射擊類
- 畫面:2d(因?yàn)?3d 游戲開發(fā)需要的前置知識(shí)點(diǎn)更多,光渲染都可以出本書了,不太適合作為教程)
之所以這么選擇,是因?yàn)?moba 游戲?qū)儆诒容^火的類型,而且玩法上有非常多可擴(kuò)展的點(diǎn)
游戲開發(fā)
在決定游戲類型玩法之后,我們就可以開始動(dòng)手了。對(duì)于上面提出來的需求,實(shí)現(xiàn)起來需要:
- 可以管理復(fù)雜的對(duì)象交互邏輯的框架
- 能夠檢測(cè)、處理碰撞的物理引擎
- 渲染游戲場(chǎng)景、對(duì)象所需的渲染器
- 資源,各種各樣的資源,包括美術(shù)、音樂等各種各樣的方面
0x01 創(chuàng)世的開始 - 引擎/框架與游戲
先說一下為什么要取這么個(gè)中二的標(biāo)題...實(shí)際上最早的電子游戲(Pong),就是源于對(duì)現(xiàn)實(shí)的模擬,隨著技術(shù)的發(fā)展,游戲畫面越發(fā)的精致,游戲系統(tǒng)也越發(fā)的復(fù)雜,還有像VR這樣希望更進(jìn)一步仿真的發(fā)展方向。因此,我覺得,做一個(gè)游戲,在一定程度上,可以看做是創(chuàng)造一個(gè)世界
首先,要做一個(gè)游戲,或者說,要?jiǎng)?chuàng)造一個(gè)世界,第一步需要什么?按照一些科學(xué)家的說法,是一些最基礎(chǔ)的「宇宙常數(shù)」(eg: 萬有引力常數(shù)、光速、絕對(duì)零度...etc)在這些常數(shù)的基礎(chǔ)上,進(jìn)一步延伸出各種規(guī)則。而這個(gè)宇宙,便在這一系列規(guī)則的基礎(chǔ)上演變,直到成為如今的模樣
對(duì)于我們的游戲來說,同樣如此。我們所選用的游戲引擎與框架,便是我們游戲世界中的法則
游戲引擎 & 框架
那么,什么是游戲引擎/框架呢?其實(shí)跟我們平時(shí)寫前端一樣。引擎,本質(zhì)上就是一個(gè)盒子,接受我們的輸入提供輸出(比如渲染引擎接受位置/大小/貼圖等信息,輸出圖像...etc)而框架呢,我個(gè)人認(rèn)為更多的是一種思想,決定我們要如何組織功能
類比一下:我們使用的 react 框架,可以看作是一套組件化編程的范式,它會(huì)為組件生成 react element;而 react-dom 則是引擎,負(fù)責(zé)把我們寫的組件轉(zhuǎn)換成 HTML,再交由瀏覽器做進(jìn)一步的工作
那么,作為從零開始的創(chuàng)世,我們就先從游戲框架這里開始第一步——
框架的選擇
對(duì)于這個(gè)游戲,我決定選用 ECS(Entity Component System) 框架。ECS 的思想早已有之,在 17 年的 GDC 上因?yàn)? Blz OW 團(tuán)隊(duì)的分享而變得流行。在介紹 ECS 之前,我們先來與熟悉的 OOP 對(duì)比一下:
Procedural Programming & Object Oriented Programming
國內(nèi)很多高校,都是以 C 語言開始第一門編程語言的教學(xué)的,對(duì)應(yīng)的編程范式,一般被稱為「「面向過程」」;而到了 C++ 這里,引入了「類/對(duì)象」的概念,因此也被稱為「「面向?qū)ο蟆埂咕幊?/p>
Eg: 「我吃午飯」
- // Procedural Programming
- eat(me, lunch)
- // OOP
- me.eat(lunch)
前者強(qiáng)調(diào)的是「吃」這個(gè)過程,「我」與「午飯」都只是參數(shù);后者強(qiáng)調(diào)的是「我」這個(gè)對(duì)象,「吃」只是「我」的一個(gè)動(dòng)作
對(duì)于更復(fù)雜的情況,OOP 發(fā)展出了繼承、多態(tài)這一套規(guī)則,用于抽象共有的屬性與方法,以實(shí)現(xiàn)代碼與邏輯的復(fù)用
- class People {
- void eat()
- }
- class He extends People {}
- class She extends People {}
- const he = new He()
- const she = new She()
- he.eat()
- she.eat()
可以看出,我們關(guān)注的點(diǎn)是:He 和 She 都是「人」,都具有「吃」這個(gè)共通的動(dòng)作
ECS - 三相之力
那么,換作 ECS 則如何呢?
我們首先需要有一個(gè) Entity(它可以理解為一個(gè)組件 Component 的集合,僅此而已)
- class Entity {
- components: {}
- addComponent(c: Component) {
- this.components[c.name] = component
- }
- }
然后,在 ECS 中,一個(gè) Entity 能干嘛,取決于所擁有的 Component:我們需要標(biāo)識(shí)它可以「吃」
- class Mouth {
- name: 'mouth'
- }
最后,需要引入一個(gè) System 來統(tǒng)一執(zhí)行 「吃」這個(gè)動(dòng)作
- class EatSystem {
- update(list: Entity[]) {
- list.forEach(e => e.eat)
- }
- }
OK,現(xiàn)在 E C S 三者已經(jīng)集齊,他們?nèi)绾谓M合起來運(yùn)行呢?
- function run() {
- const he = (new Entity()).addComponent(Mouth)
- const she = (new Entity()).addComponent(Mouth)
- const eatSystem = new EatSystem()
- eatSystem.update([he, she])
- }
在 ECS 中,我們關(guān)注的重點(diǎn)在于,Entity 都具有 Mouth 這個(gè) Component,那么對(duì)應(yīng)的 EatSystem 就會(huì)認(rèn)為它可以「吃」
說到這里,大家可能都要罵坑爹了:整的這么復(fù)雜,就為了實(shí)現(xiàn)上面這簡(jiǎn)單的功能?其實(shí)說的沒錯(cuò)...ECS 的引入,確實(shí)讓代碼變得更加多了,但這也正是它的核心思想所在:「組合優(yōu)于繼承」
當(dāng)然,實(shí)際的 ECS 并沒有這么簡(jiǎn)單,它需要大量的 utils 以及 輔助數(shù)據(jù)結(jié)構(gòu)來實(shí)現(xiàn) Entity、Component 的管理,比如說:
需要設(shè)計(jì)數(shù)據(jù)結(jié)構(gòu)以方便 Entity 的查詢
需要引入 Component 的狀態(tài)管理、屬性變化追蹤等機(jī)制,參考資料:
- ECS ReactiveSystem:https://www.effectiveunity.com/ecs/06-how-to-build-reactive-systems-with-unity-ecs-part-1/
- ECS 檢測(cè) Component 狀態(tài)變化: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
真正工業(yè)級(jí)的 ECS 框架還需要優(yōu)化內(nèi)存管理機(jī)制,用來加速 System 的執(zhí)行
這里比比了這么多,只是為了先給大家留下一個(gè)大概印象,具體的機(jī)制以及實(shí)現(xiàn)等內(nèi)容,后面會(huì)結(jié)合項(xiàng)目的功能以及迭代來講解 ECS 在其中的作用,這樣也更有利于理解
ECS Pros and Cons
長(zhǎng)處
- 「組合優(yōu)于繼承」:Entity 所具有的表現(xiàn),僅取決于它所擁有的 Component,這意味著完全解耦對(duì)象的屬性與方法;另外,不存在繼承關(guān)系,也就意味著不需要再為基類子類的各種問題所頭疼(eg:菱形繼承、基類修改影響所有子類...etc)
- 「數(shù)據(jù)與邏輯的完全抽離」:Entity 由 Component 組成,Component 之中只有數(shù)據(jù),沒有方法;而 System 只有方法,沒有數(shù)據(jù)。這也就意味著,我們可以簡(jiǎn)單地把當(dāng)前整個(gè)游戲的狀態(tài)生成快照,也可以簡(jiǎn)單地將快照還原到整個(gè)游戲當(dāng)中(這點(diǎn)對(duì)于多人實(shí)時(shí)網(wǎng)游而言,非常重要)
- 「表現(xiàn)與邏輯的抽離」:組件分離的方式天生適合邏輯和表現(xiàn)分離。通過一些組件來控制表現(xiàn),以此實(shí)現(xiàn)同一份代碼,同時(shí)運(yùn)行于服務(wù)端與客戶端
- 「組織方式更加友好」:真實(shí)的 ECS 中,Entity 本身僅具有 id 屬性,剩下完全由 Component 所組成,這意味著可以輕松做到游戲內(nèi)對(duì)象與數(shù)據(jù)、文檔之間的序列化、表格化轉(zhuǎn)換
不足之處「System 之間存在執(zhí)行順序上的耦合」:容易因?yàn)?System 的某些副作用行為(刪除 Entity、移除 Component)而影響到后續(xù) System 的執(zhí)行。這需要一些特殊的機(jī)制來盡量避免
- 「C 與 S 之間分離」:導(dǎo)致 S 難以跟蹤 C 的屬性變化(因?yàn)?S 中沒有任何狀態(tài);可以參考 unity 引入 SystemStateComponent / GlobalSystemVersion 等,見 「擴(kuò)展閱讀」 部分 1/2/3)
- 「邏輯內(nèi)聚,也更分散」:比如 A 對(duì) B 攻擊,傳統(tǒng) OOP 中很容易糾結(jié)傷害計(jì)算這件事情需要在 A 的方法還是 B 的方法中處理;而 ECS 中可以有專門的 System 處理這件事。但同樣的,System 也容易造成邏輯的分散,導(dǎo)致單獨(dú)看某些 System 代碼難以把握到完整的邏輯
引擎各部分
相比負(fù)責(zé)游戲邏輯的框架,引擎更多的是注重提供某一方面的功能。比如:
- 渲染引擎
- 物理引擎
- AI 引擎
- ...etc
這些引擎,每一部分都很復(fù)雜;為了省事,我們這個(gè)項(xiàng)目,將使用現(xiàn)成的渲染引擎以及現(xiàn)成的資源管理加載器(Layabox,一個(gè) JS 的 H5 游戲引擎)
這里各部分的內(nèi)容,跟游戲本身的內(nèi)容關(guān)聯(lián)比較緊密,我會(huì)在后面講到的時(shí)候詳細(xì)說明,這里就先不展開了。免得大家?guī)е嗟膯栴},影響思考
0x02 創(chuàng)世的次日
在整個(gè)游戲世界的基礎(chǔ)確定了之后,我們可以開始著手游戲的開發(fā)了。當(dāng)然,在這之前,我們需要先準(zhǔn)備一些美術(shù)方面的資源
大地與水 - Tilemap
作為一個(gè) moba 游戲,地圖設(shè)計(jì)是必不可少的。而沒有設(shè)計(jì)技能,沒有美術(shù)基礎(chǔ)的我們,要怎么才能比較輕松的將腦子里的思路轉(zhuǎn)換為對(duì)應(yīng)的素材呢?
這里我推薦一個(gè)被很多獨(dú)立游戲使用的工具:Tilemap Editor。它是一個(gè)開源且免費(fèi)的 tilemap 編輯器,非常好用;此外,整個(gè)圖形化的編輯過程也非常的簡(jiǎn)單易上手,資源也可以在網(wǎng)上比較簡(jiǎn)單的找到,這里就不贅述過多
Tilemap Editor:https://www.mapeditor.org/
如此這般,一番操作之后,我們得到了一個(gè)簡(jiǎn)單的地圖?,F(xiàn)在我們可以開始整個(gè)游戲開發(fā)的第一步了
場(chǎng)景 & 角色 - 大地創(chuàng)生
我們需要有兩個(gè) Entity,其中一個(gè)對(duì)應(yīng)場(chǎng)景 —— initArena,一個(gè)對(duì)應(yīng)我們的人物 —— 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)
- }
在把這兩個(gè) Entity 加入游戲之后,我們還需要一個(gè) System 幫助我們把它們渲染出來。我將它起名為 RenderSystem,由它專門負(fù)責(zé)所有的渲染工作(這里我們直接使用現(xiàn)成的是渲染引擎,如果大家對(duì)這方面有興趣的話,回頭也可以再做一個(gè)延伸的分享與介紹...渲染其實(shí)也是很有意思的事情并不)
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
上面的代碼,其實(shí)就是 ECS 思想的體現(xiàn):Position 儲(chǔ)存位置信息,Sprite 儲(chǔ)存渲染相關(guān)的寬高以及貼圖、軸心點(diǎn)等信息;而 RenderSystem 會(huì)在每一幀中遍歷所有具有這兩個(gè) Component 的 Entity,并渲染他們
然后,我們有了 E 與 S,還需要一個(gè)東西把它們串聯(lián)起來。這里引入了一個(gè) World 的概念,E 與 S 均是 W 里面的成員。然后 W 每一幀調(diào)用一次 update 方法,更新并推進(jìn)整個(gè)世界的狀態(tài)。這樣我們整個(gè)邏輯就能跑通了!
world.ts
- class World {
- update(dt: number) {
- this.systems.forEach(s => s.update(dt))
- }
- addSystem(system: System) {}
- addEntity(entity: Entity) {}
- addComponent(component: Component) {}
- }
萬事俱備,讓我們來運(yùn)行一下代碼:
這樣,我們創(chuàng)造游戲世界的第一步:簡(jiǎn)單的場(chǎng)景 + 角色 就渲染出來了~
輸入組件 - 賦予生命
眾所周知,游戲的核心在于交互,游戲需要根據(jù)玩家的輸入(操作)實(shí)時(shí)產(chǎn)生輸出(反饋),玩游戲的過程本質(zhì)上就是一個(gè)跟游戲互動(dòng)的過程。這也正是游戲與傳統(tǒng)藝術(shù)作品的區(qū)別:不僅僅是被動(dòng)的接受,還可以通過自己的行為,影響它的走向發(fā)展
要實(shí)現(xiàn)這點(diǎn),我們離不開輸入。對(duì)于 moba 游戲而言,比較自然的操作方式是「輪盤」。輪盤其實(shí)可以看做是虛擬搖桿:處理玩家在屏幕上的觸控操作,輸出方向信息
對(duì)于游戲而言,這個(gè)輪盤應(yīng)該只是 UI 部分,不應(yīng)該與其他游戲邏輯相關(guān)對(duì)象存在耦合。這里我們考慮引入一個(gè) UIComponent 的全局 UI 組件機(jī)制,用于處理游戲世界中的一些 UI 對(duì)象
搖桿組件 joyStick.ts
- abstract class JoyStick extends UIComponent {
- protected touchStart(e: TouchEvent)
- protected touchMove(e: TouchEvent)
- protected touchEnd(e: TouchEvent)
- }
虛擬搖桿主要的邏輯是:
其中我們需要:
- 從屏幕對(duì)應(yīng)的全局坐標(biāo)系轉(zhuǎn)換到搖桿的局部坐標(biāo)系(線性變換)
- 判斷落點(diǎn)是否在搖桿內(nèi)(點(diǎn)在圓內(nèi))
- 跟手移動(dòng)(向量縮放)
通過一些簡(jiǎn)單的向量運(yùn)算,我們可以獲取到玩家觸控所對(duì)應(yīng)的搖桿內(nèi)的點(diǎn),并實(shí)現(xiàn)搖桿的跟手交互
但是,這離讓坦克動(dòng)起來,還是有點(diǎn)差距的。我們要怎么把這個(gè)輪盤的操作轉(zhuǎn)換成小車的移動(dòng)指令呢?
事件系統(tǒng) - 控制的中樞
因?yàn)橛螒蚴且怨潭ǖ膸蔬\(yùn)行的,所以我們需要一個(gè)實(shí)時(shí)的事件系統(tǒng)來收集各種各樣的指令,等待每幀的 update 時(shí)統(tǒng)一執(zhí)行。因此我們需要引入名為 BackgroundSystem 的后臺(tái)系統(tǒng)(區(qū)別于普通系統(tǒng))來輔助處理用戶輸入、網(wǎng)絡(luò)請(qǐng)求等實(shí)時(shí)數(shù)據(jù)
BackgroundSystem.ts
- class BackgroundSystem {
- start() {}
- stop() {}
- }
它與普通 System 不同,不具有 update 方法;取而代之的是 start 與 stop。它在整個(gè)游戲開始時(shí),便會(huì)執(zhí)行 start 方法以監(jiān)聽某些事件,并在 stop 的時(shí)候移除監(jiān)聽
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')
- // 離線模式下直接把指令塞進(jìn)隊(duì)列
- if (!this.world.online) {
- queue.push(cmd)
- } else {
- // 走 socket 把指令發(fā)到服務(wù)端
- }
- }
- }
(此處留待之后做在線模式擴(kuò)展用)
注意,我們?cè)谶@里引入了「全局組件」的概念,某些 Component,比如這里的命令序列,又或者是輸入組件,它不應(yīng)該從屬于某個(gè)具體的 Entity;取而代之的,我們讓他作為整個(gè) World 之中的單例而存在,以此實(shí)現(xiàn)全局層面的數(shù)據(jù)共享
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)
- }
- }
- }
由于指令可能會(huì)非常多,因此我們需要引入一系列的 helper 來輔助該系統(tǒng)執(zhí)行命令,這并不與 ECS 的設(shè)計(jì)思路有沖突
另外,雖然為了執(zhí)行指令而引入這兩個(gè) BackgroundSystem 的行為看似麻煩,但長(zhǎng)遠(yuǎn)來看,其實(shí)是為了方便之后的擴(kuò)展~因?yàn)槎嗳擞螒驎r(shí)候,我們的操作很多時(shí)候并不能馬上被執(zhí)行,而是需要發(fā)送到服務(wù)器,由它收集排序之后返回給客戶端。這時(shí)候,客戶端才能依次執(zhí)行這序列中的指令
joyStick.ts #2
- class MoveWheel extends JoyStick {
- touchStart(e: TouchEvent) {
- const e = super.touchStart(e)
- emitter.emit(events.SEND_CMD, /* 指令數(shù)據(jù) */)
- }
- // 各種方法 ...
- }
這時(shí),我們就可以對(duì)搖桿簡(jiǎn)單擴(kuò)展,把操作事件轉(zhuǎn)換成指令交由 BackgroundSystem 去執(zhí)行了
運(yùn)動(dòng)
折騰了這么多之后,我們已經(jīng)有了移動(dòng)的指令,那么要怎么才能讓角色動(dòng)起來呢?仍然是通過 ECS 之間的配合:我們需要一個(gè)在 RunCMDSystem 中執(zhí)行指令的 helper,以及處理運(yùn)動(dòng)的
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)
- }
- }
- }
我們先獲取到移動(dòng)指令,然后根據(jù)該指令解算出速度對(duì)應(yīng)的單位向量,然后結(jié)合 Entity 對(duì)應(yīng)的 Speed 組件放縮這個(gè)向量,便是我們需要的 Velocity,同時(shí)根據(jù)速度對(duì)應(yīng)方向,可以獲取角色的朝向;
這之后,我們只需要在 MoveSystem 中做簡(jiǎn)單的向量運(yùn)算,便能計(jì)算出下一幀的角色所處位置了!
跟隨相機(jī)
雖然目前我們已經(jīng)可以實(shí)現(xiàn)全方向的自由移動(dòng)了,但是總感覺少了點(diǎn)什么...唔,我們?nèi)鄙僖粋€(gè)相機(jī)!沒有相機(jī)的話,我們只能以固定的視角觀察這個(gè)場(chǎng)景,這顯然是不合理的...
那么,所謂的相機(jī),又應(yīng)該如何實(shí)現(xiàn)呢?最常見的相機(jī),是以跟隨的形式存在的。也就是說,不管我們操控的角色如何行動(dòng),相機(jī)總會(huì)把它放在視野范圍的最中心
(換句話說,相機(jī)的實(shí)現(xiàn)本質(zhì)上就是個(gè)矩陣,用于將世界坐標(biāo)映射到相機(jī)坐標(biāo)...這個(gè)是 3D 游戲里面的邏輯,對(duì)此感興趣回頭可以再做個(gè)渲染器的實(shí)現(xiàn),展開來講...)
想清楚了這點(diǎn),其實(shí)就不難了:我們的相機(jī)的視口尺寸,與屏幕的寬高相等;然后我們這里只是一個(gè)2D 界面,從世界坐標(biāo)到相機(jī)坐標(biāo)只需要一個(gè)簡(jiǎn)單的平移變換即可:
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 之中每一幀更新一次相機(jī)的位置(重新定位相機(jī),使其以主角為中心),然后 RenderSystem 之中針對(duì)別的物體做一次平移變換即可;另外,這里還增加了相交檢測(cè),如果待渲染的物體不位于相機(jī)可見范圍之內(nèi)的話,則不作更新
這里插入視頻
0x03 地形 & 碰撞檢測(cè) / 處理
現(xiàn)在我們可以自由行走在游戲世界內(nèi)了!但是我們...嗯,目前還與缺乏一些與世界內(nèi)元素的互動(dòng)。比如不允許穿越地圖的邊界;我們繪制在地圖內(nèi)的墻壁,也應(yīng)該是不能穿越的地形...此外,可能還需要更復(fù)雜的玩法,比如河流(角色不能穿越,但是子彈可以..)沼澤(進(jìn)入減速)所以,我們下一步要做的,就是加入這一套與地形有關(guān)的交互邏輯
地形系統(tǒng)
各種各樣的地形,可以一定程度上豐富游戲的玩法與深度。我們以常見的 moba 游戲?yàn)槔?,一般?huì)包括以下幾種地形:
- 平地:即沒有任何特殊效果的地形
- 墻壁:不允許通過,可能會(huì)對(duì)視野有阻礙(Dota 中的樹林)
- 草叢:進(jìn)入之后可以隱蔽(LOL、王者)
- 高地:高地上的單位能看見同樣位于高地,或者外部地形上的單位;但外部地形上的單位無法看見高地上的單位
- ...
為了簡(jiǎn)單演示,我們這里只做一下簡(jiǎn)單的墻壁:阻礙玩家的移動(dòng),也不會(huì)被子彈摧毀。由于墻壁的貼圖已經(jīng)在編輯地圖的時(shí)候加入了,我們目前需要做的只有
- 加入墻壁對(duì)應(yīng)的 Entity
- 每幀檢測(cè)玩家的位置,接觸到墻壁的時(shí)候不允許移動(dòng)
為了實(shí)現(xiàn)這個(gè)玩法,我們需要引入專門檢測(cè)并處理碰撞的 System
「Attention」:下面這里的碰撞相關(guān)邏輯,其實(shí)不應(yīng)該直接放在 system 內(nèi),而是應(yīng)該抽象出一個(gè)單獨(dú)的,類似渲染引擎那樣的物理引擎,然后才是在 system 中每幀調(diào)用
碰撞檢測(cè) / 處理
首先,讓我們從最簡(jiǎn)單的情況開始:矩形與矩形之間的碰撞。由于我們使用了 Tilemap ,這導(dǎo)致我們的碰撞檢測(cè)情況比較簡(jiǎn)單:兩個(gè)水平和垂直方向上對(duì)稱矩形碰撞
這里并不會(huì)展開來講太多關(guān)于數(shù)學(xué)上的東西,具體可以參考一個(gè)簡(jiǎn)單的幾何庫 rect.ts參考:https://aotu.io/notes/2017/02/16/2d-collision-detection/index.html
rect.ts
相交判定部分..具體規(guī)律(比如 rect1.topLeft.x 總是小于 rect2.topRight.x etc...)可以對(duì)照上圖找
- 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
有了相交判定方法之后,我們就能簡(jiǎn)單的實(shí)現(xiàn)一個(gè)碰撞檢測(cè)系統(tǒng)了
- 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)
- }
- }
- }
- }
我們這里采用了比較簡(jiǎn)單的兩重循環(huán)暴力遍歷,但還是盡可能的去降低運(yùn)算量:
- 沒有 Velocity 的 Entity 不會(huì)動(dòng),因此第一重循環(huán)不需要考慮他們
- 使用兩層字典,避免重復(fù)運(yùn)算已經(jīng)判定過的物體
然后,我們便可以根據(jù)這個(gè)檢測(cè)到的碰撞信息,進(jìn)行下一步的碰撞處理
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')
- }
- }
- }
這里我們做了一個(gè) handler 的字典,因?yàn)榕鲎蔡幚硐到y(tǒng)也需要大量的 helper 來輔助處理各種物體之間碰撞的情況(比如目前僅有 「角色與墻壁」,之后會(huì)引入更多的地形,以及更多的 Entity),之后就可以方便擴(kuò)展
最后,我們只需要往世界里面加入幾個(gè)空氣墻對(duì)應(yīng)的 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 文件內(nèi)
展示一下...
攻擊 & 子彈
ok,在引入了碰撞檢測(cè)與處理的系統(tǒng)之后,是時(shí)候更進(jìn)一步引入攻擊系統(tǒng)了。首先,我們要設(shè)計(jì)一個(gè)攻擊模式:
- 使用輪盤搓方向,這樣可以支持 360° 射擊
- 攻擊之間存在間隔
先加入一個(gè)輪盤:它只關(guān)心滑動(dòng)結(jié)束時(shí)候的方向,并根據(jù)該方向生成一個(gè)攻擊指令:
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
- }
- }
但是在新加了這個(gè)輪盤之后,我們會(huì)很驚喜的遇到一個(gè)新問題:全局的觸摸事件沖突了...回想一下,我們的 addEventListener 是直接往 document 上面添加的監(jiān)聽方法,因此每一個(gè)觸摸事件,都會(huì)觸發(fā)兩個(gè)輪盤的 handler。這里我們引入一個(gè)變量 identifier 用于解決這個(gè)問題
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)
- }
- }
- }
我們根據(jù)攻擊指令的發(fā)起 id,獲取對(duì)應(yīng) Entity 的 Attack Component,它里面包含了關(guān)于攻擊的信息(傷害、間隔、子彈...),并為對(duì)應(yīng)對(duì)象增加一個(gè) Attacking Component 用以指示狀態(tài)
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 會(huì)遍歷所有具有 Attacking 的對(duì)象,并根據(jù)它的一系列信息生成一個(gè)子彈。然后這個(gè)子彈會(huì)在 MoveSystem 中不斷地按照發(fā)射方向移動(dòng)
攻擊判定 & Entity 的銷毀
當(dāng)然,上面這個(gè)無限射程的子彈,其實(shí)并不是我們所希望的;同時(shí),子彈在打到障礙物的時(shí)候也不應(yīng)該穿透過去。這里我們稍微修改一下原有的系統(tǒng),使得子彈在擊中敵人或者墻壁時(shí)消失:
moveSystem #2
- // 增加以下代碼
- if (entity.has('bullet')) {
- const { range, origin } = entity.getComponent<Bullet>('bullet')
- if (range * range < position.distance2(origin)) {
- entity.addComponent('destroy')
- }
- }
超出了射程范圍的子彈,應(yīng)該被移除... 其實(shí)這個(gè)邏輯,應(yīng)該另外再加一個(gè) BulletSystem 之類的系統(tǒng)用于處理的,這里我偷懶了...我們會(huì)給超出了射程范圍的子彈加一個(gè) Destroy 的標(biāo)記,之后銷毀它。原因在下面的 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')
- }
與障礙物/角色碰撞的子彈,也需要移除。但是忽略子彈與自身的碰撞(因?yàn)樽訌検菑慕巧?dāng)前位置被發(fā)射出去的)
destroySystem.ts
- class DestroySystem extends System {
- update() {
- const entities = this.getEntities('destroy')
- for (const i in entities) {
- this.world.removeEntity(entities[i])
- }
- }
- }
這里做的還比較簡(jiǎn)單,如果是完整的實(shí)現(xiàn),還可以補(bǔ)充上子彈銷毀時(shí)候的「爆炸動(dòng)畫效果」。我們可以借助 ECS 中的 Entity 上面的 removeFromWorld 回調(diào)實(shí)現(xiàn)之
*ps
:這里的 DestroySystem 執(zhí)行順序應(yīng)該位于所有 System 之后。這也是 ECS 應(yīng)該遵循的設(shè)計(jì):推遲所有會(huì)影響其他 System 的行為,放在最后統(tǒng)一執(zhí)行
**
pps
:這里可以再增加一個(gè)池化的機(jī)制,減少子彈這類需要反復(fù)創(chuàng)建/銷毀的對(duì)象的維護(hù)開銷
AI 的引入
到目前為止,我們已經(jīng)有一個(gè)比較完整的地圖,以及可自由移動(dòng)、攻擊的角色。但只有一個(gè)角色,游戲是玩不起來的,下一步我們就需要往游戲內(nèi)加入一個(gè)個(gè)的 AI 角色
我們將隨機(jī)生成 Position (x, y) 的位置,如果該位置對(duì)應(yīng)的是空地,那么則把 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 角色,他們都莫得靈魂!
在我們創(chuàng)造 AI 角色之后,下一步就需要給他們賦予生命,讓他們能夠移動(dòng),能夠攻擊,甚至給他們更加真實(shí)的一些反應(yīng),比如挨打了會(huì)逃跑,會(huì)追殺玩家...etc。要實(shí)現(xiàn)這樣的 AI,讓我們先來了解一下游戲 AI 的一種比較常用的實(shí)現(xiàn)方式——決策樹(或者叫 行為樹)
行為樹
整個(gè)行為樹,由一系列的節(jié)點(diǎn)所組成,每個(gè)節(jié)點(diǎn)都具有一個(gè) execute 方法,它返回一個(gè) boolean,我們將根據(jù)這個(gè)返回值來決定下一步的動(dòng)作。節(jié)點(diǎn)可以分為以下幾類:
- 選擇節(jié)點(diǎn):執(zhí)行所有子節(jié)點(diǎn),當(dāng)遇到第一個(gè)為 true 的返回值時(shí)結(jié)束
- 順序節(jié)點(diǎn):執(zhí)行所有子節(jié)點(diǎn),當(dāng)遇到第一個(gè)為 false 的返回值時(shí)結(jié)束
- 條件節(jié)點(diǎn):一般用來作為葉子節(jié)點(diǎn)與順序節(jié)點(diǎn)、行為節(jié)點(diǎn)組合,實(shí)現(xiàn)條件執(zhí)行動(dòng)作的功能
- 行為節(jié)點(diǎn):具體執(zhí)行動(dòng)作的節(jié)點(diǎn),比如移動(dòng)、攻擊...etc
更具體的解釋可參考 https://www.cnblogs.com/KillerAery/p/10007887.html
tankTree.ts
這里我們構(gòu)建了幾個(gè) AI 最基本的動(dòng)作,作為葉子節(jié)點(diǎn)
- 移動(dòng)
- 索敵
- 攻擊
省略了大部分邏輯相關(guān)代碼,具體可見 systems/ai 目錄下相關(guān)文件
- class RandomMovingNode extends ActionNode {
- execute() {
- // 尋路...
- return true
- }
- }
- class SearchNode extends ConditionNode {
- condiction() {
- // 檢測(cè)范圍內(nèi)是否存在敵人
- }
- }
- class AttackNode extends ActionNode {
- execute() {
- // 向敵人發(fā)起攻擊
- 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)
- )
- )
- }
- }
在這幾個(gè)基礎(chǔ)的葉子節(jié)點(diǎn)上,搭配上文提到的 并行、順序 等節(jié)點(diǎn),就可以組成一棵簡(jiǎn)單的 AI 行為樹:AI 一邊隨機(jī)移動(dòng),一邊搜索當(dāng)前范圍內(nèi)是否存在敵人
然后我們把行為樹附加到 AI 角色身上,他們就可以動(dòng)起來了!
運(yùn)行展示一下...
0x04 總結(jié)
到這里,我們已經(jīng)做出來一個(gè)簡(jiǎn)單的游戲了!第一部分的內(nèi)容,到這里就暫告一段落了?;仡櫼幌?,在這部分里面,我們:
- 實(shí)現(xiàn)了一套邏輯層相關(guān)的 ECS 框架,用于管理復(fù)雜的游戲?qū)ο蟮母陆换ミ壿?/li>
- 實(shí)現(xiàn)了簡(jiǎn)單的事件系統(tǒng),以及 UI 組件相關(guān)邏輯
- 簡(jiǎn)單實(shí)現(xiàn)了游戲中的大部分邏輯:移動(dòng)、攻擊、相機(jī)跟隨...
當(dāng)然,它也還差一些未完成的部分:
- 多人游戲支持
- 游戲選單(Game Menu):包括重新開始、退出游戲等
- 更豐富的玩法:比如守家 / 占點(diǎn) / 奪旗...多種模式
- 更多的游戲元素:技能、升級(jí)成長(zhǎng)、地形...
- ...
這只是一個(gè)作為教程的示例,并不能做到盡善盡美,但還是希望大家能在整個(gè)分享里面,對(duì)「如何從零開始做一個(gè)游戲」這件事,有一個(gè)或多或少的認(rèn)知。如果能讓大家感覺到,「做一個(gè)游戲,其實(shí)很簡(jiǎn)單」 的話,那今天的分享就算是成功了~
說起來...后面如果有時(shí)間,可以把這些點(diǎn)都補(bǔ)充上去,實(shí)際上,都還挺有趣的..