Godot 4 유닛 AI — LimboAI 비헤이비어 트리와 깃발 명령, 그리고 안 먹히던 버그

2026년 08월 27일 · 개발 일지

Tiny Reign은 유닛이 알아서 싸우는 자동 전투 게임이다. 그러다 보니 AI를 어떻게 짤 것인가가 프로젝트의 중심 문제였다. 여기에 플레이어가 개입할 방법까지 붙이면서 꽤 오래 붙잡고 있었고, 마지막엔 노드 타입 하나 때문에 며칠을 날렸다. 순서대로 정리했다.

상태 머신 대신 비헤이비어 트리

상태 머신(State Machine)이 첫 번째 선택지였는데, 상태가 늘어날수록 전환 조건이 복잡해지는 게 걱정됐다. 찾아보다가 LimboAI라는 Godot 플러그인을 알게 됐고, 비헤이비어 트리를 써보기로 했다.

비헤이비어 트리는 AI 행동을 트리 구조로 표현한다. 루트부터 시작해서 조건을 검사하고, 성공이나 실패에 따라 다음 노드로 넘어간다. Selector는 자식 중 하나가 성공하면 멈추고, Sequence는 모든 자식이 성공해야 다음으로 넘어간다.

Tiny Reign 유닛의 기본 흐름은 이렇다. 적이 감지 범위 안에 있으면 추격하고, 공격 범위 안에 들어오면 공격한다. 감지된 적이 없으면 깃발 위치로 이동하거나 대기한다. 이 흐름을 비헤이비어 트리로 표현하면 코드보다 훨씬 읽기 쉽다.

커스텀 태스크 작성

LimboAI 비헤이비어 트리 에디터에서 구성한 아처 유닛의 행동 트리

LimboAI에서 AI 행동 하나하나는 BTTask를 상속받아 구현한다. BTDetectEnemy, BTChaseEnemy, BTAttackEnemy 같은 식으로 각각 파일을 만들었다.

태스크는 _tick() 함수에서 매 프레임 실행되고 SUCCESS, FAILURE, RUNNING 중 하나를 반환한다. RUNNING을 반환하면 다음 프레임에도 계속 실행된다. BTChaseEnemy는 목표까지 이동하는 동안 RUNNING을 반환하고, 목표에 도달하면 SUCCESS를 반환하는 식이다.

이 RUNNING이라는 상태가 나중에 발목을 잡는데, 그때는 몰랐다.

유닛마다 트리를 바꿀 수 있다

몽크는 공격 대신 힐을 써야 해서 일반 유닛과 행동이 다르다. LimboAI에서는 .tres 파일로 비헤이비어 트리를 저장하고 유닛마다 다른 트리를 붙일 수 있다. archer_bt.tres, monk_bt.tres 이런 식으로 분리했다.

새 유닛을 추가할 때 기존 태스크를 재사용하면서 트리 구조만 바꾸면 된다. 코드를 복사할 필요 없이 트리 에디터에서 노드를 조합하면 됐다.

플레이어가 개입할 자리 — 깃발 시스템

AI가 돌아가기 시작하니 다음 고민이 생겼다. 유닛이 알아서 싸우는데 플레이어는 뭘 하는가. 자동 전투 게임에서 개입하는 재미를 어떻게 줄 것인가. 나온 답이 깃발 시스템이다.

집결(1번), 돌격(2번), 방어(3번), 퇴각(4번). 키보드 숫자키에 배치했다. 각 깃발은 맵에 배치되고 아군 유닛들이 해당 깃발을 목표로 행동 방식을 바꾼다.

깃발마다 버프도 다르게 줬다. 돌격 깃발은 ATK 증가, 방어 깃발은 DEF 증가, 집결은 SPEED 증가, 퇴각은 HEAL 효과. 단순한 이동 명령이 아니라 전략적 선택에 의미를 주고 싶었다.

깃발 명령을 받으면 비헤이비어 트리가 다른 행동을 해야 한다. BTMoveToFlag는 현재 활성 깃발 위치로 이동하는 태스크다. BTRetreat는 퇴각 깃발일 때만 실행되어 전투를 포기하고 돌아온다. BTIdle은 방어 깃발에서 제자리를 지키는 행동이다. FlagManager Autoload에서 현재 활성 깃발 정보를 들고 있고, BT 태스크에서 이걸 참조해 어떤 깃발이 활성화됐는지 확인한다.

