LeeTaes 공부노트

[UE Team Project/T.A.] 11. Boss AI 본문

프로젝트/TimelessAdventure

[UE Team Project/T.A.] 11. Boss AI

리태s 2024. 10. 22. 10:47
728x90
반응형

개요

지금까지는 전부 팀원 개개인별로 같은 내용을 제작하고, 가장 좋은 결과물을 뽑는 방식으로 진행했습니다. 하지만 프로젝트 기한이 얼마 남지 않은 시점에서, 팀원들 전부 구현을 마무리짓지 못해 제가 만든 코드만 사용하다 보니,, 개인 프로젝트가 될 것 같아 이후부터는 작업을 나눠 진행하게 되었습니다.

 

남은 부분은 몬스터(일반 몬스터, 보스 몬스터), 기믹(시간 정지, 그랩)이며 팀원들이 선택하지 않은 보스 몬스터를 제가 맡아 구현하게 되었습니다.

 

그래서 이번 포스팅에서는 보스 몬스터에 대해 정리해보도록 하겠습니다.


보스 구현 아이디어 설명

학원 수업에서는 AI와 관련된 부분을 블루프린트로 진행했지만, 몬스터를 처음 작업해보기에 C++을 활용해 Task, Service, Decorator 노드 등을 제작해 볼 예정입니다.

 

제가 생각한 보스 몬스터의 주요 특징은 다음과 같습니다.

1. 플레이어와 거리가 떨어져 있는 경우 빠르게 근접해야 하는 수단이 필요

2. 체력이 절반 이하로 내려가면 특수 동작 필요

3. 여러 종류의 공격을 랜덤하게 수행

4. 시간 정지 기믹으로 멈출 수 있는 원거리 공격

 

보스 몬스터는 상태를 기반으로 현재 행동을 결정하고 수행하도록 만들었습니다.

BehaviorTree


Boss State, AI Key

우선 보스의 상태를 구분짓기 위한 열거형을 선언해주도록 하겠습니다.

여러 파일에서 가볍게 호출이 가능하도록 별도의 헤더 파일로 분리해서 관리해줬습니다.

 

TA_AIState.h

더보기
#pragma once

UENUM(BlueprintType)
enum class EBossState : uint8
{
    BS_Idle UMETA(DisplayName = "Idle"),
    BS_Attack UMETA(DisplayName = "Attack"),
    BS_Special UMETA(DisplayName = "Special"),
    BS_Die UMETA(DisplayName = "Die"),
};

또한, AI 작업을 진행하다 보면 Blackboard에 접근해 키값을 토대로 값을 설정하고 가져오는 일이 빈번합니다.

즉, 키 값을 매 번 찾아서 복사/붙여넣기 해야하는데 이런 수고를 줄여주기 위해 AI Key값을 매크로로 지정해주었습니다.

 

참고로 제가 Blackboard Data에 저장하고 사용할 데이터는 다음과 같습니다.

Blackboard Data

TA_AIKeys.h

더보기
#pragma once

#define BBKEY_HOMEPOS TEXT("HomePos")
#define BBKEY_PLAYER TEXT("Player")
#define BBKEY_STATE TEXT("State")

AI Controller

다음으로 보스를 움직이기 위해 AI Controller를 제작해줘야 합니다.

 

AI Controller에서는 보스 스폰과 동시에 Possess(빙의)하는 기능을 추가해야 하며, 행동 트리(Behavior Tree)를 실행시켜줘야 합니다.

 

AI Controller에 대한 자세한 설명은 다음 포스팅을 참고해주세요.

https://apth1023.tistory.com/135

 

[Unreal Engine 5] AI Controller

개요이번 포스팅에서는 몬스터 AI를 구현하기 위해 필요한 AI Controller에 대해 다루어보도록 하겠습니다.Controller언리얼 엔진의 Controller는 Pawn(폰) 또는 Character(캐릭터)처럼 폰에서 파생된 클래스

apth1023.tistory.com

 

AIController.h

더보기
UCLASS()
class TIMELESSADVENTURE_API ATA_BossController : public AAIController
{
    GENERATED_BODY()
	
public:
    ATA_BossController();

    // AI Start
    void RunAI();
    // AI Stop
    void StopAI();

protected:
    // 빙의
    virtual void OnPossess(APawn* InPawn) override;
    // 빙의 해제
    virtual void OnUnPossess() override;

private:
    // BlackBoard, BehaviorTree
    UPROPERTY(EditAnywhere, Category = "AI")
    TObjectPtr<class UBlackboardData> BB_Asset;

    UPROPERTY(EditAnywhere, Category = "AI")
    TObjectPtr<class UBehaviorTree> BT_Asset;
};

OnPossess 함수는 Blackboard Component를 받아와 BlackboardData를 설정하고 BehaviorTree를 동작시키는 기능을 추가했습니다.

 

