Skip to main content

One post tagged with "ECS"

View All Tags

· 13 min read
Wei Ji

前情提要

隨著 Polis 專案的串流功能完成,開發開始往下一個階段發展,PoC 還剩下兩個功能需要實作:

  • 基本遊戲系統
  • P2P 連線

因為 P2P 連線還需要解決遊戲狀態同步的問題,因此它在某種程度上仰賴遊戲系統,所以遊戲系統的實作就是我下一個階段要處理的事情。而遊戲系統預計要使用 ECS (Entity Component System) 實作則是很早就決定的事。

ECS (Entity Component System)

關於 ECS 的具體細節本文就不再贅述,簡言之就是使用 ECS 架構的遊戲在做職責分離的時候會跟物件導向 (OOP) 中抽象化的思考方式不相同,這對我而言是一個挑戰,因為我沒有真正用 ECS 架構去寫遊戲過。

最早會接觸 ECS 這個新鮮玩意兒是因為工作的關係接觸了 React Game Engine 但是它只是 ECS 的弱實現 (loose implementation),因此與實際 ECS 的思考方式之間還是存在落差,加上同事其實跟我提過我的寫法很大程度還是一般的 React 程式,並沒有發揮 ECS 的特性。

另外一個困擾我的點是,部分 Javascript/Typescript 實作的 ECS 函式庫著重於 ECS 中「資料緊湊」的特性,它們使用 TypedArray 定義的基本型態來儲存資料,這使得它們的原始設計與 Three.js 或 Rapier 這種有自己定義 class 的函式庫之間不好協調。

因此我認為在正式引入 ECS 這個技術棧 (Tech Stack) 到 Polis 以前應該先寫一個短期專案對選定函式庫的可行性做驗證,以及累積足夠的「ECS 思維」。

aimAndShoot

aimAndShoot 便是我的實驗對象。在「底火的芬芳 - 專案起源」一文中有提到過這個 repository,它是一個有點像 diep.io 的簡單小遊戲,作為重構改寫的對象是再適合不過了。

Miniplex

Javascript/Typescript 中有不少 ECS 套件,雖然跟一般的熱門套件相較之下都算是冷門套件。

正如我提過得,它們之中的大部分著重於資料緊湊的設計,或是有著不支援/不友善 Typescript 的問題,另外一點則是一些套件更預設了一套框架,開發者必須在這個架構下開發。

相較之下 Miniplex 更深刻的體現了「一次做好一件事」的哲學,讓我們來看看它的介紹:

A 👩‍💻 developer-friendly entity management system for 🕹 games and similarly demanding applications, based on 🛠 ECS architecture.

是的,它只把自己定位在「Entity Management」的部份,既不管資料緊不僅湊,也不管開發者想怎麼實作 System,即便看著範例程式碼,我也不會浮現「這玩意兒到底要怎麼跟 Three.js 或 Rapier 整合?」的問題,我只需要煩惱如何把問題使用 ECS 的架構去實現就好。

重構

很快就完成了從 Javascript 轉換成 Typescript 的重構 (2023-09-13~2023-09-15),這個部份應該算是整個重構中最簡單的,畢竟遷移 Legacy Code 我也不是第一次幹了,有一些工具可以協助完成這種事:

Web Component (Custom Element) 初體驗

微前端 (Micro frontends) 其實也不是什麼新鮮的玩意了,不過一直沒有機會或動機去使用它。第一次知道這個東西是為了解決客戶的(相對)大型專案而查資料查到的,只是客戶方面的技術相對落後(沒有 CI/CD、整合仰賴客戶的工程師手動複製程式碼、預計使用 iframe 作為大型專案的 solution...),而公司作為代工方並沒有技術決策的權限,自己本身也沒有 side project 有面臨類似的問題。

作為一個網頁前端工程師,UI 之類的問題我一律想用 React 解決,在 Canvas 上刻 UI 根本浪費時間效果又差。但是這個 ECS 的練習我並不想直接讓它跟 React App 耦合,發現這似乎是使用 Web Component 這個 Web API 的好時機。

於是一開始 (2023-09-16) 我先根據我預想的排版用 React 實作,再把它包裝成 Custom Element,透過一個 callback (EventListener) 當 Canvas 被掛上 DOM 之後回傳 HTMLCanvasElement ,再用遊戲的實例去接收它來繪製遊戲內容。

當我把遊戲邏輯實作的差不多之後 (2023-09-20),發現應該反過來操作:

把遊戲封裝成 Custom Element 給 React 使

因為整個遊戲其實只仰賴一塊 Canvas 作為 I/O,只要能夠恰當的完成初始化跟釋放資源,就能被封裝到 React 的 Cycle life 內才對。

另一方面也是因為我已經在 React 內寫過諸如 Three.js 和 MapBox GL JS 的東西,對於這種「非 React 原生的套件如何整合進 React App 內」已經很直覺的知道怎麼做了:用 ref 取得實例並搭配 useEffect 來把事件掛上去跟釋放 callback。

