MessagePipe와 VContainer로 이벤트 주도형, 느슨하게 결합된 GameManager를 설계하고, 게임 룰을 순수 C# 클래스로 분리해 유닛 테스트까지 가능한 구조를 예제 코드와 함께 설명합니다.
Unity로 게임을 개발할 때 GameManager를 만들고 게임 로직을 작성해 진행을 제어하는 경우가 많습니다.
하지만 GameManager를 직접 알아야 하고, UI나 기타 컴포넌트가 밀접하게 결합되어 코드가 깔끔하게 써지지 않습니다.
몇 년 전의 저는 이를 해결하지 못해 Unity 게임 개발을 포기했었습니다.
이번에 이를 해결하는 방법으로 이벤트 주도형 게임 매니저를 떠올렸고, 그 아이디어를 공유하고자 합니다.
이를 달성하기 위해서는 아래와 같이 하면 좋아 보입니다.
기존의 GameManager는 다른 컴포넌트가 직접 참조할 필요가 있었지만, 이벤트 주도형으로 바꾸면 각 컴포넌트는 이벤트 발행과 구독만 수행하고 서로를 알 필요가 없어집니다. 이로써 컴포넌트 간 의존 관계가 크게 줄어들어 유지보수성과 확장성이 향상됩니다.
게임 룰을 순수 C# 클래스로 분리하면 Unity 에디터를 띄우지 않고도 유닛 테스트를 실행할 수 있습니다. 이를 통해 게임 로직의 품질을 유지하고 리팩터링 시 안전성도 확보할 수 있습니다.
추상 클래스로 게임 룰의 인터페이스를 정의하고 구체 구현을 분리함으로써, 다양한 게임 모드를 쉽게 추가/변경할 수 있게 됩니다.
MessagePipe는 C#용 고성능 인메모리 메시징 라이브러리입니다. 특히 Unity에서의 사용에 최적화되어 있으며, Zero Allocation으로 동작이 가능합니다.
예를 들어 일반적인 FPS에서, 적을 처치했을 때 킬 로그 UI나 게임 매니저가 각자 이벤트를 받아서 처리할 수 있습니다.
UniRx나 R3와 달리, 서로를 몰라도 IPublisher<T>/ISubscriber<T>를 IoC 컨테이너에서 DI하면 Pub/Sub이 가능하므로 깔끔하게 느슨한 결합을 만들 수 있습니다.
MessagePipe를 사용하면 컴포넌트 간 직접 참조를 제거하고 이벤트 기반 설계를 할 수 있습니다. 이로써 시스템 전체의 결합도를 낮추고 테스트 용이성을 높일 수 있습니다.
이 부분은 기존의 UniRx나 R3를 사용하는 편이 필터링이 수월하다고 생각합니다.
만약 그래도 UI에서 MessagePipe를 쓰고 싶다면, Rx로 메시지를 필터링하는 형태의 BLoC 패턴을 사용하면 좋겠습니다.
VContainer는 Unity용 경량/고속 DI 컨테이너입니다. MessagePipe와 조합하면 의존성 주입과 이벤트 배포를 효율적으로 관리할 수 있습니다.
openupm을 아직 설치하지 않았다면 여기에서 설치해 주세요.
# MessagePipe 설치
openupm add com.cysharp.messagepipe
# VContainer 설치
openupm add jp.hadashikick.vcontainer
셋업이 끝났으니, 실제로 일반적인 대전형 FPS의 Free For All 규칙을 가정하고 코드를 생각해 봅시다.
이번에 다룰 규칙은 다음과 같습니다.
CoD나 Valorant의 데스매치를 해본 분이라면 익숙한 규칙일 겁니다.
또한 이 규칙을 확장해 다양한 대전형 게임에 대응할 수 있습니다.
using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class GameLifeTimeScope : LifetimeScope
{
[SerializeField] private GameSettings gameSettings;
protected override void Configure(IContainerBuilder builder)
{
// MessagePipe 등록
builder.AddMessagePipe();
// 게임 설정 등록
builder.RegisterInstance(gameSettings);
// 게임 룰 등록
builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
// GameManager 등록
builder.RegisterEntryPoint<GameManager>();
// 그 외 서비스 등록
builder.Register<PlayerManager>(Lifetime.Singleton);
builder.Register<TimeManager>(Lifetime.Singleton);
}
}
using MessagePipe;
using System;
using VContainer.Unity;
public class GameManager : IInitializable, IDisposable
{
private readonly IGameRule gameRule;
private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
private readonly ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber;
private readonly IPublisher<GameStartedEvent> gameStartedPublisher;
private readonly IPublisher<GameEndedEvent> gameEndedPublisher;
private readonly DisposableBag disposableBag = new();
public GameManager(
IGameRule gameRule,
ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber,
IPublisher<GameStartedEvent> gameStartedPublisher,
IPublisher<GameEndedEvent> gameEndedPublisher)
{
this.gameRule = gameRule;
this.playerKilledSubscriber = playerKilledSubscriber;
this.gameTimeExpiredSubscriber = gameTimeExpiredSubscriber;
this.gameStartedPublisher = gameStartedPublisher;
this.gameEndedPublisher = gameEndedPublisher;
}
public void Initialize()
{
// 이벤트 구독 설정
playerKilledSubscriber.Subscribe(OnPlayerKilled).AddTo(disposableBag);
gameTimeExpiredSubscriber.Subscribe(OnGameTimeExpired).AddTo(disposableBag);
// 게임 룰 초기화
gameRule.Initialize();
gameRule.OnGameEnded += OnGameEnded;
// 게임 시작 이벤트 발행
gameStartedPublisher.Publish(new GameStartedEvent());
}
private void OnPlayerKilled(PlayerKilledEvent playerKilledEvent)
{
gameRule.OnPlayerKilled(playerKilledEvent.KillerId, playerKilledEvent.VictimId);
}
private void OnGameTimeExpired(GameTimeExpiredEvent gameTimeExpiredEvent)
{
gameRule.OnTimeExpired();
}
private void OnGameEnded(GameResult result)
{
gameEndedPublisher.Publish(new GameEndedEvent(result));
}
public void Dispose()
{
gameRule.OnGameEnded -= OnGameEnded;
disposableBag?.Dispose();
}
}
// 추상 클래스
using System;
using System.Collections.Generic;
public abstract class GameRule : IGameRule
{
public event Action<GameResult> OnGameEnded;
protected Dictionary<string, int> playerKills = new();
protected bool isGameEnded = false;
public abstract void Initialize();
public abstract void OnPlayerKilled(string killerId, string victimId);
public abstract void OnTimeExpired();
protected void EndGame(GameResult result)
{
if (isGameEnded) return;
isGameEnded = true;
OnGameEnded?.Invoke(result);
}
}
// 인터페이스
public interface IGameRule
{
event Action<GameResult> OnGameEnded;
void Initialize();
void OnPlayerKilled(string killerId, string victimId);
void OnTimeExpired();
}
// Free For All 구현
public class FreeForAllRule : GameRule
{
private readonly GameSettings settings;
public FreeForAllRule(GameSettings settings)
{
this.settings = settings;
}
public override void Initialize()
{
playerKills.Clear();
isGameEnded = false;
}
public override void OnPlayerKilled(string killerId, string victimId)
{
if (isGameEnded) return;
if (!playerKills.ContainsKey(killerId))
playerKills[killerId] = 0;
playerKills[killerId]++;
// 승리 조건 확인
if (playerKills[killerId] >= settings.killsToWin)
{
EndGame(new GameResult
{
WinnerId = killerId,
WinType = WinType.KillLimit,
FinalScores = new Dictionary<string, int>(playerKills)
});
}
}
public override void OnTimeExpired()
{
if (isGameEnded) return;
// 최다 킬 플레이어를 승자로
string winnerId = "";
int maxKills = -1;
foreach (var kvp in playerKills)
{
if (kvp.Value > maxKills)
{
maxKills = kvp.Value;
winnerId = kvp.Key;
}
}
EndGame(new GameResult
{
WinnerId = winnerId,
WinType = WinType.TimeLimit,
FinalScores = new Dictionary<string, int>(playerKills)
});
}
}
using System.Collections.Generic;
// 게임 이벤트 정의
public struct PlayerKilledEvent
{
public string KillerId { get; }
public string VictimId { get; }
public PlayerKilledEvent(string killerId, string victimId)
{
KillerId = killerId;
VictimId = victimId;
}
}
public struct GameStartedEvent
{
// 필요 시 데이터 추가
}
public struct GameEndedEvent
{
public GameResult Result { get; }
public GameEndedEvent(GameResult result)
{
Result = result;
}
}
public struct GameTimeExpiredEvent
{
// 필요 시 데이터 추가
}
// 게임 결과 정의
public class GameResult
{
public string WinnerId { get; set; }
public WinType WinType { get; set; }
public Dictionary<string, int> FinalScores { get; set; }
}
public enum WinType
{
KillLimit,
TimeLimit
}
// 게임 설정
[System.Serializable]
public class GameSettings
{
public int killsToWin = 10;
public float gameTimeLimit = 300f; // 5분
}
using NUnit.Framework;
using System.Collections.Generic;
public class FreeForAllRuleTest
{
private FreeForAllRule rule;
private GameSettings settings;
private GameResult capturedResult;
[SetUp]
public void SetUp()
{
settings = new GameSettings
{
killsToWin = 3,
gameTimeLimit = 300f
};
rule = new FreeForAllRule(settings);
rule.OnGameEnded += (result) => capturedResult = result;
rule.Initialize();
capturedResult = null;
}
[Test]
public void 킬_수에_의한_승리_조건_테스트()
{
// Arrange
string playerId = "Player1";
// Act
rule.OnPlayerKilled(playerId, "Enemy1");
rule.OnPlayerKilled(playerId, "Enemy2");
// 아직 게임은 종료되지 않음
Assert.IsNull(capturedResult);
rule.OnPlayerKilled(playerId, "Enemy3");
// Assert
Assert.IsNotNull(capturedResult);
Assert.AreEqual(playerId, capturedResult.WinnerId);
Assert.AreEqual(WinType.KillLimit, capturedResult.WinType);
Assert.AreEqual(3, capturedResult.FinalScores[playerId]);
}
[Test]
public void 시간_초과에_의한_승리_조건_테스트()
{
// Arrange
rule.OnPlayerKilled("Player1", "Enemy1");
rule.OnPlayerKilled("Player1", "Enemy2");
rule.OnPlayerKilled("Player2", "Enemy3");
// Act
rule.OnTimeExpired();
// Assert
Assert.IsNotNull(capturedResult);
Assert.AreEqual("Player1", capturedResult.WinnerId);
Assert.AreEqual(WinType.TimeLimit, capturedResult.WinType);
Assert.AreEqual(2, capturedResult.FinalScores["Player1"]);
Assert.AreEqual(1, capturedResult.FinalScores["Player2"]);
}
[Test]
public void 게임_종료_후에는_추가_킬을_받아들이지_않는다()
{
// Arrange
string playerId = "Player1";
// 3킬로 승리
rule.OnPlayerKilled(playerId, "Enemy1");
rule.OnPlayerKilled(playerId, "Enemy2");
rule.OnPlayerKilled(playerId, "Enemy3");
var firstResult = capturedResult;
// Act - 게임 종료 후 추가 킬
rule.OnPlayerKilled(playerId, "Enemy4");
// Assert
Assert.AreEqual(firstResult.FinalScores[playerId], capturedResult.FinalScores[playerId]);
}
}
Debug.Log()를 추가하기Unity의 Debug.Log는 그대로 두면 성능 저하나 보안상 문제가 될 수 있습니다. 또한 Debug.Log()가 각 컴포넌트에 산재하면 코드가 지저분해집니다.
#if UNITY_EDITOR
using MessagePipe;
using UnityEngine;
using VContainer.Unity;
public class EventDebugger : IInitializable, System.IDisposable
{
private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
private readonly ISubscriber<GameStartedEvent> gameStartedSubscriber;
private readonly ISubscriber<GameEndedEvent> gameEndedSubscriber;
private readonly DisposableBag disposableBag = new();
public EventDebugger(
ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
ISubscriber<GameStartedEvent> gameStartedSubscriber,
ISubscriber<GameEndedEvent> gameEndedSubscriber)
{
this.playerKilledSubscriber = playerKilledSubscriber;
this.gameStartedSubscriber = gameStartedSubscriber;
this.gameEndedSubscriber = gameEndedSubscriber;
}
public void Initialize()
{
playerKilledSubscriber.Subscribe(evt =>
Debug.Log($"[DEBUG] Player killed: {evt.KillerId} -> {evt.VictimId}")
).AddTo(disposableBag);
gameStartedSubscriber.Subscribe(evt =>
Debug.Log("[DEBUG] Game started")
).AddTo(disposableBag);
gameEndedSubscriber.Subscribe(evt =>
Debug.Log($"[DEBUG] Game ended: Winner={evt.Result.WinnerId}, Type={evt.Result.WinType}")
).AddTo(disposableBag);
}
public void Dispose()
{
disposableBag?.Dispose();
}
}
#endif
Unity에는 SerializeReference라는 애트리뷰트가 존재합니다.
이는 Unity 2019.3에서 추가된, 인터페이스 구현이나 추상 클래스의 서브클래스를 인스펙터에서 편집 가능하도록 하는 기능입니다.
하지만 기본 SerializeReference에는 서브클래스를 교체할 수 없다는 단점이 있습니다.
그래서 Unity-SerializeReferenceExtensions를 사용해 이 문제를 해결할 수 있습니다.
# Unity-SerializeReferenceExtensions 설치
openupm add com.mackysoft.serializereference-extensions
using UnityEngine;
using MackySoft.SerializeReferenceExtensions;
[System.Serializable]
public abstract class GameRuleBase
{
public abstract void Initialize();
public abstract void OnPlayerKilled(string killerId, string victimId);
public abstract void OnTimeExpired();
}
[System.Serializable]
public class FreeForAllRuleSettings : GameRuleBase
{
[SerializeField] private int killsToWin = 10;
[SerializeField] private float gameTimeLimit = 300f;
public int KillsToWin => killsToWin;
public float GameTimeLimit => gameTimeLimit;
public override void Initialize() { /* 초기화 처리 */ }
public override void OnPlayerKilled(string killerId, string victimId) { /* 킬 처리 */ }
public override void OnTimeExpired() { /* 시간 초과 처리 */ }
}
[System.Serializable]
public class TeamDeathMatchRuleSettings : GameRuleBase
{
[SerializeField] private int teamKillsToWin = 50;
[SerializeField] private int maxTeamSize = 5;
public int TeamKillsToWin => teamKillsToWin;
public int MaxTeamSize => maxTeamSize;
public override void Initialize() { /* 팀전 초기화 처리 */ }
public override void OnPlayerKilled(string killerId, string victimId) { /* 팀전 킬 처리 */ }
public override void OnTimeExpired() { /* 팀전 시간 초과 처리 */ }
}
// GameManager에 통합
public class GameRuleConfiguration : MonoBehaviour
{
[SerializeReference, SubclassSelector]
private GameRuleBase gameRuleSettings = new FreeForAllRuleSettings();
public GameRuleBase GameRuleSettings => gameRuleSettings;
}
이 방법을 통해 인스펙터에서 직관적으로 게임 룰을 전환할 수 있고, 각 룰 고유의 파라미터도 편집 가능합니다.
더 나아가, VContainer와 결합하면 설정된 룰에 근거해 적절한 구현을 주입할 수 있습니다:
public class GameLifeTimeScope : LifetimeScope
{
[SerializeField] private GameRuleConfiguration ruleConfiguration;
protected override void Configure(IContainerBuilder builder)
{
builder.AddMessagePipe();
// 설정된 룰에 따라 구현 등록
switch (ruleConfiguration.GameRuleSettings)
{
case FreeForAllRuleSettings ffaSettings:
builder.RegisterInstance(ffaSettings);
builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
break;
case TeamDeathMatchRuleSettings tdmSettings:
builder.RegisterInstance(tdmSettings);
builder.Register<IGameRule, TeamDeathMatchRule>(Lifetime.Singleton);
break;
}
builder.RegisterEntryPoint<GameManager>();
}
}
MessagePipe와 VContainer를 조합해 기존 GameManager의 과제를 해결할 수 있었습니다.
얻은 효과:
느슨한 결합을 달성했고, 게임 룰도 VContainer에 등록해 손쉽게 전환할 수 있습니다.
또한 GameRule을 순수 C#으로 만들어 테스트 가능해졌습니다.
이 설계 패턴을 응용하면 더 복잡한 게임 시스템에도 대응할 수 있습니다. 이벤트 주도형 설계는 처음엔 익숙해지는 데 시간이 필요하지만, 한 번 익숙해지면 매우 강력한 무기가 됩니다.