OnUnPossess 함수는 BehaviorTree Component를 받아와 AI 동작을 종료시키는 기능을 추가했습니다.


Idle State

몬스터가 배치되면 AI Controller에서 기본적으로 state를 Idle로 지정합니다.

Idle 상태인 경우 기본적으로 대기하며, 플레이어를 탐색하고 플레이어를 찾은 경우 Attack 상태로 상태를 전환했습니다.

 

플레이어를 찾는 로직은 매 프레임 실행되면 당연히 너무 무거워지며, 이를 방지하기 위해 약 1초마다 반복 실행하도록 Service 노드를 추가하였습니다.

 

플레이어 탐지 거리를 에디터 상에서 변경해가며 체크하기 위해 UPROPERTY(EditAnywhere) 매크로를 붙였습니다.

 

BTS_Detect Class

더보기
// header

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "BTS_Detect.generated.h"

/**
 * 
 */
UCLASS()
class TIMELESSADVENTURE_API UBTS_Detect : public UBTService
{
    GENERATED_BODY()
	
public:
    UBTS_Detect();

protected:
    virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

private:
    // 탐지 거리
    UPROPERTY(EditAnywhere)
    float DetectDistance;
};
// cpp


#include "Monster/Service/BTS_Detect.h"
#include "Monster/TA_AIKeys.h"

#include "AIController.h"
#include "GameFramework/Character.h"
#include "Engine/OverlapResult.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTS_Detect::UBTS_Detect()
{
   // 호출 간격 설정
   Interval = 1.0f;

   // 탐지 거리 설정
   DetectDistance = 1000.0f;
}

void UBTS_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

    // 몬스터 Pawn 받아오기
    APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();

    TArray<FOverlapResult> OverlapResults;

    FCollisionQueryParams Params;
    Params.AddIgnoredActor(ControllingPawn);

    // SphereTrace 진행
    bool bHasHit = GetWorld()->OverlapMultiByChannel(
        OverlapResults,
        ControllingPawn->GetActorLocation(),
        FQuat::Identity,
        ECollisionChannel::ECC_GameTraceChannel5,
        FCollisionShape::MakeSphere(DetectDistance),
        Params
    );

    // 충돌이 발생한 경우
    if (bHasHit)
    {
        // 순회하며 플레이어인지 체크
        for (const FOverlapResult& Result : OverlapResults)
        {
            // Character 타입으로 형변환
            ACharacter* Character = Cast<ACharacter>(Result.GetActor());
            // Character 유효성 및 플레이어인지 체크
            if (Character && Character->GetController()->IsPlayerController())
            {
                // 플레이어인 경우이므로 BlackBoard에 추가
                OwnerComp.GetAIOwner()->GetBlackboardComponent()->SetValueAsObject(BBKEY_PLAYER, Character);
            }
        }
    }
}

위 과정을 통해 플레이어를 탐지하면 Idle 애니메이션을 재생하고 Attack 상태로 전환합니다.


Attack State

몬스터의 공격 상태입니다. 이 상태에서는 플레이어와의 거리를 체크하고 일정 거리를 넘어가면 원거리, 일정 거리 미만이면 근거리 공격을 수행하도록 로직을 구성하였습니다.

 

공격이 완료되면 체력이 절반 이하인지 체크하고, 절반 이하인 경우 Special(특수 상태)로 전환했습니다.

 

Decorator 노드의 CalculateRawConditionValue() 함수는 반환값이 bool 타입이며 이를 통해 플레이어와 몬스터의 현재 거리가 특정 거리를 기준으로 원거리에 속하는지, 근거리에 속하는지를 체크합니다.

 

DistanceCheck Class

더보기
// header
#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "BTD_DistanceCheck.generated.h"

/**
 * 
 */
UCLASS()
class TIMELESSADVENTURE_API UBTD_DistanceCheck : public UBTDecorator
{
    GENERATED_BODY()

public:
    UBTD_DistanceCheck();

protected:
    virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;

private:
    UPROPERTY(EditAnywhere)
    float CheckDistance;
};
// cpp


#include "Monster/Decorator/BTD_DistanceCheck.h"
#include "Monster/TA_BossController.h"
#include "Monster/TA_AIKeys.h"

#include "BehaviorTree/BlackboardComponent.h"

UBTD_DistanceCheck::UBTD_DistanceCheck()
{
    // 기본 거리 설정
    CheckDistance = 1000.0f;
}

bool UBTD_DistanceCheck::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
    Super::CalculateRawConditionValue(OwnerComp, NodeMemory);

    // AI Controller 가져오기 
    ATA_BossController* AIController = Cast<ATA_BossController>(OwnerComp.GetAIOwner());
    if (AIController)
    {
        // 플레이어 찾기
        AActor* Target = Cast<AActor>(AIController->GetBlackboardComponent()->GetValueAsObject(BBKEY_PLAYER));
        if (Target)
        {
            // 플레이어와 자신 사이의 거리 체크
            float Distance = (Target->GetActorLocation() - AIController->GetPawn()->GetActorLocation()).Length();

            if (Distance >= CheckDistance)
            {
                return true;
            }
            else
            {
                return false;
            }
        }
    }

    return false;
}