ECS

Miniplex 的 Typescript 友善程度算是比我預期的的還好,關於 Entity 的型別如何聲明,Github 上面有一則討論算是給出了蠻明確的方向1

type IPosition {
position: Vector3
}

type IHealth {
health: { current: number, max: number }
}

type Entity = Partial<IPosition & IHealth & ...>

我個人是使用類似但是有一點差異的方式:

export type AgentEntity = {
id: string;
particle: ParticleComponent;
health: HealthComponent;
projectileEmitter: ProjectileEmitterComponent;
warrior: WarriorMiscComponent;
brain?: DejavuComponent;
statistics: WarriorStatisticsComponent;
};

export type BulletEntity = {
particle: ParticleComponent;
attackEffect: AttackEffectComponent;
};

export type Entity = Partial<
TimeEntity & EventEntity & AgentEntity & BulletEntity
>;

以 ECS 的思考模式,其實並不在乎 Entity 要多具體,而是以 Component 為操作單元,而 Component 在中 Miniplex 實質等於 Entity 的 properties,因此如果思考方式依然受到 OOP 影響,至少可以用這種方式輔助產生一種介於「依照物件設計邏輯的切割方式」與「依照 Component 邏輯的切割方之」之間的過渡狀態。

另外因為我是從一個既存專案做重構,因此我懶得重新用「The Component Way」的思考模式去重新設計資料結構,所以我把物件的屬性隨便包成 Component 丟進去。

Joy UI 初體驗

工作上很常要寫一些 B2B 的後台 (Dashboard),Material UI 算是我最常使用的 React 套件之一,可以快速搭建出有模有樣的網頁,作用跟 Bootstrap 其實差不多。只是它內置了一套設計系統 (Design System),對於沒有引入 Design System 的專案來說,反而要為了那些缺乏一致性的客製化 style 去覆蓋掉原本的設計而花費很多心力。並且對於個人使用的小專案來說又稍嫌笨重了一點。

直到前一陣子注意到這個套件出了一個衍生套件 Joy UI,它的所有組件與 Material UI 幾乎是一對一對應的,只是少了 Material Design 的部份。看上去也還算順眼,於是便趁著這個機會來用用看。

於是在完成了遊戲本體之後,便用這個套件新增了一些 UI,包含:玩家列表、記分板。

小插曲

因為要重構成 ECS 架構的關係,所有的實作我都必須逐行閱讀了解其意義之後才能將邏輯從 class 內抽出然後丟到特定的 System 去,也因此讓我有了意外收穫。

幾年前 (2020),我曾經改寫了同一個專案,移除了玩家、讓 Bot 分成兩隊互毆,但是成效不佳,跑了 300 多代依然蠢蠢的。

重構的過程我發現了有這麼一段程式:

  private selectParent(queries: IQueries) {
let total = 0;

for (const bot of queries.botPlayer) {
total += bot.statistics.fitness;
}

let prob = Math.random() * total;

for (const bot of queries.botPlayer) {
if (prob < bot.statistics.fitness) {
return bot;
}

prob -= bot.statistics.fitness;
}

return null;
}

原作者想用加權抽籤的方式抽出 AI 進行繁殖,其中的 fitness 是分數的意思,也就是這裡的權重值。然而這個實作會造成一個 AI 只要有一回合表現不好拿了 0 分,它被刷掉機率會達到 100%。並且只要有一回合大家運氣不好都拿了 0 分,就會整代被刷掉,演算法會直接 Random 一組 0 經驗的類神經網路來繁殖。

因此在不考慮評分標準的數學模型到底合理不合理的前提下,AI 被重置的機率其實很高,這也解釋了為什麼我之前跑了 300 多代 Bot 依然蠢蠢的原因。

收官 (Yose, ヨセ)

重構過程其實讓我對這個專案產生了不少額外的想像,比如說:利用 ECS 的特性,我其實可以把當前遊戲幀的所有資料 dump 出來,之後重開網頁再把資料倒回去理論上可以很輕易的實現「遊戲讀擋」的功能,畢竟關於遊戲的所有狀態都塞在一個由 Entity 填充的 Array 裡面;又或是寫一個視覺化的組件呈現 AI 當前類神經網路的激發狀態;甚至是製作另一個模式實現我曾經實現過得 Zero-player game,理論上只要置換 System 就能輕易實現。

然而我其實對這個專案的定位有立下一個明確的目標:

以 ECS 實現原專案的所有或大部份機能,藉此累積使用 Miniplex 的經驗

目的已經達成,該收官了,於是我藉著這個星期六 (2023-09-23) 進行最後打磨,讓它至少看起來有模有樣,然後把原本分散在兩個 repo 的 commit 用 subtree 和 rebase 整理到 fork 的 repo 去。

結論

Footnotes

  1. Does Entity need to know all possible components?. Retrieved 2023-09-24, from https://github.com/hmans/miniplex/discussions/295