成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

一看就懂 - 從零開始的游戲開發

開發 前端 游戲開發
對于開發而言,了解一下如何從零開始做游戲是一個非常有趣且有益的過程(并不)。這里我先以大家對游戲開發一無所知作為前提,以一個簡單的游戲開發作為.

[[442973]]

0x00 寫在最前面

對于開發而言,了解一下如何從零開始做游戲是一個非常有趣且有益的過程(并不)。這里我先以大家對游戲開發一無所知作為前提,以一個簡單的游戲開發作為🌰,跟大家一起從零開始做一個游戲,淺入淺出地了解一下游戲的開發

此外,諸君如果有游戲制作方面的經驗,也希望能不吝賜教,畢竟互相交流學習,進步更快~

這次的分享,主要有幾個點:

  1. Entity Component System 思想,以及它在游戲開發中能起的作用(important!)
  2. 一個簡單的 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: 「我吃午飯」

  1. // Procedural Programming 
  2. eat(me, lunch) 
  3. // OOP 
  4. me.eat(lunch) 

前者強調的是「吃」這個過程,「我」與「午飯」都只是參數;后者強調的是「我」這個對象,「吃」只是「我」的一個動作

對于更復雜的情況,OOP 發展出了繼承、多態這一套規則,用于抽象共有的屬性與方法,以實現代碼與邏輯的復用

  1. class People { 
  2.   void eat() 
  3. class He extends People {} 
  4. class She extends People {} 
  5.  
  6. const he = new He() 
  7. const she = new She() 
  8. he.eat() 
  9. she.eat() 

可以看出,我們關注的點是:He 和 She 都是「人」,都具有「吃」這個共通的動作

ECS - 三相之力

那么,換作 ECS 則如何呢?

我們首先需要有一個 Entity(它可以理解為一個組件 Component 的集合,僅此而已)

  1. class Entity { 
  2.   components: {} 
  3.   addComponent(c: Component) { 
  4.     this.components[c.name] = component 
  5.   } 

然后,在 ECS 中,一個 Entity 能干嘛,取決于所擁有的 Component:我們需要標識它可以「吃」

  1. class Mouth { 
  2.   name'mouth' 

最后,需要引入一個 System 來統一執行 「吃」這個動作

  1. class EatSystem { 
  2.   update(list: Entity[]) { 
  3.     list.forEach(e => e.eat) 
  4.   } 

OK,現在 E C S 三者已經集齊,他們如何組合起來運行呢?

  1. function run() { 
  2.   const he = (new Entity()).addComponent(Mouth) 
  3.   const she = (new Entity()).addComponent(Mouth) 
  4.  
  5.   const eatSystem = new EatSystem() 
  6.   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

  1. function initArena() { 
  2.   const arena = new Entity() 
  3.   world.addEntity( 
  4.     arena 
  5.       .addComponent<Position>('position', { x: 0, y: 0 }) 
  6.       .addComponent<RectangularSprite>('sprite', { 
  7.         width, 
  8.         height, 
  9.         texture: resource 
  10.       }) 
  11.   ) 

initPlayer.ts

  1. function initPlayer() { 
  2.   const player = new Entity() 
  3.   player 
  4.     .addComponent('player'
  5.     .addComponent<Position>('position', new Point(64 * 7, 64 * 7)) 
  6.     .addComponent<RectangularSprite>('sprite', { 
  7.       pivot: { x: 32, y: 32 }, 
  8.       width: 64, 
  9.       height: 64, 
  10.       texture: ASSETS.PIXEL_TANK 
  11.     }) 
  12.  
  13.   world.addEntity(player) 

在把這兩個 Entity 加入游戲之后,我們還需要一個 System 幫助我們把它們渲染出來。我將它起名為 RenderSystem,由它專門負責所有的渲染工作(這里我們直接使用現成的是渲染引擎,如果大家對這方面有興趣的話,回頭也可以再做一個延伸的分享與介紹...渲染其實也是很有意思的事情并不)

renderSystem.ts

  1. class RenderSystem extends System { 
  2.   update() { 
  3.     const entities = this.getEntities('position''sprite'
  4.  
  5.     for (const i in entities) { 
  6.       const entity = entities[i] 
  7.       const position = new Point(entity.getComponent<Position>('position')) 
  8.       const sprite = entity.getComponent<RectangularSprite>('sprite'
  9.  
  10.       if (!sprite.layaSprite) { 
  11.         // init laya sprite... ignore 
  12.       } 
  13.  
  14.       const { layaSprite } = sprite 
  15.       const { x, y } = position 
  16.       layaSprite.pos(x, y) 
  17.     } 
  18.   } 

Position & Sprite

上面的代碼,其實就是 ECS 思想的體現:Position 儲存位置信息,Sprite 儲存渲染相關的寬高以及貼圖、軸心點等信息;而 RenderSystem 會在每一幀中遍歷所有具有這兩個 Component 的 Entity,并渲染他們

然后,我們有了 E 與 S,還需要一個東西把它們串聯起來。這里引入了一個 World 的概念,E 與 S 均是 W 里面的成員。然后 W 每一幀調用一次 update 方法,更新并推進整個世界的狀態。這樣我們整個邏輯就能跑通了!

world.ts

  1. class World { 
  2.   update(dt: number) { 
  3.     this.systems.forEach(s => s.update(dt)) 
  4.   } 
  5.  
  6.   addSystem(system: System) {} 
  7.   addEntity(entity: Entity) {} 
  8.   addComponent(component: Component) {} 

萬事俱備,讓我們來運行一下代碼:

這樣,我們創造游戲世界的第一步:簡單的場景 + 角色 就渲染出來了~

輸入組件 - 賦予生命

眾所周知,游戲的核心在于交互,游戲需要根據玩家的輸入(操作)實時產生輸出(反饋),玩游戲的過程本質上就是一個跟游戲互動的過程。這也正是游戲與傳統藝術作品的區別:不僅僅是被動的接受,還可以通過自己的行為,影響它的走向發展

要實現這點,我們離不開輸入。對于 moba 游戲而言,比較自然的操作方式是「輪盤」。輪盤其實可以看做是虛擬搖桿:處理玩家在屏幕上的觸控操作,輸出方向信息

對于游戲而言,這個輪盤應該只是 UI 部分,不應該與其他游戲邏輯相關對象存在耦合。這里我們考慮引入一個 UIComponent 的全局 UI 組件機制,用于處理游戲世界中的一些 UI 對象

搖桿組件 joyStick.ts

  1. abstract class JoyStick extends UIComponent { 
  2.   protected touchStart(e: TouchEvent) 
  3.   protected touchMove(e: TouchEvent) 
  4.   protected touchEnd(e: TouchEvent) 

虛擬搖桿主要的邏輯是:

其中我們需要:

  • 從屏幕對應的全局坐標系轉換到搖桿的局部坐標系(線性變換)
  • 判斷落點是否在搖桿內(點在圓內)
  • 跟手移動(向量縮放)

通過一些簡單的向量運算,我們可以獲取到玩家觸控所對應的搖桿內的點,并實現搖桿的跟手交互

但是,這離讓坦克動起來,還是有點差距的。我們要怎么把這個輪盤的操作轉換成小車的移動指令呢?

事件系統 - 控制的中樞

因為游戲是以固定的幀率運行的,所以我們需要一個實時的事件系統來收集各種各樣的指令,等待每幀的 update 時統一執行。因此我們需要引入名為 BackgroundSystem 的后臺系統(區別于普通系統)來輔助處理用戶輸入、網絡請求等實時數據

BackgroundSystem.ts

  1. class BackgroundSystem { 
  2.   start() {} 
  3.   stop() {} 

它與普通 System 不同,不具有 update 方法;取而代之的是 start 與 stop。它在整個游戲開始時,便會執行 start 方法以監聽某些事件,并在 stop 的時候移除監聽

SendCMDSystem.ts

  1. class SendCMDSystem extends BackgroundSystem { 
  2.   start() { 
  3.     emitter.on(events.SEND_CMD, this.sendCMD) 
  4.   } 
  5.   stop() { 
  6.     emitter.off(events.SEND_CMD, this.sendCMD) 
  7.   } 
  8.  
  9.   sendCMD(cmd: any) { 
  10.     const queue: any[] = this.world.getComponent('cmdQueue'
  11.     // 離線模式下直接把指令塞進隊列 
  12.     if (!this.world.online) { 
  13.       queue.push(cmd) 
  14.     } else { 
  15.       // 走 socket 把指令發到服務端 
  16.     } 
  17.   } 

(此處留待之后做在線模式擴展用)

注意,我們在這里引入了「全局組件」的概念,某些 Component,比如這里的命令序列,又或者是輸入組件,它不應該從屬于某個具體的 Entity;取而代之的,我們讓他作為整個 World 之中的單例而存在,以此實現全局層面的數據共享

RunCMDSystem.ts

  1. class RunCMDSystem extends BackgroundSystem { 
  2.   start() { 
  3.     emitter.on(events.RUN_CMD, this.runCMD) 
  4.   } 
  5.   stop() { 
  6.     emitter.off(events.RUN_CMD, this.runCMD) 
  7.   } 
  8.  
  9.   runCMD() { 
  10.     const queue: any[] = this.world.getComponent('cmdQueue'
  11.  
  12.     queue.forEach(this.handleCMD) 
  13.   } 
  14.  
  15.   handleCMD(cmd: any) { 
  16.     const type: Command = cmd.type 
  17.     const handler: CMDHandler = CMD_HANDLER[type] 
  18.     if (handler) { 
  19.       handler(cmd, this.world) 
  20.     } 
  21.   } 

由于指令可能會非常多,因此我們需要引入一系列的 helper 來輔助該系統執行命令,這并不與 ECS 的設計思路有沖突

另外,雖然為了執行指令而引入這兩個 BackgroundSystem 的行為看似麻煩,但長遠來看,其實是為了方便之后的擴展~因為多人游戲時候,我們的操作很多時候并不能馬上被執行,而是需要發送到服務器,由它收集排序之后返回給客戶端。這時候,客戶端才能依次執行這序列中的指令

joyStick.ts #2

  1. class MoveWheel extends JoyStick { 
  2.   touchStart(e: TouchEvent) { 
  3.     const e = super.touchStart(e) 
  4.     emitter.emit(events.SEND_CMD, /* 指令數據 */) 
  5.   } 
  6.   // 各種方法 ... 

這時,我們就可以對搖桿簡單擴展,把操作事件轉換成指令交由 BackgroundSystem 去執行了

運動

折騰了這么多之后,我們已經有了移動的指令,那么要怎么才能讓角色動起來呢?仍然是通過 ECS 之間的配合:我們需要一個在 RunCMDSystem 中執行指令的 helper,以及處理運動的

MoveSystemplayerCMD.ts

  1. function moveHandler(cmd: MoveCMD, world: World) { 
  2.   const { data, id } = cmd 
  3.   const entity = world.getEntityById(id) 
  4.   if (entity) { 
  5.     const { speed } = entity.components 
  6.     const velocity = new Point(data.point).normalize().scale(speed) 
  7.     const degree = (Math.atan2(velocity.y, velocity.x) / Math.PI) * 180 
  8.     entity 
  9.       .addComponent('velocity', velocity) 
  10.       .addComponent('orientation', degree > 0 ? degree - 360 : degree + 360) 
  11.   } 

moveSystem.ts

  1. class MoveSystem extends System { 
  2.   update(dt: number) { 
  3.     const entities = this.getEntities('velocity'
  4.     for (const i in entities) { 
  5.       const entity = entities[i] 
  6.  
  7.       const position = entity.getComponent<Point>('position'
  8.       const velocity = entity.getComponent<Velocity>('velocity'
  9.       position.addSelf(velocity * dt) 
  10.     } 
  11.   } 

我們先獲取到移動指令,然后根據該指令解算出速度對應的單位向量,然后結合 Entity 對應的 Speed 組件放縮這個向量,便是我們需要的 Velocity,同時根據速度對應方向,可以獲取角色的朝向;

這之后,我們只需要在 MoveSystem 中做簡單的向量運算,便能計算出下一幀的角色所處位置了!

跟隨相機

雖然目前我們已經可以實現全方向的自由移動了,但是總感覺少了點什么...唔,我們缺少一個相機!沒有相機的話,我們只能以固定的視角觀察這個場景,這顯然是不合理的...

那么,所謂的相機,又應該如何實現呢?最常見的相機,是以跟隨的形式存在的。也就是說,不管我們操控的角色如何行動,相機總會把它放在視野范圍的最中心

(換句話說,相機的實現本質上就是個矩陣,用于將世界坐標映射到相機坐標...這個是 3D 游戲里面的邏輯,對此感興趣回頭可以再做個渲染器的實現,展開來講...)

想清楚了這點,其實就不難了:我們的相機的視口尺寸,與屏幕的寬高相等;然后我們這里只是一個2D 界面,從世界坐標到相機坐標只需要一個簡單的平移變換即可:

cameraSystem.ts

  1. class CameraSystem extends System { 
  2.   start() { 
  3.     this.updateCamera() 
  4.   } 
  5.   update() { 
  6.     this.updateCamera() 
  7.   } 
  8.  
  9.   updateCamera() { 
  10.     const camera = this.world.getComponent('camera'as Rect 
  11.     const me = this.world.getEntityById(this.world.userId) 
  12.     if (me) { 
  13.       const position = me.getComponent('position'as Position 
  14.       camera.pos(position.x - camera.w / 2, position.y - camera.h / 2) 
  15.     } 
  16.   } 

renderSystem.ts

  1. class RenderSystem extends System { 
  2.   update() { 
  3.     const camera = this.world.getComponent('camera'as Rect 
  4.  
  5.     for (const i in entities) { 
  6.       // ignore other code... 
  7.       const position = new Point(entity.getComponent<Position>('position')) 
  8.       const sprite = entity.getComponent<RectangularSprite>('sprite'
  9.       // 不在可見范圍 就不更新了 
  10.       if ( 
  11.         !camera.intersection({ 
  12.           x: position.x, 
  13.           y: position.y, 
  14.           w: sprite.width, 
  15.           h: sprite.height 
  16.         }) 
  17.       ) { 
  18.         continue 
  19.       } 
  20.       position.subSelf(camera.topLeft) 
  21.     } 
  22.   } 

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...)可以對照上圖找

  1. class Rect { 
  2.   intersection(rect: Rect) { 
  3.     return ( 
  4.       this._x < rect.x + rect.w && 
  5.       this._x + this._w > rect.x && 
  6.       this._y < rect.y + rect.h && 
  7.       this._y + this._h > rect.y 
  8.     ) 
  9.   } 

collisionTestSystem.ts

有了相交判定方法之后,我們就能簡單的實現一個碰撞檢測系統了

  1. class CollisionTestSystem extends System { 
  2.   update() { 
  3.     const entities = this.world.getEntities('collider''velocity'
  4.     const allEntities = this.world.getEntities('collider'
  5.  
  6.     const map: { [key: number]: { [key: number]: boolean } } = {} 
  7.  
  8.     for (let i in entities) { 
  9.       const entityA = entities[i] 
  10.       const colliderA = entityToRect(entityA, true
  11.       const colliders: Entity[] = [] 
  12.  
  13.       map[i] = {} 
  14.       for (let j in allEntities) { 
  15.         if (i === j) { 
  16.           continue 
  17.         } 
  18.  
  19.         map[j] || (map[j] = {}) 
  20.         if (map[i][j] || map[j][i]) { 
  21.           continue 
  22.         } 
  23.         map[i][j] = map[j][i] = true 
  24.  
  25.         const entityB = allEntities[j] 
  26.         const colliderB = entityToRect(entityB) 
  27.         if (colliderA.intersection(colliderB)) { 
  28.           colliders.push(entityB) 
  29.         } 
  30.       } 
  31.  
  32.       if (colliders.length) { 
  33.         entityA.addComponent<Entity[]>('colliders', colliders) 
  34.       } 
  35.     } 
  36.   } 

我們這里采用了比較簡單的兩重循環暴力遍歷,但還是盡可能的去降低運算量:

  • 沒有 Velocity 的 Entity 不會動,因此第一重循環不需要考慮他們
  • 使用兩層字典,避免重復運算已經判定過的物體

然后,我們便可以根據這個檢測到的碰撞信息,進行下一步的碰撞處理

collisionHandleSystem.ts

  1. class CollisionHandleSystem extends System { 
  2.   update() { 
  3.     const entities = this.world.getEntities('colliders''velocity'
  4.  
  5.     for (const i in entities) { 
  6.       const entity = entities[i] 
  7.       const colliders = entity.getComponent<Entity[]>('colliders'
  8.  
  9.       const typeA = entity.getComponent<Collider>('collider').type 
  10.  
  11.       colliders.forEach(e => { 
  12.         const typeB = e.getComponent<Collider>('collider').type 
  13.         const handler = handlerMap[typeA][typeB] 
  14.         if (handler) { 
  15.           handler(entity, e, this.world) 
  16.         } 
  17.       }) 
  18.       entity.removeComponent('colliders'
  19.     } 
  20.   } 

這里我們做了一個 handler 的字典,因為碰撞處理系統也需要大量的 helper 來輔助處理各種物體之間碰撞的情況(比如目前僅有 「角色與墻壁」,之后會引入更多的地形,以及更多的 Entity),之后就可以方便擴展

最后,我們只需要往世界里面加入幾個空氣墻對應的 Entity 即可:

initArena.ts

  1. [topright, bottom, left].forEach((e: Rect) => { 
  2.   const { x, y, w, h } = e 
  3.  
  4.   world.addEntity( 
  5.     new Entity() 
  6.       .addComponent<Position>('position', { 
  7.         x, 
  8.         y 
  9.       }) 
  10.       .addComponent<Collider>('collider', { 
  11.         width: w, 
  12.         height: h, 
  13.         type: ColliderType.Obstacle 
  14.       }) 
  15.   ) 
  16. }) 

同理,墻壁也可以這樣加入到我們的游戲世界中,具體代碼就不貼了,同樣在 initArena.ts 文件內

展示一下...

攻擊 & 子彈

ok,在引入了碰撞檢測與處理的系統之后,是時候更進一步引入攻擊系統了。首先,我們要設計一個攻擊模式:

  • 使用輪盤搓方向,這樣可以支持 360° 射擊
  • 攻擊之間存在間隔

先加入一個輪盤:它只關心滑動結束時候的方向,并根據該方向生成一個攻擊指令:

joyStick.ts

  1. class AttackWheel extends JoyStick { 
  2.   constructor(params: JoyStickParams) { 
  3.     super(params) 
  4.   } 
  5.  
  6.   touchEnd(e: TouchEvent): undefined { 
  7.     const event = super.touchEnd(e) 
  8.  
  9.     emitter.emit(events.SEND_CMD, { 
  10.       type: Command.Attack, 
  11.       ...event 
  12.     }) 
  13.  
  14.     return undefined 
  15.   } 

但是在新加了這個輪盤之后,我們會很驚喜的遇到一個新問題:全局的觸摸事件沖突了...回想一下,我們的 addEventListener 是直接往 document 上面添加的監聽方法,因此每一個觸摸事件,都會觸發兩個輪盤的 handler。這里我們引入一個變量 identifier 用于解決這個問題

joystick.ts #4

  1. class JoyStick extends UIComponent { 
  2.   touchMove(e: TouchEvent): Event | undefined { 
  3.     // ignore ... 
  4.     const point = this.getPointInWheel(changedTouches[0]) 
  5.     if (this.identifier === changedTouches[0].identifier) { 
  6.       // ignore ... 
  7.     } 
  8.     return undefined 
  9.   } 

指令有了,再加入攻擊指令的處理方法:

playerCMD.ts #2

  1. function attackHandler(cmd: AttackCMD, world: World) { 
  2.   const { id, data, ts } = cmd 
  3.   const entity = world.getEntityById(id) 
  4.  
  5.   if (entity) { 
  6.     const attackConfig = entity.getComponent<Attack>('attack'
  7.     const lastAttackTS = entity.getComponent<number>('lastAttack') || 0 
  8.  
  9.     if (attackConfig.cooldown < ts - lastAttackTS) { 
  10.       entity.addComponent('attacking', data.point) 
  11.       entity.addComponent('lastAttackTS', ts) 
  12.     } 
  13.   } 

我們根據攻擊指令的發起 id,獲取對應 Entity 的 Attack Component,它里面包含了關于攻擊的信息(傷害、間隔、子彈...),并為對應對象增加一個 Attacking Component 用以指示狀態

attackSystem.ts

  1. class AttackSystem extends System { 
  2.   update() { 
  3.     const entities = this.getEntities('attacking'
  4.  
  5.     for (const i in entities) { 
  6.       const entity = entities[i] 
  7.  
  8.       const position = entity.getComponent<Point>('position').clone 
  9.  
  10.       const attackingDirection = entity.getComponent<Point>('attacking'
  11.       const attackConfig = entity.getComponent<Attack>('attack'
  12.  
  13.       const velocity = attackingDirection.normalize() 
  14.  
  15.       const { width, height } = attackConfig.bullet 
  16.       position.addSelf(width / 2, height / 2) 
  17.       velocity.scaleSelf(attackConfig.speed) 
  18.  
  19.       const bullet = new Entity() 
  20.       bullet 
  21.         .addComponent<Bullet>('bullet', { /* ... */ }) 
  22.         .addComponent<Point>('position', position) 
  23.         .addComponent<Point>('velocity', velocity) 
  24.         .addComponent<RectangularSprite>('sprite', {  /* ... */  }) 
  25.         .addComponent<Collider>('collider', {  /* ... */  }) 
  26.  
  27.       this.world.addEntity(bullet) 
  28.       entity.removeComponent('attacking'
  29.     } 
  30.   } 

AttackSystem 會遍歷所有具有 Attacking 的對象,并根據它的一系列信息生成一個子彈。然后這個子彈會在 MoveSystem 中不斷地按照發射方向移動

攻擊判定 & Entity 的銷毀

當然,上面這個無限射程的子彈,其實并不是我們所希望的;同時,子彈在打到障礙物的時候也不應該穿透過去。這里我們稍微修改一下原有的系統,使得子彈在擊中敵人或者墻壁時消失:

moveSystem #2

  1. // 增加以下代碼 
  2. if (entity.has('bullet')) { 
  3.   const { range, origin } = entity.getComponent<Bullet>('bullet'
  4.   if (range * range < position.distance2(origin)) { 
  5.     entity.addComponent('destroy'
  6.   } 

超出了射程范圍的子彈,應該被移除... 其實這個邏輯,應該另外再加一個 BulletSystem 之類的系統用于處理的,這里我偷懶了...我們會給超出了射程范圍的子彈加一個 Destroy 的標記,之后銷毀它。原因在下面的 DestroySystem 處有提到

creatureBullet.ts

  1. function creatureBullet( 
  2.   entityA: Entity, 
  3.   entityB: Entity, 
  4.   world: World 
  5. ) { 
  6.   const aIsBullet = 
  7.     entityA.getComponent<Collider>('collider').type === ColliderType.Bullet 
  8.  
  9.   const bullet = aIsBullet ? entityA : entityB 
  10.   const creature = aIsBullet ? entityB : entityA 
  11.  
  12.   const { generator: generatorID } = bullet.getComponent<Bullet>('bullet'
  13.   if (generatorID === creature.id) { 
  14.     return 
  15.   } 
  16.   bullet.addComponent('destroy'

與障礙物/角色碰撞的子彈,也需要移除。但是忽略子彈與自身的碰撞(因為子彈是從角色當前位置被發射出去的)

destroySystem.ts

  1. class DestroySystem extends System { 
  2.   update() { 
  3.     const entities = this.getEntities('destroy'
  4.     for (const i in entities) { 
  5.       this.world.removeEntity(entities[i]) 
  6.     } 
  7.   } 

這里做的還比較簡單,如果是完整的實現,還可以補充上子彈銷毀時候的「爆炸動畫效果」。我們可以借助 ECS 中的 Entity 上面的 removeFromWorld 回調實現之

*ps

:這里的 DestroySystem 執行順序應該位于所有 System 之后。這也是 ECS 應該遵循的設計:推遲所有會影響其他 System 的行為,放在最后統一執行

**

pps

:這里可以再增加一個池化的機制,減少子彈這類需要反復創建/銷毀的對象的維護開銷

AI 的引入

到目前為止,我們已經有一個比較完整的地圖,以及可自由移動、攻擊的角色。但只有一個角色,游戲是玩不起來的,下一步我們就需要往游戲內加入一個個的 AI 角色

我們將隨機生成 Position (x, y) 的位置,如果該位置對應的是空地,那么則把 AI 玩家放置在此處

initPlayer.ts # 2

  1. function initAI(world: World, arena: TransformedArena) { 
  2.   for (let i = 0; i < count; i++) { 
  3.     let x, y 
  4.     do { 
  5.       x = random(leftright
  6.       y = random(top, bottom) 
  7.     } while (tilemap[x + y * width] !== -1) 
  8.  
  9.     const enemy = generatePlayer({ 
  10.       player: true
  11.       creature: true
  12.       position: new Point(cellPixel * x, cellPixel * y), 
  13.       collider: { /* ... */ }, 
  14.       speed, 
  15.       sprite: { /* ... */ }, 
  16.       hp: 1 
  17.     }) 
  18.  
  19.     world.addEntity(enemy) 
  20.   } 

但是,這些 AI 角色,他們都莫得靈魂!

在我們創造 AI 角色之后,下一步就需要給他們賦予生命,讓他們能夠移動,能夠攻擊,甚至給他們更加真實的一些反應,比如挨打了會逃跑,會追殺玩家...etc。要實現這樣的 AI,讓我們先來了解一下游戲 AI 的一種比較常用的實現方式——決策樹(或者叫 行為樹)

行為樹

整個行為樹,由一系列的節點所組成,每個節點都具有一個 execute 方法,它返回一個 boolean,我們將根據這個返回值來決定下一步的動作。節點可以分為以下幾類:

  • 選擇節點:執行所有子節點,當遇到第一個為 true 的返回值時結束
  • 順序節點:執行所有子節點,當遇到第一個為 false 的返回值時結束
  • 條件節點:一般用來作為葉子節點與順序節點、行為節點組合,實現條件執行動作的功能
  • 行為節點:具體執行動作的節點,比如移動、攻擊...etc

更具體的解釋可參考 https://www.cnblogs.com/KillerAery/p/10007887.html

tankTree.ts

這里我們構建了幾個 AI 最基本的動作,作為葉子節點

  • 移動
  • 索敵
  • 攻擊

省略了大部分邏輯相關代碼,具體可見 systems/ai 目錄下相關文件

  1. class RandomMovingNode extends ActionNode { 
  2.   execute() { 
  3.     // 尋路... 
  4.     return true 
  5.   } 
  6.  
  7. class SearchNode extends ConditionNode { 
  8.   condiction() { 
  9.     // 檢測范圍內是否存在敵人 
  10.   } 
  11.  
  12. class AttackNode extends ActionNode { 
  13.   execute() { 
  14.     // 向敵人發起攻擊 
  15.     return true 
  16.   } 
  17.  
  18. // Tree Component 有方法, 不太好, 想想怎么改 
  19. export class TankAITree extends BehaviorTree { 
  20.   constructor(world: World, entity: Entity) { 
  21.     this.root = new ParallelNode(this).addChild( 
  22.       new RandomMovingNode(this), 
  23.       new SequenceNode(this).addChild( 
  24.         new SearchNode(this), 
  25.         new AttackNode(this) 
  26.       ) 
  27.     ) 
  28.   } 

在這幾個基礎的葉子節點上,搭配上文提到的 并行、順序 等節點,就可以組成一棵簡單的 AI 行為樹:AI 一邊隨機移動,一邊搜索當前范圍內是否存在敵人

然后我們把行為樹附加到 AI 角色身上,他們就可以動起來了!

運行展示一下...

0x04 總結

到這里,我們已經做出來一個簡單的游戲了!第一部分的內容,到這里就暫告一段落了。回顧一下,在這部分里面,我們:

  • 實現了一套邏輯層相關的 ECS 框架,用于管理復雜的游戲對象的更新交互邏輯
  • 實現了簡單的事件系統,以及 UI 組件相關邏輯
  • 簡單實現了游戲中的大部分邏輯:移動、攻擊、相機跟隨...

當然,它也還差一些未完成的部分:

  • 多人游戲支持
  • 游戲選單(Game Menu):包括重新開始、退出游戲等
  • 更豐富的玩法:比如守家 / 占點 / 奪旗...多種模式
  • 更多的游戲元素:技能、升級成長、地形...
  • ...

這只是一個作為教程的示例,并不能做到盡善盡美,但還是希望大家能在整個分享里面,對「如何從零開始做一個游戲」這件事,有一個或多或少的認知。如果能讓大家感覺到,「做一個游戲,其實很簡單」 的話,那今天的分享就算是成功了~

說起來...后面如果有時間,可以把這些點都補充上去,實際上,都還挺有趣的..

 

責任編輯:姜華 來源: Tecvan
相關推薦

2020-04-15 08:33:43

Netty網絡通信

2020-09-21 08:33:12

線程池調度Thread Pool

2023-05-12 09:08:48

TypeScript工具類型

2018-09-28 14:28:28

MySQL存儲過程

2021-07-15 09:55:47

systemdLinux文件

2020-03-27 09:06:54

選擇排序算法冒泡排序

2021-05-14 07:11:49

方法調用類加載

2022-05-29 22:55:00

適配器設計模式

2022-08-15 19:49:57

Consul架構注冊中心

2019-08-14 10:20:32

算法數組鏈表

2019-01-15 09:55:24

RAID磁盤陣列數據存儲

2020-05-09 14:40:29

UI設計開發

2015-07-21 13:07:14

Reactjs教程

2025-03-04 02:00:00

Python編寫自動化

2022-05-27 10:00:06

C++游戲引擎

2023-11-01 08:35:56

Python函數

2024-12-12 08:22:03

負載均衡算法無狀態

2014-03-13 14:02:28

Android手游速成

2014-03-13 15:15:33

Android手游速成

2014-03-13 15:06:30

Android手游速成
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 韩日在线视频 | 欧美久操网 | 超碰伊人 | 久草网站 | 亚洲精品免费视频 | www久久国产| 日韩成人在线视频 | 日韩精品一区二区三区视频播放 | 激情a| 国产精品国产三级国产播12软件 | 欧美极品视频在线观看 | 国外成人在线视频网站 | www.日韩 | 色偷偷噜噜噜亚洲男人 | 99re在线视频 | av在线天堂 | 国产原创视频 | 亚洲精品一区国产精品 | 亚洲瑟瑟 | 青娱乐av| 免费毛片网站在线观看 | 国产情侣在线看 | 免费成年网站 | 在线观看成人小视频 | 久在线观看 | 91中文在线观看 | 亚洲一二三视频 | 亚洲三级在线 | 亚洲男人天堂av | 黄色大全免费看 | 奇米av| 日韩精品一区二区三区免费视频 | 欧美日韩在线一区二区三区 | 日韩毛片在线观看 | 亚洲欧美在线免费观看 | 亚洲国产一区二区三区在线观看 | 亚洲国产aⅴ成人精品无吗 综合国产在线 | 国产福利视频 | 国产精品国产成人国产三级 | 亚洲福利| 国产精品久久久久久久久久久久 |