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

「題圖:The Elder Scroll VI」

0x00 寫在最前面

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

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

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

  1. Entity Component System 思想,以及它在遊戲開發中能起的作用 (important!)

  2. 一個簡單的 MOBA 遊戲,是如何一步步開發出來的

Entity Component System: https://en.wikipedia.org/wiki/Entity_component_system

「由於時間關係內容沒有仔細校對,難免存在疏漏,還請各位予以指正~」

製作遊戲的開始

在動手做遊戲之前,最重要的事情當然是先決定要做一個什麼樣的遊戲。作爲一個教程的遊戲,我希望它的玩法比較簡單,是可以一眼就看出來的;在此基礎上,又要有可以延展的深度,這樣才利於後面教程後面的拓展

一番思索,腦子裏的遊戲大致是:

之所以這麼選擇,是因爲 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 的管理,比如說:

這裏比比了這麼多,只是爲了先給大家留下一個大概印象,具體的機制以及實現等內容,後面會結合項目的功能以及迭代來講解 ECS 在其中的作用,這樣也更有利於理解

ECS Pros and Cons

長處

不足之處

引擎各部分

相比負責遊戲邏輯的框架,引擎更多的是注重提供某一方面的功能。比如:

這些引擎,每一部分都很複雜;爲了省事,我們這個項目,將使用現成的渲染引擎以及現成的資源管理加載器(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 方法;取而代之的是 startstop。它在整個遊戲開始時,便會執行 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 遊戲爲例,一般會包括以下幾種地形:

爲了簡單演示,我們這裏只做一下簡單的牆壁:阻礙玩家的移動,也不會被子彈摧毀。由於牆壁的貼圖已經在編輯地圖的時候加入了,我們目前需要做的只有

爲了實現這個玩法,我們需要引入專門檢測並處理碰撞的 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)
      }
    }
  }
}

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

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

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,在引入了碰撞檢測與處理的系統之後,是時候更進一步引入攻擊系統了。首先,我們要設計一個攻擊模式:

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

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,我們將根據這個返回值來決定下一步的動作。節點可以分爲以下幾類:

更具體的解釋可參考 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 總結

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

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

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

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

0x05 擴展閱讀

  1. ECS ReactiveSystem:https://www.effectiveunity.com/ecs/06-how-to-build-reactive-systems-with-unity-ecs-part-1/

  2. ECS 檢測 Component 狀態變化:https://www.effectiveunity.com/ecs/07-how-to-build-reactive-systems-with-unity-ecs-part-2/

  3. ECS SystemStateComponent:https://docs.unity3d.com/Packages/com.unity.entities@0.0/manual/system_state_components.html

  4. 2d 碰撞檢測:https://aotu.io/notes/2017/02/16/2d-collision-detection/index.html

  5. 遊戲 AI 行爲樹:https://www.cnblogs.com/KillerAery/p/10007887.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ELEDfKjnCFGanxxPWT_xDg