---
title: "Global Game Jam 2024 - 軟體架構適用遊戲開發嗎？"
date: 2024-01-31T00:00:00+08:00
publishDate: 2024-01-31T00:00:00+08:00
lastmod: 2024-01-31T22:49:35+08:00
tags: ["黑客松","Global Game Jam","Unity","C#","測試","Clean Architecture"]
toc: true
permalink: "https://blog.aotoki.me/posts/2024/01/31/can-software-architecture-apply-to-game-development/"
language: "zh-tw"
---


答案是肯定的，遊戲也是軟體的一種，善用軟體架構相關的思考對於設計遊戲仍然非常有幫助。今年的 Global Game Jam 因為隊友都比較熟悉 Unity，因此我也挑戰在 Unity 實踐[去年](https://blog.aotoki.me/posts/2023/02/08/does-write-test-in-game-jam-is-useful/)沒有完善的部分。

<!--more-->

## 測試驅動{#test-driven}

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

> 這一次在兩天的時間內，整個團隊觸發了 [GitHub Actions 運行](https://github.com/elct9620/GlobalGameJam2024/actions)近 200 次，以敏捷開發的角度來看，至少有做到快速迭代的目標。

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

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

## Clean Architecture

要在遊戲中採用 [Clean Architecture](https://blog.aotoki.me/tags/Clean-Architecture/) 的方式設計，我們可以先簡化成兩種問題，分別是「高階、低階元件」以及「依賴管理」兩種情境。

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

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

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

```cs
public class ProjectInstaller : MonoBehaviour, IInstaller
{
    public void InstallBindings(ContainerBuilder builder)
    {
        // ...
        builder.AddSingleton(typeof(Repository.PuzzleRepository));

		// ...
        builder.AddTransient(typeof(Command.LocalUnlockCommand), typeof(Command.IUnlockCommand));
    }
}
```

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

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

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

```cs
public namespace Command {
    public interface IUnlockCommand {
	    public bool Unlock(int lockID);
    }

	public class LocalUnlockCommand : IUnlockCommand {
		private readonly PuzzleRepository _puzzleRepo;

		public LocalUnlockCommand(PuzzleRepository puzzleRepo) {
			_puzzleRepo = puzzleRepo;
		}

		public bool Unlock(int lockID) {
			var puzzle = _puzzleRepo.FindByLock(lockID);
			if (puzzle == null) return false;

			return puzzle.Unlock();
		}
	}
}
```

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

```cs
public class UnlockButton : MonoBehavior {
	[Inject] private readonly IUnlockCommand _unlockCommand;

	public int LockID = -1;

	public OnClick() {
		if(_unlockCommand.Unlock(LockID)) {
			// Success Feedback
			return
		}

		// Failed Feedback
	}
}
```

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

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

## 狀態維護{#state-management}

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

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

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

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

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

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

```cs
public PuzzleDataset : ScriptableObject, IPuzzleRepository {
	public Puzzle[] Puzzles;

	public Puzzle FindByLock(int lockID) {
		// ...
	}
}
```

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

```cs
public class ProjectInstaller : MonoBehaviour, IInstaller
{
	public IPuzzleRepository PuzzleRepo;

    public void InstallBindings(ContainerBuilder builder)
    {
        // ...
        builder.AddSingleton(PuzzleRepo, typeof(Repository.IPuzzleRepository));

		// ...
    }
}
```

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

