蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Global Game Jam 2024 - 軟體架構適用遊戲開發嗎?

答案是肯定的,遊戲也是軟體的一種,善用軟體架構相關的思考對於設計遊戲仍然非常有幫助。今年的 Global Game Jam 因為隊友都比較熟悉 Unity,因此我也挑戰在 Unity 實踐去年沒有完善的部分。

測試驅動

今年跟去年一樣選擇要在遊戲開發過程中寫測試,雖然並不容易,然而有測試跟沒有測試相比,對於開發過程的「修改意願」還是能夠提高不少。

這一次在兩天的時間內,整個團隊觸發了 GitHub Actions 運行近 200 次,以敏捷開發的角度來看,至少有做到快速迭代的目標。

要能夠寫測試來輔助開發,需要測試框架跟持續整合能夠達到標準。得益於 Unity Test Framework 逐漸完善而且能夠針對遊戲物件測試,再加上 GameCI 專案的完善,現在能夠順利使用 Docker 環境和 Personal License 來做自動化測試、建制,過去也嘗試過導入但都卡在 Unity 極度煩人的授權機制上。

工具層面完善後,在遊戲開發上還是會遇到「操作測試」這樣的情境很難滿足,因此這次也沒有強行的要去模擬遊戲操作,而是遊戲中的程式劃分為 Presenter(表現)跟 Core(核心玩法)兩個概念,並且針對核心玩法處理。

Clean Architecture

要在遊戲中採用 Clean Architecture 的方式設計,我們可以先簡化成兩種問題,分別是「高階、低階元件」以及「依賴管理」兩種情境。

上述的 Presenter 跟 Core 剛好就是低階(輸入、輸出)跟高階(規則)兩個部分,實際上需要繼承遊戲引擎的 MonoBehavior 的只有 Presenter 而已,那麼在開發過程中,因為 Core 就只是單純的 C# 實作,而且 Unity Test Framework 基於 NUnit 即使在 VSCode、Rider 等編輯器也能夠直接用 .NET 的環境測試。

另一方面過去在 Unity 的依賴注入(Dependency Injection)比較有名的 Zenject 我一直認為不好用,今年發現的 Reflex 就非常輕量跟容易使用,雖然有些限制,但是在整個架構的設計上只要處理好,也不會不好用。

透過上述的工具跟框架,整個遊戲開發大致上就變成這樣的形式。

 1public class ProjectInstaller : MonoBehaviour, IInstaller
 2{
 3    public void InstallBindings(ContainerBuilder builder)
 4    {
 5        // ...
 6        builder.AddSingleton(typeof(Repository.PuzzleRepository));
 7
 8		// ...
 9        builder.AddTransient(typeof(Command.LocalUnlockCommand), typeof(Command.IUnlockCommand));
10    }
11}

先對依賴注入框架定義各種類型的物件,另外,寫這篇文章時我認為要將原本直接註冊的命令(Command)物件,以 Command.IUnlockCommand 的方式定義更適合。

原因在於 Command 是 Presenter 跟 Core 互動的邊界,同時也是用於定義玩家可以做的「操作」是怎樣的,現在許多遊戲可能會支援連線機制,如果在設計時將操作處理做適當的處理,就很容易可以擴充出單機、單機多人、連線多人等情境。

使用 Reflex 來依賴注入,除了可以用註釋(Annotation)機制對屬性注入外,Core 類型的物件大多還是偏向注入到建構子(Constructor)上更加適合,在撰寫測試的時候比較容易注入物件,而不會因為設定成私有(Private)屬性而需要另外處理。

 1public namespace Command {
 2    public interface IUnlockCommand {
 3	    public bool Unlock(int lockID);
 4    }
 5
 6	public class LocalUnlockCommand : IUnlockCommand {
 7		private readonly PuzzleRepository _puzzleRepo;
 8
 9		public LocalUnlockCommand(PuzzleRepository puzzleRepo) {
10			_puzzleRepo = puzzleRepo;
11		}
12
13		public bool Unlock(int lockID) {
14			var puzzle = _puzzleRepo.FindByLock(lockID);
15			if (puzzle == null) return false;
16
17			return puzzle.Unlock();
18		}
19	}
20}

這樣一來,遊戲引擎負責的部分處理上就會單純很多。

 1public class UnlockButton : MonoBehavior {
 2	[Inject] private readonly IUnlockCommand _unlockCommand;
 3
 4	public int LockID = -1;
 5
 6	public OnClick() {
 7		if(_unlockCommand.Unlock(LockID)) {
 8			// Success Feedback
 9			return
10		}
11
12		// Failed Feedback
13	}
14}

以上述的遊戲元件來看,負責處理的人只需要知道有 Unlock() 的動作可以使用,其他細節就不需要知道,分工上也會更加流暢。

上述的例子只有解決寫入的部分,要對應寫入的結果,除了從回傳值判斷的同步處理外,也可以再額外加入事件系統、CQRS(Command Query Responsibility Segregation)等架構使用,然而可能跟 ECS(Entity Component System)的概念是重疊的,目前還沒有機會驗證哪個方法更恰當。

狀態維護

另一個有趣的地方是我在 ScriptableObject 的應用上有新的理解,而這剛好是我最近在讀一些 Kubernetes 相關文章了解到的概念。

現代的雲端環境有非常多東西要管理,因此以 Kubernetes 這類調度系統來說,會以「描述最終狀態」的方式設計,也就是透過撰寫 YAML 來描述部署後最後想要的樣子。

Unity 實際上也採用 YAML 來管理一個遊戲場景、預製物件(Prefab)的內容,從這個角度來看遊戲引擎被保存的場景,也是我們對於遊戲過程中某個狀態的「快照(Snapshot)」兩者的目標都是想透過特定的方式描述一個狀態轉變到另一個狀態。

這樣一來,在遊戲設計中就需要思考另一個問題,那就是哪些物件是有狀態(Statefule)哪些是無狀態(Stateless)的,並且將這些物件區分出來,這樣在實作上就會變得更清楚且容易理解。

假設我們從上述的 Repository(倉儲)和 Command(命令)來看,也是做了類似的區分,在 Kubernetes 上則會有 OperatorController 的角色去控管資源進入到預期的狀態,對我來說其實跟 ECS 的 System 是很類似的角色。

那麼 ScriptableObject 很明顯的就是一種狀態的保存方式,他可以讓我們以非常直覺的方式來設計遊戲中的一些資料。

1public PuzzleDataset : ScriptableObject, IPuzzleRepository {
2	public Puzzle[] Puzzles;
3
4	public Puzzle FindByLock(int lockID) {
5		// ...
6	}
7}

比較簡單的方式,就是直接把 ScriptableObject 當作是一種 Repository 的實作,最後只需要在 Reflex 框架以依賴注入的方式處理即可。

 1public class ProjectInstaller : MonoBehaviour, IInstaller
 2{
 3	public IPuzzleRepository PuzzleRepo;
 4
 5    public void InstallBindings(ContainerBuilder builder)
 6    {
 7        // ...
 8        builder.AddSingleton(PuzzleRepo, typeof(Repository.IPuzzleRepository));
 9
10		// ...
11    }
12}

這樣一來在兩天規模的小型實驗性遊戲開發上,基本上就能取得很不錯的測試、開發效率的平衡,也有不少可以延伸思考的地方。像是 Kubernetes 的調度系統如何運作,也許可以成為 Unity 這類引擎在遊戲架構設計上的參考,而遊戲引擎的編輯器,說不定也能讓 Kubernetes 這類工具更加使用者友善。