공격은 Task 노드를 제작하여 구현하였습니다. 원거리/근거리 전부 유사한 로직이기에 근접 공격 Task만 설명하도록 하겠습니다. 우선 Task 노드의 ExecuteTask()는 실행 함수로, 현재 Task가 성공했는지, 실패했는지, 진행중인지를 반환해줍니다. 여기서 진행중을 반환받으면 Behavior Tree는 해당 노드에서 정지하게 되기에 무조건 성공/실패를 체크하는 로직을 작성해줘야 합니다.

 

참고로 의존성을 낮추기 위해 Monster Interface를 추가하여 몬스터에서 필요한 데이터만 가져올 수 있도록 구조를 설계하였습니다.

 

MonsterInterface.h

더보기
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "Monster/TA_AIState.h"
#include "MonsterInterface.generated.h"

// 공격 종료 델리게이트
DECLARE_DELEGATE(FOnAttackEndDelegate)
// 점프 종료 델리게이트
DECLARE_DELEGATE(FOnJumpBackEndDelegate)

// This class does not need to be modified.
UINTERFACE(MinimalAPI)
class UMonsterInterface : public UInterface
{
	GENERATED_BODY()
};

/**
 * 
 */
class TIMELESSADVENTURE_API IMonsterInterface
{
    GENERATED_BODY()

// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
    virtual void ChangeState(EBossState NewState) = 0;
    virtual void RangedAttack() = 0;
    virtual void MeleeAttack() = 0;

    virtual void SpawnStone() = 0;
    virtual void Throw() = 0;
    virtual float GetDamage() = 0;

    virtual	void BaseAttackCheck() = 0;
    virtual	void KnockbackAttackCheck() = 0;
    virtual void JumpAttackCheck() = 0;

    virtual void JumpBack(float Distance) = 0;
    
    virtual void SetAIAttackDelegate(const FOnAttackEndDelegate& OnAttackEnd) = 0;
    virtual void SetAIJumpDelegate(const FOnJumpBackEndDelegate& OnJumpEnd) = 0;

    virtual float GetHealthPercent() = 0;
    virtual void Die() = 0;
};

 

BTT_MeleeAttack Class

더보기
// header

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTT_MeleeAttack.generated.h"

/**
 * 
 */
UCLASS()
class TIMELESSADVENTURE_API UBTT_MeleeAttack : public UBTTaskNode
{
    GENERATED_BODY()
	
public:
    UBTT_MeleeAttack();

protected:
    // 실행 함수
    virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
    // 중단시 처리 함수
    virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
// cpp


#include "Monster/Task/BTT_MeleeAttack.h"
#include "Interface/MonsterInterface.h"
#include "Monster/TA_BossController.h"

UBTT_MeleeAttack::UBTT_MeleeAttack()
{
}

EBTNodeResult::Type UBTT_MeleeAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

    // AI Controller 가져오기
    ATA_BossController* AIController = Cast<ATA_BossController>(OwnerComp.GetAIOwner());
    if (AIController)
    {
        // Monster Interface 가져오기
        IMonsterInterface* MonsterInterface = Cast<IMonsterInterface>(AIController->GetPawn());
        if (MonsterInterface)
        {
            // 공격
            MonsterInterface->MeleeAttack();

            // 공격 종료 델리게이트 생성
            FOnAttackEndDelegate OnAttackEndDelegate;
            OnAttackEndDelegate.BindLambda(
                [&]()
                {
                    // 공격이 종료되면 해당 node를 성공처리합니다.
                    FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
                }
            );

            MonsterInterface->SetAIAttackDelegate(OnAttackEndDelegate);

            // 진행 중 반환
            return EBTNodeResult::InProgress;
        }
    }

    return EBTNodeResult::Failed;
}

EBTNodeResult::Type UBTT_MeleeAttack::AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    return EBTNodeResult::Type();
}

참고로 MeleeAttack 함수가 호출되면 랜덤한 인덱스를 뽑아 공격을 수행하도록 제작하였습니다.

(원거리 공격[특수]나, 뒤로 점프[특수 행동]는 쿨타임을 적용)

랜덤 공격


Special State

이름은 Special이지만 쿨타임을 가지고 플레이어와 거리를 벌리는 기능입니다.

즉, 체력이 절반 이하인 경우 특수 공격(원거리 투사체 발사)를 할 수 있어야 하며, 이를 위해 추가한 상태입니다.


Die State

Die 상태는 몬스터의 체력이 0이 되면 전환되는 상태입니다.

사망 Anim Montage를 재생하며, AnimNotify를 통해 사망 처리 로직을 구현했습니다.

728x90
반응형