Unity에서 복잡해지는 상태 관리 코드를 어떻게 분석하고, 동작·조건·전이 구조로 분해해 데이터화와 시각화로 개선할 수 있는지 Logic Toolkit의 설계 관점에서 설명합니다.
※이 글은 Logic Toolkit 공식 사이트 블로그에 게재했던 글을 다시 정리한 것입니다.
안녕하세요. Unity의 에디터 확장 계열 에셋을 개발하고 있는 켓시웨어입니다.
이번에는 Unity 에셋 「Logic Toolkit」을 개발하면서, Unity의 상태 관리 코드 복잡성에 대해 어떤 논리로 개선했는지에 대한 생각을 정리해 보겠습니다.
에셋 소개도 마지막에 포함되어 있지만, 기본적으로는 상태 관리 코드의 설계 정리 방법 등에 대한 저 나름의 생각과 논리 설명이 중심입니다.
게임은 「복잡한 상태 전이 관리의 집합」이라고도 할 수 있는 시스템으로 이루어져 있습니다.
이해하기 쉽게, 여기서는 바나나를 매우 좋아하는 원숭이들을 예로 들어 코드 기반 구현의 한계에 대해 설명해 보겠습니다.
단순한 행동 타입의 원숭이를 생각해 봅시다.
단순하네요.
그게 함정이라면 아주 쉽게 걸리고 맙니다.
겸사겸사 함정이었다면 붙잡히는 상태도 추가해 둡시다.
이것을 의사 코드로 써 보면 이런 느낌일 것 같습니다.
배회 중:
if(MoveTo(배회 이동 목적지)==TaskResult.Completed) {
배회 이동 목적지 = 랜덤한 이동 목적지;
}
if(시야에 바나나가 들어옴) {
대상 바나나 = 발견한 바나나;
state = 바나나로 향함;
}
break;
바나나로 향함:
if(MoveTo(대상 바나나.위치) == TaskResult.Completed) {
state = 바나나를 먹음;
}
break;
바나나를 먹음:
if(Eat(대상 바나나) == TaskResult.Completed) {
if(대상 바나나.함정 여부) {
state = 붙잡힘;
} else {
대상 바나나 = 비어 있음;
배회 이동 목적지 = 랜덤한 이동 목적지;
state = 배회 중;
}
}
break;
붙잡힘:
슬퍼하는 모션;
break;
이것은 어디까지나 의사 코드이므로, 본격적으로 대응하려면 이 정도 코드량으로는 끝나지 않을 만큼의 설계와 구현이 필요할 것이라고 생각합니다.
하지만 이 단순 원숭이 한 마리만 만들면 된다면 코드도 한 번 만들고 끝입니다.
이 단계에서는 고급 제어 시스템(전용 에셋 등)이 필요하다고는 전혀 생각하지 않겠지요.
다음으로는 조금 의심이 많은 원숭이를 등장시켜 봅시다.
실제로 만든다면 「안전해 보이면~」의 판정 방법에 따라 달라지겠지만, 그 부분은 핵심이 아니므로 그런 판정이 있다고 가정하고 진행하겠습니다.
이 패턴의 의사 코드는 다음과 같습니다.
배회 중:
if(MoveTo(배회 이동 목적지)==TaskResult.Completed) {
배회 이동 목적지 = 랜덤한 이동 목적지;
}
if(시야에 바나나가 들어옴) {
대상 바나나 = 발견한 바나나;
state = 바나나로 향함;
}
break;
바나나로 향함:
if(MoveTo(대상 바나나.위치) == TaskResult.Completed) {
state = 바나나 확인;
}
break;
바나나 확인:
if(LookAround() == TaskResult.Completed) {
if(함정 바나나 판정(대상 바나나) == 안전해 보임) {
state = 바나나를 먹음;
} else {
무시할 바나나들.Add(대상 바나나);
대상 바나나 = 비어 있음;
배회 이동 목적지 = 랜덤한 이동 목적지;
state = 배회 중;
}
}
break;
바나나를 먹음:
if(Eat(대상 바나나) == TaskResult.Completed) {
if(대상 바나나.함정 여부) {
state = 붙잡힘;
} else {
대상 바나나 = 비어 있음;
배회 이동 목적지 = 랜덤한 이동 목적지;
state = 배회 중;
}
}
break;
붙잡힘:
슬퍼하는 모션;
break;
단순 원숭이와의 차이는 거의 바나나 확인 상태가 추가된 것뿐입니다.
무시할 바나나에 대해서는 「시야에 바나나가 들어옴」 판정에서 무시할 바나나들에 포함되어 있다면 무시한다고 생각해 주세요.
이렇게 쓰고 있으면 같은 부분은 공통화하고 싶어지지만, 「바나나로 향함」이 끝난 뒤의 전이 대상도 다르기 때문에 「바나나로 향함」 부분도 공통화할 수 없습니다.
두 마리 정도라면 아직 괜찮다며 그대로 진행한다고 해 봅시다.
더 이상 세세한 예시는 들지 않겠지만, 여기에 추가로 꽤 의심 많은 원숭이까지 넣고 싶다면 어떨까요.
몇몇 공통 상태를 가진 원숭이들이 더 추가되는 셈입니다.
각 상태가 반드시 공통화될 수 있는 것도 아니고, 어쩌면 우회해서 바나나로 향할지도 모릅니다.
바나나로 향하기 전에 주변을 둘러볼 수도 있겠지요.
그렇다면 어디를 공통화해야 할까요.
제가 생각하는 정답은 「상태의 흐름은 공통 코드로 만들지 않는 편이 좋다」입니다.
이 원숭이 시스템들에서 공통화할 수 있는 것은 MoveTo・Eat・LookAround처럼 각각 단독으로 동작하고 완료되는 「행동」 부분입니다.
그리고 이미 메서드로 분리해 두었다면, 행동 처리 코드는 이미 공통화되어 있다고 할 수 있습니다.
이 이상 공통화하지 않는 편이 좋다면, 현시점에서는 저런 상태 전이 코드를 하나 더 쓰게 되겠지요.
만약 원숭이가 이 세 종류뿐 아니라 100종류나 필요하다면 어떻게 하시겠습니까?
100종류 전부 저런 상태 전이 코드를 손으로 직접 작성하시겠습니까?
「예」라고 답한 분은 여기서부터는 읽을 필요 없습니다. 힘내세요.
「아니요」라고 답한 분은 더 많은 공통화 포인트를 찾아봅시다.
스테이트 패턴(각 상태마다 타입을 만들어 제어하는 구조)으로 정리하면 되지 않느냐고 생각하는 분도 잠깐만 기다려 주세요.
가령 원숭이 하나당 10개 상태라고 하면, 원숭이 100마리를 만들 때 1000개의 원숭이 상태를 구현하게 됩니다.
원숭이가 10마리여도 100개의 원숭이 상태가 필요합니다.
필요한 상태 수만큼 코드와 클래스를 써야 한다는 점은 변함없으니 힘듭니다.
아까 단순 원숭이와 조금 의심 많은 원숭이의 공통점은 MoveTo 같은 행동 부분이라고 했는데, 상태 전이 구조 자체도 공통화할 수 있을 것 같지 않나요?
MoveTo(대상 바나나.위치) == TaskResult.Completed
나
Eat(대상 바나나) == TaskResult.Completed
부분 말입니다.
「무언가를 끝내면 다른 일을 하러 간다」라는 구조 자체는 같고, 다른 것은 「무엇을 하는가」, 「전이할지 판단하는 조건」, 「전이 대상 지정」뿐인 경우가 있습니다.
즉, 스테이트의 구성 요소는 「행동」, 「전이 조건」, 「전이 대상 지정」의 세 요소로 나누어 조합한 것이라고 할 수 있습니다.
그 조합으로 흐름을 구성해 나갈 수 있게 되는 것입니다.
예를 들어 필요한 행동이 10종류, 조건이 5종류이고, 전이 대상은 만든 상태를 참조만 하면 된다고 해 봅시다.
전용 코드를 써야 하는 것은 그 부분뿐입니다.
1000개의 원숭이 상태 클래스를 구현하는 것보다 훨씬 수를 줄일 수 있습니다.
이 「행동+조건+전이 대상」을 State 클래스로 구현할 수 있습니다.
class State {
public Task task;
public Condition condition;
public bool alwaysCheck; // 항상 판정할지, 태스크가 끝나면 판정할지.
public State successTransition;
public State failureTransition;
}
이런 식입니다.
Task와 Condition은 각각 상속해서 여러 가지를 조합할 필요가 있습니다.
MoveToTask나 바나나 시야 판정 Condition 같은 것을 구현해 나가면 됩니다.
State의 구조는 그대로 두고 「행동」과 「조건」의 책임을 분리하여 유연하게 대응할 수 있게 된 것입니다.
이렇게 하면 이 State를 필요한 만큼 new&Add 하는 코드를 원숭이 100종류분 작성하면 끝입니다.
즉 총 1000개의 원숭이 상태를 구현하기 위한 1000개의 State 상속 클래스는 필요 없다는 뜻입니다.
대신 1000개의 new&Add를 써야 하겠지만……
「그렇다면 쉽네, 문제도 없어 보이고 이제 조립만 하면 되겠네!」라고 생각한 분은 졸업입니다. 건강히 지내세요.
「그래도 번거롭다」고 생각한 분은 더 생각해 볼 것이 있습니다.
원숭이 100종을 만드는 데 있어, 원숭이 100종분의 상태 설정용 클래스를 준비하는 것도 현실적이지 않습니다.
대규모 개발의 프로그래머라면 「행동+조건+전이 대상」 설정을 원숭이 디자이너에게 넘기고 싶어질 것입니다.
소규모 개발이라 해도 Monkey001.cs부터 Monkey100.cs까지의 소스 코드 파일을 쓰고 싶지 않을 수 있습니다.
혹은 Monkey089의 행동만 조금 조정해 검증하고 싶을 때, Monkey089.cs 코드를 조금 바꾸고 컴파일해서 검증하고 또 바꾸고를 반복하는 것도 비효율적입니다.
「행동+조건+전이 대상」 설정을 원숭이 디자이너(혹은 개인 개발에서 디자이너 역할을 맡은 나 자신)에게 맡기려면 어떻게 해야 할까요.
여기서 데이터화가 등장합니다.
스프레드시트, 간이 스크립트 언어(DSL), Unity의 직렬화 데이터 등으로 「행동(태스크)+조건+전이 대상」의 세트 목록을 설정하게 하면 되는 것입니다.
[System.Serializable]
class StateData
{
public StateId stateId;
[SerializeReference] public Task task;
[SerializeReference] public Condition condition;
public bool alwaysCheck;
public StateId successTransition;
public StateId failureTransition;
}
Unity라면 이런 데이터를 만들고, SciptableObject나 MonoBehaviour에 List<StateData> 같은 형태로 들고 있게만 해도 설정할 수 있게 되겠지요.
(SerializeReference 속성을 사용하면 Task를 상속한 타입의 데이터도 내장할 수 있게 됩니다. 다만 내장할 타입을 지정하기 위한 에디터 구현이나 대응 에셋이 필요합니다.)
이렇게 데이터로 분리해 두었기 때문에, 숫자만 조금 바꾸거나 흐름만 조금 바꾸고 싶을 때 컴파일 없이 변경 내용을 확인할 수 있습니다.
Monkey089.prefab만 수정하고 바로 플레이 버튼을 누르면 되는 것입니다.
심지어 플레이 중인 상태 그대로 Monkey089 오브젝트를 바꿔 보며 동작을 확인해도 좋겠네요.
장점은 프로그래머가 디자이너에게 통째로 넘길 수 있다는 것만이 아닙니다.
데이터를 추가하거나 변경하는 것만이라면 컴파일이 필요 없기 때문에, 출시 후 업데이트도 하기 쉬워집니다.
Addressables와 조합하면 수정, 버전 업, DLC 등으로 새 원숭이를 추가하기 쉬워지는 것입니다.
이제 개발상의 문제는 해결될 것 같나요?
「이제 구현할 수 있을 것 같고 문제도 없어 보인다」면, 그 정도 개발 규모일지도 모릅니다.
그 규모라서 다행이네요.
끝날 것 같지 않다면 더 생각할 것이 있어 보입니다.
데이터 구조화에 성공한 것은 좋지만, 「이 근처 흐름은 똑같잖아」 싶을 때는 복사해서 붙여넣고 싶고,
「이 부분은 통째로 필요 없잖아」 싶을 때는 범위를 선택해서 한꺼번에 삭제하고 싶습니다.
전이 대상이 어느 상태인지 설정할 때도, 목록에서 이름을 보고 고르는 것보다 마우스로 드래그해서 노드를 잇는 편이 더 이해하기 쉬운 경우도 있습니다.
애초에 상태 전이 구조는 노드 그래프 구조라고 할 수 있습니다.
예전에는 사양서에 어설픈 상태 전이도를 실어 놓고 그것을 수동으로 코드에 옮기던 시절도 있었습니다.
구조상으로도 우선 도식화하는 것이 자연스러운 것입니다.
그림으로 시각화되고, 그림으로 편집할 수 있다는 데 의미가 생깁니다.
또한 테스트 플레이 중에 「얘 움직임이 뭔가 이상한데 왜 이러지?」 싶을 때도, 게임 엔진 위에서 동작을 확인하고 있다면 에디터에서 「지금 어떤 상태를 하고 있는지」를 확인하기 쉽습니다.
이처럼 흐름의 전체 파악과 편집 편의성을 고려하면, 이런 종류의 상태 관리 데이터는 노드 그래프 형식의 GUI로 편집하는 방향으로 정리됩니다.
에디터가 있으면 구성하기 쉬워진다는 점은 이해하셨을 거라고 생각합니다.
그렇다면 이제 과제는 모두 해결된 걸까요?
아까의 StateData라는 정의만으로 모든 패턴을 구현할 수 있을까요?
「어떻게든 될 것 같은데」라고 생각하신 분은 힘내 주세요.
실제 게임 개발에서의 상태 전이와 동작 관리는 훨씬 더 복잡하며, 앞서 본 StateData 구조만으로는 끝나지 않을 수도 있습니다.
동시에 여러 행동을 하기도 하고, 전이 조건도 여러 개이며 어디로 전이할지도 뒤섞이곤 합니다.
캐릭터 AI(NPC) 관련 강연 등에서도, 스테이트 머신만으로는 대응력이 부족해서 비헤이비어 트리도 사용한다는 이야기가 자주 나옵니다.
그러한 복잡한 동작을 제어하려면 어떤 구조가 좋을까요?
상태에 연결되는 개념은 정말 「행동」이나 「조건」뿐일까요?
아직도 생각할 것은 많아 보입니다.
저 나름대로 복잡한 동작에 대응하는 방법을 검토한 결과, Logic Toolkit의 설계 구조에 도달했습니다.
「행동」이나 「조건」은 게임마다 제각각이므로, 코드로 작성할 수 있는 유연성과 확장성이 필수적일 것입니다.
「흐름의 제어 방법」에도 여러 패턴이 있고, 일반적인 실행 플로우도 있고 스테이트 머신도 있고 비헤이비어 트리도 있습니다.
각각의 「흐름」에 맞는 노드 구조를 만들면 될 것 같습니다.
「행동」과 「조건」은 그대로 공유할 수 있으므로, 흐르는 방식만 다른 시스템도 같은 기반 위에서 동작시킬 수 있을 것 같습니다.
이렇게 해서 여기까지의 예시처럼 「행동과 조건을 조합한 흐름」을 구조화하고 데이터화함으로써 Logic Toolkit이 만들어졌습니다.
즉,
그것이 Unity 에셋 「Logic Toolkit」입니다.
무료 체험판도 있으니, 이번 구조 설계 이야기가 궁금하셨다면 꼭 한 번 만져 보시고 참고해 보세요.
각종 링크는 아래와 같습니다.
정리하자면,
라는 이야기였습니다.
여기까지 읽어 주셔서 감사합니다.
좋은 게임 개발 되세요!