
맵을 만들었으니 그 위에서 움직이는 적들을 구현해보겠습니다.
Enemy AI를 구현하는 방법은 여러가지가 있습니다.
적들이 단순한 이동 및 공격만 한다면 Update문 안에 작성하거나 FSM이 좋겠지만,
저는 좀 더 다양한 적들을 만들어보기 위해서 Behavior Tree를 구현해 보겠습니다.
큰 구조는 이 유튜브 영상을 참고했습니다.
영상 보시고 글을 읽으시면 이해가 더 쉬우실 것 같습니다.
Behavior Tree는 복잡한 행동 알고리즘을 Node 라는 작업단위로 잘게 나눕니다.
각 노드는 성공, 실패, 실행 중 상태를 반환할 수 있으며, 트리를 통해 상위노드로 전달되어 전체 트리의 실행 흐름을 결정합니다.
Tree의 가장 끝 부분에서 실제로 동작을 수행하는 노드입니다.
예를 들어, Move 노드에서는 오브젝트의 위치를 조작하고, Search 노드에선 주변을 탐색합니다.
여러 자식노드를 가지며 자식들의 상태에 따라 어떻게 실행할 지 결정합니다.
하나의 자식 노드를 갖고 실행을 제어합니다.
예를 들면, 실행 회수나 조건에 따라 실행을 건너뛰게 합니다.
밑에 예시가 있으니 이해가 잘 안되시면 참고하기 바랍니다.
public class Node
{
public NodeState state;
public Node parent;
protected List<Node> children = new();
private Dictionary<string, object> nodeData = new();
public Node(){
parent = null;
}
public Node(List<Node> children)
{
foreach (var child in children)
Attach(child);
}
/// <summary>
/// 해당 노드에 인자로 받은 노드를 Child로 입력
/// </summary>
private void Attach(Node child)
{
child.parent = this;
children.Add(child);
}
public virtual NodeState Evaluate () => NodeState.Failure;
public void SetNodeData(string key, object value)
{
nodeData[key] = value;
}
public object GetNodeData(string key)
{
if (nodeData.TryGetValue(key, out object ret))
{
return ret;
}
Node cur = this.parent;
while (cur != null)
{
ret = cur.GetNodeData(key);
if (ret != null) return ret;
cur = cur.parent;
}
return null;
}
public bool RemoveNodeData(string key)
{
if (nodeData.ContainsKey(key))
{
nodeData.Remove(key); return true;
}
Node cur = this.parent;
while (cur.parent != null)
{
if (cur.RemoveNodeData(key)) return true;
cur = cur.parent;
}
return false;
}
}
Node 클래스 입니다.
각 Node는 NodeState를 갖고 Evalute() 함수에서 자신의 state를 판단합니다.
Composite Node는 자식들의 Evalute() 함수를 순서대로 호출합니다.
Sequence는 Failure나 Running이 나오면 중단하고
Selector는 Success나 Running이 나오면 중단하며 상태를 반환합니다.
Leaf Node 같은 경우에는 각 노드의 기능에 맞게 만들어야 합니다.
public abstract class Tree : MonoBehaviour
{
private Node root = null;
private void Start()
{
root = SetupRoot();
}
private void Update()
{
if (root != null) root.Evaluate();
}
public abstract Node SetupRoot();
}
Tree에서 root의 Evaluate() 함수만 호출합니다.
Composite Node가 자식들의 Evaluate() 함수를 전부 호출하기 때문에 괜찮습니다.
위 클래스들을 상속받은 클래스들로 Node들을 구현해 보겠습니다.
public override NodeState Evaluate()
{
foreach (Node child in children)
{
switch (child.Evaluate()) {
case NodeState.Failure:
state = NodeState.Failure;
return state;
case NodeState.Success:
continue;
case NodeState.Running:
state = NodeState.Running;
return state;
}
}
state = NodeState.Success;
return state;
}
public override NodeState Evaluate()
{
foreach (Node child in children)
{
switch (child.Evaluate())
{
case NodeState.Failure:
continue;
case NodeState.Success:
state = NodeState.Success;
return state;
case NodeState.Running:
state = NodeState.Running;
return state;
default:
continue;
}
}
state = NodeState.Failure;
return state;
}
Sequence와 Selector Node는 위에서 말한 그대로 만들면 됩니다.
이 부분에선 Behavior Tree가 제대로 작동하는지 확인하기 위해서 간단하게 만들었습니다.
다음은 제가 만들 Behavior Tree를 그래프로 시각화한 것입니다.