깃발 주변 대형

깃발 위치로 모인 유닛들이 대형 슬롯을 나눠 선 모습

깃발 위치로 유닛들이 몰리면 다 겹쳐버린다. 깃발 주변에 대형 슬롯을 만들어서 유닛들이 각자 다른 위치를 차지하게 했다.

3열 그리드로 슬롯을 미리 계산해두고, 유닛이 대형에 합류하면 빈 슬롯을 하나 배정받는다. Warrior는 앞줄, Lancer와 Monk는 중간, Archer는 뒤에 배치되도록 유닛 타입별로 우선 슬롯 열을 다르게 했다. 슬롯마다 상하좌우 16픽셀 범위의 랜덤 오프셋을 줘서 완벽한 격자 느낌은 줄였다.

그런데 깃발을 바꿔도 반응이 없었다

깃발을 바꿨는데도 유닛들이 이전 목표를 향해 이동하던 상황

여기까지 만들고 나서 이상한 버그가 생겼다. 깃발을 새로 배치해도 유닛들이 반응을 안 했다. 처음 배치된 깃발로만 계속 이동하고, 키를 눌러 다른 깃발로 바꿔도 꿈쩍하지 않았다.

집결 깃발을 배치하면 유닛들이 거기로 움직였다. 그런데 돌격 깃발로 바꿔도 유닛들은 이전 위치 근처에서 계속 맴돌았다. FlagManager에서 활성 깃발이 바뀐 건 로그로 확인했다. 그런데 BT 태스크는 새 목표를 인식하지 못하는 것 같았다.

FlagManager를 의심했다가, BTMoveToFlag의 목표 갱신 로직을 의심했다가, 한참을 헤맸다. 정작 원인은 그 어느 쪽도 아니었다.

원인은 BTSelector의 캐싱이었다

한참 디버깅하다가 LimboAI 문서에서 단서를 찾았다. BTSelector는 자식 태스크 중 하나가 RUNNING 상태면 다음 틱에도 그 태스크를 계속 실행한다. 다른 자식을 다시 검사하지 않는다.

앞에서 만든 BTMoveToFlag가 깃발을 향해 이동하는 동안 RUNNING을 반환하고 있었다. BTSelector 입장에서는 실행 중인 태스크가 있으니 조건을 다시 확인할 이유가 없었던 것이다. 깃발이 바뀌어도 태스크는 이전 목표를 향해 계속 달리고 있었다.

버그는 깃발 쪽에 있는 줄 알았는데, 실제로는 트리가 조건을 다시 안 보고 있었던 셈이다.

BTDynamicSelector로 해결

LimboAI에는 BTDynamicSelector라는 노드가 있다. 일반 BTSelector와 다르게, RUNNING 중인 태스크가 있어도 매 틱마다 자식 태스크를 처음부터 다시 평가한다. 우선순위가 높은 조건이 충족되면 실행 중인 태스크를 중단하고 새 태스크로 전환한다.

BTSelector를 BTDynamicSelector로 바꾸고 나서 깃발을 바꾸면 유닛들이 즉시 새 깃발 방향으로 전환됐다. 코드를 고친 게 아니라 노드 타입 하나 바꾼 것이었다.

언제 어느 걸 써야 하나

BTSelector는 실행 중인 행동을 끝까지 수행해야 할 때 쓴다. 공격 모션처럼 중간에 끊기면 안 되는 경우다. BTDynamicSelector는 외부 상황이 바뀌면 즉시 반응해야 할 때 쓴다. 깃발 명령이나 위험 감지처럼 우선순위가 바뀌는 경우다.

정리하면

비헤이비어 트리 자체는 배우는 데 시간이 걸리지만, 구조를 이해하고 나면 AI 행동을 추가하거나 수정하기가 상태 머신보다 훨씬 편했다. 유닛별로 트리 파일을 분리할 수 있는 것도 컸다.

다만 이런 식의 미묘한 차이에서 버그가 생긴다. 증상은 명령이 안 먹힌다인데 원인은 노드 타입 하나였다. 프레임워크가 알아서 해주는 부분이 많을수록, 그 프레임워크가 내부적으로 뭘 하고 있는지 모르면 디버깅할 곳을 못 찾는다. LimboAI를 쓸 계획이라면 BTSelector와 BTDynamicSelector의 차이는 미리 알아두는 편이 낫다.

이 시리즈의 다른 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다