
EnemyAI를 사용해서 tag를 통해 적을 인식하고 Track, 적이 죽으면 Move하는 부분까지 만들었습니다. 이제 저희가 소환할 GoblinBT를 만들어서 고블린과 적이 서로 싸우도록 만들어보겠습니다.
일단 GoblinBT 에 EnemyBT를 그대로 복붙해주고 겹치는 부분들은 전부 BTree 로 올려줍니다.
그 후에, Move노드를 없애주고 클래스에 Tag를 적절하게 넘겨줍니다.
public class GoblinBT : BTree
{
[SerializeField] private int searchRange;
[SerializeField] private int attackRange;
public override Node SetupRoot()
{
Node root = new Selector(new List<Node> {
new Sequence(new List<Node>
{
new IsDead(transform, enemyStat),
new Disappear(transform, enemyStat)
}) ,
new Sequence(new List<Node>
{
new Search(transform, searchRange, "Human")
}),
new Sequence(new List<Node>
{
new IsAttacking(transform),
new Track(transform, attackRange, enemyStat),
new Attack(transform, enemyStat)
})
});
return root;
}
}
public abstract class BTree : MonoBehaviour
{
// Constructs
private Node root = null;
private void OnEnable()
{
root = SetupRoot();
}
private void Update()
{
if (root != null) root.Evaluate();
}
public abstract Node SetupRoot();
// Contents
protected EnemyStat enemyStat = new EnemyStat(0.05f, 3, 10);
public static void SetAnimatior(Animator anim, string name)
{
anim.SetBool("Attack", false);
anim.SetBool("Die", false);
anim.SetBool("Damage", false);
anim.SetBool("Walk", false);
anim.SetBool("Idle", false);
anim.SetBool(name, true);
}
public bool OnDamaged(float damage)
{
enemyStat.Hp -= damage;
if (enemyStat.Hp > 0)
{
var animator = transform.GetComponent<Animator>();
SetAnimatior(animator, "Damage");
return false;
}
else return true;
}
public void OnRecover(float damage)
{
if (enemyStat.Hp > 0)
{
enemyStat.Hp += damage;
}
}
public void MoveDebuff(float w)
{
enemyStat.MoveSpeed /= w;
}
public void MoveBuff(float w)
{
enemyStat.MoveSpeed *= w;
}
}
이렇게 한 후에 실행을 해보면 치명적인 문제가 생깁니다.

이동을 하는 도중에는 경로를 새로 찾지않기 때문에 계속 엇갈리게 됩니다.
GoblinBT를 위한 LinearTrack Action Node를 따로 만들어줍니다.
public class LinearTrack : Node
{
private Transform transform;
private int attackRange;
private Animator animator;
private Rigidbody2D rigid;
private EnemyStat stat;
public LinearTrack(Transform transform, int attackRange, EnemyStat stat)
{
this.stat = stat;
this.transform = transform;
this.attackRange = attackRange;
this.animator = transform.GetComponent<Animator>();
this.rigid = transform.GetComponent<Rigidbody2D>();
}
public override NodeState Evaluate()
{
// BossObject 변수 받고 따라가기
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);
// 성공 -> Seq의 다음노드 실행
if (dis2 < attackRange * attackRange)
{
animator.SetBool("Walk", false);
return NodeState.Success;
}
EnemyBT.SetAnimatior(animator, "Walk");
dir.Normalize();
rigid.MovePosition(transform.position + stat.MoveSpeed * dir);
return NodeState.Running;
}
}
Track을 그대로 복붙하고 경로를 찾는 부분을 삭제하고 탐지한 Enemy의 위치를 따라가도록 만들어줍니다.
이렇게 되면 정상적으로 AutoBattle이 가능하게 됩니다.

여기에 원거리 고블린을 추가해보겠습니다.

Range를 [SerializeField] 로 에디터에서 조작할 수 있으니, 프리팹과 카드를 새로만들고 Search, Attack Range를 6, 5로 설정해줍니다.

에셋은 근거리 밖에 없으므로 전부 만들고 나중에 따로 그릴 예정입니다.
지금 Attack Node가 애니메이션이 끝나면 다시 실행될수 있게 되어있습니다.
에디터에서 공격속도를 조절할 수가 없는게 확장성에 좋지 않아보입니다.
public override NodeState Evaluate()
{
if (GetNodeData("attackFlag") != null)
{
coolTime += Time.deltaTime;
}
if (coolTime >= stat.Cooltime)
{
coolTime = 0;
EnemyBT.SetAnimatior(animator, "Idle");
RemoveNodeData("attackFlag");
}
var cols = Physics2D.OverlapCircleAll(transform.position, searchRange);
foreach (var col in cols)
{
if (col.CompareTag(tagName))
{
parent.parent.SetNodeData("BossObject", col.gameObject);
parent.SetNodeData("pathfindFlag", true);
return NodeState.Failure;
}
}
if (GetNodeData("isTracked") != null || GetNodeData("attackFlag") != null)
{ return NodeState.Failure; }
//
return NodeState.Success;
}
Search 노드의 Evaluate함수를 위와 같이 변경해줍니다.
EnemyStat 클래스에 Cooltime을 저장해두고 Search Action Node의 멤버변수인 coolTime을 늘려가며 비교합니다.
public override NodeState Evaluate()
{
if (GetNodeData("attackFlag") == null)
{
parent.parent.SetNodeData("attackFlag", true);
EnemyBT.SetAnimatior(animator, "Attack");
// 죽으면 지워야됨
var tr = (GameObject)GetNodeData("BossObject");
if (tr == null)
{
RemoveNodeData("BossObject");
return NodeState.Failure;
}
// 떄리는 로직
// 때리고 죽었으면
if (tr.GetComponent<GoblinBT>() != null && tr.GetComponent<GoblinBT>().OnDamaged(stat.Attack))
{
tr.tag = "Dying";
RemoveNodeData("BossObject");
}
if (tr.GetComponent<EnemyBT>() != null && tr.GetComponent<EnemyBT>().OnDamaged(stat.Attack))
{
tr.tag = "Dying";
RemoveNodeData("BossObject");
}
}
return NodeState.Success;
}
}
NodeData를 사용해서 Attack 노드가 attackFlag가 없을 때에만 실행되게 하고, 실행되면 바로 NodeData를 생성해서 Search에서 지워주기 전까지는 실행되지 않도록 합니다.
그 밑의 tag를 Dying으로 바꿔주는 것은 죽는 애니메이션이 실행될 동안 Search에 탐지되어서 공격모션이 나가는 경우가 있기 때문입니다.
이렇게 되면 쿨타임을 직접 설정할 수 있습니다.
아래는 2초로 설정하고 실행한 화면입니다.

이렇게 만들어두면 나중에 컨텐츠를 늘릴 때 훨씬 쉬워질 것 같습니다.
적이든 몬스터든 마법을 사용하거나 뭔가 던지는 것도 쉽게 구현할 수 있게 되었습니다.
하지만 컨텐츠를 만들기 전에 전체적인 틀을 먼저 잡는게 중요한 것 같아서, 다음은 이벤트 시스템을 만들어보겠습니다. 각 스텝마다 랜덤적으로 적이 나오거나 NPC가 나오는 등의 이벤트 시스템을 구현해볼 예정입니다.