왼쪽 Leaf노드부터 보겠습니다.
Search : 주변에 적이 있는지 찾습니다. 적이 있으면 Failure, 아니면 Success를 반환합니다.
Failure를 반환하면 Sequence는 중단되고 Selector는 다음 Sequence를 실행시킵니다.
Success를 반환하면 Move를 실행시킵니다.
Move : 오른쪽으로 움직입니다. 항상 Running을 반환합니다.
( 테스트를 위해서 오른쪽으로만 가도록 했습니다. )
IsAttacking : 현재 공격중인지 확인합니다. 공격중이면 Failure, 아니면 Success를 반환합니다.
Failure를 반환하면 더 이상 실행하지 않습니다.
Success를 반환하면 Sequence에 의해 Track을 실행합니다.
Track : Search에서 찾은 적을 추적합니다. 공격 사거리 안에 들어오면 Success, 아니면 Failure를 반환합니다.
Failure를 반환하면 더 이상 실행하지 않습니다.
Success를 반환하면 Sequence에 의해 Attack을 실행합니다.
Attack : 적을 공격합니다.
각 클래스의 Evaluate() 함수만 보겠습니다.
public override NodeState Evaluate()
{
var cols = Physics2D.OverlapCircleAll(transform.position, searchRange);
foreach (var col in cols)
{
if (col.CompareTag("Monster"))
{
parent.parent.SetNodeData("BossObject", col.gameObject);
return NodeState.Failure;
}
}
return NodeState.Success;
}
public override NodeState Evaluate()
{
animator.SetBool("Walk", true);
animator.SetFloat("X", moveSpeed);
animator.SetFloat("Y", 0);
rigid.MovePosition(transform.position + new Vector3(moveSpeed, 0, 0));
return NodeState.Running;
}
public override NodeState Evaluate()
{
var isAtt= animator.GetBool("Attack");
if (isAtt) return NodeState.Failure;
else return NodeState.Success;
}
public override NodeState Evaluate()
{
var boss = (GameObject)GetNodeData("BossObject");
Vector3 dir = boss.transform.position - transform.position;
float dis2 = dir.x * dir.x + dir.y * dir.y;
animator.SetFloat("X", dir.x);
animator.SetFloat("Y", dir.y);
if (dis2 < attackRange * attackRange) {
animator.SetBool("Walk", false);
return NodeState.Success;
}
animator.SetBool("Walk", true);
dir.Normalize();
rigid.MovePosition(transform.position + trackSpeed * dir);
return NodeState.Running;
}
public override NodeState Evaluate()
{
if (!animator.GetBool("Attack"))
animator.SetTrigger("Attack");
var tr = (GameObject)GetNodeData("BossObject");
return NodeState.Success;
}
애니메이션을 추가해서 코드가 조금 지저분해진 것 같습니다.
추후에 Enemy AI를 구현하면서 재사용이 불가능하면 수정하도록 하겠습니다.
실제로 오브젝트에 컴포넌트로 붙이고 사용하는 클래스입니다.
위 그림에서 그래프로 나타낸 대로 만들어줍니다.
특히, 순서는 곧 중요도를 나타내기 때문에 굉장히 중요합니다.
public class EnemyBT : Tree
{
[SerializeField] private int searchRange;
[SerializeField] private int attackRange;
[SerializeField] private float moveSpeed;
[SerializeField] private float trackSpeed;
public override Node SetupRoot()
{
Node root = new Selector(new List<Node> {
new Sequence(new List<Node>
{
new Search(transform, searchRange),
new Move(transform, moveSpeed)
}),
new Sequence(new List<Node>
{
new IsAttacking(transform),
new Track(transform, attackRange, trackSpeed),
new Attack(transform)
})
});
return root;
}
}
아래는 실행화면입니다.

원래 가야하는 길은 오른쪽이지만, 주변에 몬스터가 있으면 추적해서 공격하는 모습을 볼 수 있습니다.
IsAttacking 클래스를 사용함으로써 공격중에 Track이 실행되지 않도록 했습니다.
또한, 사용한 에셋의 애니메이터가 너무 잘 만들어져 있어서 시간을 절약할 수 있었습니다.
다음은 생성한 맵에서 길찾기를 만들어 보겠습니다.
https://en.wikipedia.org/wiki/Behavior_tree_(artificial_intelligence,_robotics_and_control)
https://www.youtube.com/watch?v=aR6wt5BlE-E
https://assetstore.unity.com/packages/2d/environments/minifantasy-dungeon-206693