강의에서 알려준 방식의 BT가 처음부터 마음에 들지 않았다. 모든 AI가 BT를 공유하는데, 근접 공격 적들은 모두 150의 사거리, 원거리 공격 적들은 모두 500의 사거리를 갖고 있었다. 그리고 이걸 무슨 조건으로 적용하느냐 하면, 캐릭터의 '직업' 역할을 하는 멤버 변수(Enum)을 통해 판단한다. 그러니까, 원거리 공격을 하는 Warrior 캐릭터는 존재할 수 없고, 근접 공격을 하는 Elementalist는 존재할 수 없는 상황인 거다.
그래서 이게 어떻게 구현되어있느냐..
트리 자체를 학습한다는 생각으로 다가가면 나쁘지 않은 것 같다. 트리가 어떻게 동작하는 건지, 역할이 뭔지, 왜 쓰는 건지.. 하지만 나는 그런 레벨에서 머무르고 싶지 않다. 강의를 들으면 단순히 사용하는 사람이 아니라, 그 즉시 응용할 수 있는 사람이 되고 싶다. 일단 문제가 바로 보이니까 하나씩 해결해보자.
강의에서 2번 문제를 해결한 방식도 당연히 문제를 껴안고 있다. 나중에 AI마다 다른 로직이 필요해서 트리가 20개, 30개가 되었고, 공통된 부분에 수정요구사항이 생긴다면? 그 많은 트리들을 전부 들어가서 수정해야 하는 일이 생긴다. 그래서 해결할 방법들을 찾아봤다.
SubTree
트리는 기본적으로 상속이 안 된다. 따라서 '공통 로직'은 MainTree에서 해결하고, 조건이 갖춰지면 SubTree로 진입한다. (챗gpt에 의하면)현업에서 사용하는 방식이라고 한다. 나도 그렇게 구현해봤다.
가장 먼저 float 2개의 값을 비교할 수 있는 Decorator부터 만들었다. 그냥 간단하게 FName으로 Blackboard Key 이름을 적어 사용하는 Decorator다.
기존 노드 2개(위쪽 2개)는 유지했다. 가장 가까운 플레이어를 찾아 저장하고, 아래 분기들로 진입해도 되는지를 판단하는 노드들이다.
조건을 만족하면 Run Combat Tree로 진입한다.
이건 범용 CombatTree다. Shaman은 다른 CombatTree를 사용한다. 그리고 좌측 2개의 노드가 원거리 공격 전용으로 보일 수 있는데, 그냥 장애물을 지나 공격할 수 있는 위치를 찾는 Task일 뿐이다. 따라서 근접 공격에서도 똑같이 작동한다. 사실 발사체가 없다면 무용지물이지만, 근접 공격 캐릭터도 일부 스킬은 원거리가 될 수 있으니 그냥 이거로 퉁친다. 이름은 바꿀 거다. 어쨌든 공격 가능한 상태인지 조건을 확인하고, 공격할 위치를 찾아 이동한 뒤 공격 Task를 실행한다.
CombatTree로 진입하지 못 했다면 2번째 SubTree인 AgroTree로 진입한다.
마찬가지로 범용 AgroTree다. 경계 거리 안으로 들어왔지만 전투를 시작할 거리는 아닌 경우 이 트리로 들어온다. 원한다면 이것도 만들어서 끼워주면 된다.
이 조건 모두에 부합하지 않는다면 아무 행동도 하지 않는다.
그리고 Run Combat Tree와 Run Agro Tree는 Run Behavior Dynamic 노드인데, Injection Tag를 통해 트리를 주입받는다. 만약 주입받지 못 한 경우 Default Behavior Asset을 사용해 작동한다.
이건 Shaman 전용 CombatTree다. 별로 특별할 것 없이 Task만 교체해줬다. 나중에 더 복잡한 로직을 원하는 적이 기획된다면 새로운 CombatTree를 생성해 로직을 작성해주면 된다.
Shaman용 Attack Task의 구현부다. 나머지 부분은 범용 Attack Task와 일치한다. 단순하게 하수인 소환 가능한 상태인지 확인하고, 가능하면 Abilities.Summon을, 아니라면 Abilities.Attack을 발동한다.
SummonComponent도 작성했으나, 단순히 하수인의 수를 관리하는 Component기 때문에 간단해서 생략했다.
그럼 이제 코드를 확인해보자.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AI")
float AgroRange = 1000.f;
// 일반적으로 근접 공격 캐릭터는 150, 원거리 공격 캐릭터는 600을 사용합니다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AI")
float CombatRange = 150.f;
// 메인 BT 안에 서브로 들어가는 Tree들입니다.
// 기본값은 메인 BT에서 직접 할당하기 때문에, 범용 SubTree를 사용하는 경우 값을 할당하지 않습니다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AI")
TObjectPtr<UBehaviorTree> AgroBehaviorTree;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AI")
TObjectPtr<UBehaviorTree> CombatBehaviorTree;
Enemy 클래스에 새롭게 선언된 멤버변수들이다. 트리에서 사용할 본인의 경계 거리 및 전투 거리, 그리고 SubTree들이다.
void AAuraEnemy::PossessedBy(AController* NewController)
{
// 아래 값 할당 함수들은 반드시 RunBehaviorTree 이후에 호출합니다.
AuraAIController->RunBehaviorTree(BehaviorTree);
...
AuraAIController->GetBlackboardComponent()->SetValueAsFloat(FName("AgroRange"), AgroRange);
AuraAIController->GetBlackboardComponent()->SetValueAsFloat(FName("CombatRange"), CombatRange);
if (AgroBehaviorTree)
{
AuraAIController->BehaviorTreeComponent->SetDynamicSubtree(FAuraGameplayTags::Get().BT_Sub_Agro, AgroBT);
}
if (CombatBehaviorTree)
{
AuraAIController->BehaviorTreeComponent->SetDynamicSubtree(FAuraGameplayTags::Get().BT_Sub_Combat, CombatBT);
}
}
AIController가 가진 BlackboardComponent 및 BehaviorTreeComponent로 접근해서 값들을 할당한다. 태그 선언은 평소대로 했다.
마지막으로 Shaman 블루프린트 클래스에서 Shaman용 CombatTree를 할당해준다.
정리하면 이렇다.
의도대로 3마리까지 스폰시킨 뒤, 기본 원거리 공격을 실행하는 걸 볼 수 있다.