Enemy를 움직이고, 공격하고 여러 행동을 가지도록 구현한다.

NavMeshBoundsVolume

단순히 Enemy 를 다른 위치로 옮기는 것이 아니라 최단 경로를 탐색하여 이동하게 해야 할 때 사용한다.
좌측 상단의 Selection Mode 옆의 정육면체를 클릭하고, NavMeshBoundsVolume 을 월드에 드래그드랍한다.
P 키를 누르면 volume 확인도 가능하다. 이를 통해 실질적으로 경로탐색 가능한 범위를 확인할 수 있다.
크기를 넓혀 맵 전체에 적용되도록 한다.
BP_Enemy 에서 월드에 배치되거나 스폰되었을 경우 AI를 소유하도록 변경한다.
Event Graph 에서 Get AIController 노드를 배치한다.
Get a reference to self 노드를 이용하여 자기자신에 대한 참조를 통해 Contolled Actor 핀과 연결시켜 AIController 를 소유하도록 한다.
이제 EnemyAIController 를 소유하므로 Move to Location 노드를 연결하여 특정 위치로 움직일 수 있도록 한다.

  • Dest
    목적지 벡터
  • Acceptance Radius
    수용반경, 목표지점에 도달후 목표지점 주변을 돌아다니지 않도록 하기 위함
  • Use Pathfinding
    길찾기 알고리즘 사용 여부, 해제시 목적지까지 그냥 직진이동(길 막히면 도달못함)
  • Project Destination to Navigation
    목적지가 공중에 있는 경우, 체크박스 활성화시 목적지가 네비게이션에 투영됨
  • Filer Class
    추가적인 경로 지향 옵션
    Dest 는 필수로 필요하므로 핀을 분리해 xyz(0, 0, 0)으로 이동하도록 한 뒤, 컴파일후 실행하면 Enemy 가 해당 위치로 이동하는 것을 확인할 수 있다.
    캐릭터와 같은 Actor 를 목표로 하고 싶다면 Move to Actor 노드를 이용하면 된다.
    ACtor 타입의 변수 Target 을 생성하고
    Goal 핀과 연결해 준 뒤
    Instancee Editable 을 활성화해주면
    UPROPERTYEditInstanceOnly 와 같이 사용할 수 있다.
    실행시 Target으로 이동하는 것을 확인할 수 있다.
    Target Point 를 생성하여 이용할 수도 있다.
    Place Actors 패널을 열고 Target Point 를 검색한 후 월드에 드래그드랍하여 배치할 수 있다.
    월드에 배치하고
    동일하게 Target을 배치해둔 Target Point 로 지정하면 그 위치로 이동한다(스포이드 같은거 클릭하고 Target Point 클릭시 직접 검색하지 않아도 설정 가능).


    월드에 존재하는 캐릭터에게 향하는 방법도 있다.
    단순 구현 목적이라 추후에 제대로 구현할 예정.
    BP_Enemy 에서 Get All Actors Of Class 노드를 생성한다.
    WraithCharacter 클래스를 넣어주고, Get(a copy) 노드를 생성해 연결해준다.
    기존의 노드들과 연결시켜주면 끝.
    Move to Actor 노드가 한번 작동하므로, Delay 노드를 이용해 루프를 걸어주면 계속해서 캐릭터를 따라온다.
    CharacterMovementComponent 에서 Walk Speed 를 조절하면 적당한 속도로 따라오는 Enemy를 만들 수 있다.


    캐릭터의 움직임은 일단 여기까지 하고, 생성한 Navigation Mesh 에 몇가지 문제가 있다.
  1. 오브젝트 파괴후에도, 해당구역 경로로 설정 불가
    게임을 실행하고 Show Navigation 명령어를 입력한 후, 항아리를 파괴해도 해당 경로는 탐색하지 않는 문제가 발생한다.
    Navigation Mesh 를 재생성하기 위해서 좌측 상단의 Edit -> Project Settings -> Navigation Mesh 로 들어간다.
    하단의 Runtime -> Runtime GenerationDynamic 으로 변경해주면,Navigation Mesh 주변이 빨간색으로 동적으로 조정되는 것을 확인할 수 있다.
  2. Nav mesh 경계 볼륨크기
    오브젝트 크기에 비해 볼륨 사이즈가 매우 크다.
    캐릭터 하나는 충분히 들어가고도 남을 공간이지만, 명령어로 확인하였을 땐, 경로를 탐색하지 않는 구역인 상태이다.
    이런 셀(경로 탐색 불가능하도록 하는 일종의 구역) 사이즈를 변경시킬수 있다.
    동일하게 Project Settings ->Navigation Mesh -> Generation -> Cell Size 를 수정하면 변경가능하다.(Low 말고 Default에서 변경)
    Cell Height 를 변경하면 해당 높이의 장애물은 인식하지 않아서 경로탐색이 가능해진다.
    다만 아무리 셀의 높이를 조절해도 물리적으로 불가능한 경우(점프해야지 올라갈 수 있는 경우)에는 따라오지 않는다.(경로탐색상 가능하지만 물리적으로 도달불가능할 경우 벽에 비비는 현상 발생) 그러므로 적절히 지형지물을 배치해 문제가 발생하지 않도록 해야한다.

걷고 달리는 애니메이션 추가

적이 패트롤 상태일땐 걷는 애니메이션을 재생하다가, 일정 범위 내에 적이 발견되면 적에게 뛰어오도록 구현하고 싶다면, 먼저 걷고 뛰는 애니메이션을 추가해줘야 한다.


우선 걷고, 뛰고, 공격하는 애니메이션 에셋을 다운받는다.
root bone 이 없는 경우, 블렌더를 통해 추가해준다.
Skeleton 을 적용시키고, 애니메이션 자체에 mesh 가 있다면, import mesh 는 비활성화한 후 import all 을 클릭한다.
적당히 사용하기 편하게 파일명을 변경한다.
애니메이션간 블렌드하는 방법을 이용하여 Idle, Walk, Run 애니메이션을 구현할 것이다.
먼저 Blend Space 1D 파일을 생성한다.
하단에 보면 Axis 가 하나 있는데 해당 Axis 기준으로 애니메이션을 블렌드할 수 있다.
좌측의 Axis Settings -> Horizontal Axis 에서 축의 이름과 Min, Max를 정할 수 있다.
위와 같이 변경하면 해당 축은 GroundSpeed 를 의미하게 되는 것이고 Maximum Axis Value 에 도달시 그에 따른 애니메이션을 재생하도록 할 수 있다.
Shift 키를 이용하면 애니메이션을 그래프 격자에 맞게 배치할 수 있고, Ctrl 키를 누르면 해당 속도에서 애니메이션이 어떻게 재생되는지 확인할 수 있다.
최종적으로 GroundSpeed 가 75까지는 천천히 걷는 애니메이션이 재생될 것이고, 75에 도달할 경우 완전히 걷는 애니메이션, 75를 넘어서면 천천히 뛰기 시작하면서, 300에 도달하면 뛰는 애니메이션이 재생된다.
ABP_EnemyIdle State 에서 기존의 Idle 노드와의 연결을 해제하고, 생성한 BlendSpace1D 를 연결시킨다.
노드에서 보다시피 GroundSpeed 를 받아와, 그 값을 통해 애니메이션을 블렌드하므로 GroundSpeedEventGraph 에서 받아와야 한다.
Enemy 에서 CharacterMovement 를 변수로 승격시키고
GroundSpeed 변수를 생성한 뒤
Blueprint Thread 에서 Property Access 노드를 추가하고, Character -> Velocity 로 설정한다.
필요한건 XY축 관련 velocity이므로 Velocity Length XY 노드와 연결시켜주고, GroundSpeed Setter 에 연결시켜 속도를 할당한다.
다시 Idle State 로 돌아가, GroundSpeed Getter를 연결시켜주면 된다.
실행하면 적이 달려올 때 진행방향을 보지 않고, 캐릭터를 보며 달리는 부자연스러운 현상이 발생한다.
이를 해결하기 위해서 Enemy -> Character Movement -> Orient Rotation to Movement(적의 움직이는 방향을 향한 회전) 를 활성화시킨다.
Use Controller Rotation Yaw 를 해제시키고 실행하면 제대로 작동하게 된다.(적이 Controller.Yaw의 회전에 영향받지 않게 됨으로써, 목표 타겟을 향헤 회전하게 됨)
회전 관련된 부분은 코드로도 작성 가능하다

  • Enemy.cpp
// Enemy.cpp
...
#include "GameFramework/CharacterMovementComponent.h"
...
...AEnemy::AEnemy()
{
	...
    GetCharacterMovement()->bOrientRotationToMovement = true;
    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;
    ...
}
...

좀더 디테일한 구현을 위해서 적의 최대 속도를 75로 조정해보면 애니메이션이 문워크 하듯이 부자연스럽게 걷는 것을 확인할 수 있다. 애니메이션 재생속도를 늦추던지, 걷는 속도를 높히던지 해야 한다.
애니메이션 재생속도를 늦추는 것은 더 부자연스러워질 수 있으므로, 걷기의 최대속도를 125로 변경한다.
블렌드스페이스에서 걷는 애니메이션을 125로 조정해준뒤 실행하면 잘 작동되는 것을 확인할 수 있다.

패트롤

패트롤할 지점을 Actor 타입의 PatrolTarget 으로 지정하고, TArray 타입의 배열에 저장해두면, 해당 지점을 패트롤할 수 있도록 구현할 수 있다.

  • Enemy.h
// Enemy.h
...
class AAIController;
...
private:
	...
    /**
    * Navigation
    */
    // 소유한 AIController
   	UPROPERTY()
    AAIController* EnemyController
    // 현재 PatrolTarget;
    UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
    AActor* PatrolTarget;
    // 패트롤 지점 배열
    UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
    TArray<AActor*> PatrolTargets;

AIController를 사용하기 위해서는 AIModule이라는 모듈을 추가해주어야 한다.

  • Study.Build.cs
	PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" , "GeometryCollectionEngine", "Niagara", "UMG", "AIMoudle"});
  • Enemy.cpp
// Enemy.cpp
...
#include " AIController.h"
...
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    ...
    EnemyController = Cast<AAIController>(GetController());
    if(EnemyController && PatrolTarget)
    {
    	// AIController로 Enemy를 이동시키기 위해 MoveTo() 함수 사용
        // MoveTo 함수의 parameter는 const FAIMoveRequest& MoveRequest, FNavPathSharedPtr *OutPath
        // 그러므로 FAIMoveReuqest 타입의 변수 MoveRequest 생성 후 GollActor와 AcceptanceRadius 설정(GaolActor는 추우에 TargetPoint를 월드에 배치)
        // 이어서 FNavPathSharedPtr 타입의 NavPath 생성 후 parameter로 입력(포인터 형식이므로 &키워드 사용
        // NavPath 주소를 전달함으로써 해당 주소 기반으로 MoveTo 작동하는것같음)
        // NavPathSharedPtr의 typedef은 FNavPath 클래스에 대한 스마트 포인터를 만들고 FNavePathSharedPtr로 별칭 지정
        // 그러므로 FNavPathShredPtr는 FNavPath(경로)에 대한 공유 포인터를 나타내는 타입(멀티스레드 기반)
    	FAIMoveRequest MoveRequest;
        MoveRequest.SetGoalActor(PatrolTarget);
        MoveRequest.SetAcceptanceRadius(15.f);
        FNavPathSharedPtr NavPath;
    	EnemyController->MoveTo(MoveRequest, &NavPath);
        // 참조를 이용해서 굳이 복사본 생성하지 않고, 반복문을 통해 PathPoint에 DebugSphere 생성
        TArray<FNavPathPoint>& PathPoints = NavPath->GetPathPoint();
        for(auto& Point : PathPoints)
        {
        	const FVector& Location = Point.Location;
            DrawDebugSphere(GetWorld(), Location. 12.f, 12, FColor::Green, false, 10.f);
        }
    }
}

BP_Enemy 에서 MoveTo 노드 제거하고 월드에 TargetPoint 를 생성하고 지정해주면 해당 경로상의 PathPoints가 표시되는 것을 확인할 수 있다.


이제 여러 TargetPoint를 기준으로 패트롤하는 것을 구현할 차례이다.

  • Enemy.h
// Enemy.h
...
protected:
    ...
    // Target이 Radius 내에 존재하는지 확인하는 함수
    bool InTargetRange(AActor* Target, double Radius);
    ...
private:
	...
    // TargetPoint 감지 범위(범위사이즈내에 도달하면 다음 지점으로 이동)
    UPROPERTY(EditAnywhere)
    double PatrolRadius = 200.f;
...
  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
    if(CombatTarget)
    {
    	// CombatRange 밖에 있을 경우 체력바 안보이게 설정
    	if(!InTargetRange(CombatTarget, CombatRadius))
    	{
        	CombatTarget = nullptr;
            if(HealthBarWidget)
            {
            	HealthBarWidget->SetVisibility(false);
            }
        }
    }
    if(PatrolTarget && EnemyController)
    {
    	if(InTargetRange(PatrolTarget, PatrolRadius))
        {
        	// 랜덤해서 나온 TargetPoint가 기존과 동일할 수 있으므로 해당 문제 해결
        	TArray<AActor*> ValidTargets;
            for(auto Target : PatrolTargets)
            {
            	if(Target != PatrolTarget)
                {
                	ValidTargets.AddUnique(Target);
                }
            }
        	const int32 NumOfPatrolTargets = ValidTargets.Num();
            if(NumOfPatrolTargets > 0)
            {
            	const int32 TargetSelection = FMath::RandRange(0, NumOfPatrolTargets - 1);
                PatrolTarget = ValidTargets[TargetSelection];
                FAIMoveRequest MoveRequest;
                MoveRequest.SetGoalActor(PatrolTarget);
                MoveReques.SetAcceptanceRadius(15.f);
                EnemyController->MoveTo(MoveRequest);
            }
        }
    }       
}
...
void AEnemy::InTargetRange(AActor* Target, double Radius)
{
	// Tick함수에 있던 코드 그대로 이동
    const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
    // 디버그용
    DRAW_SPHERE_SINGLEFRAME(GetActorLocation());
    DRAW_SPHERE_SINGLEFRAME(Target->GetActorLocation());
   	// 반경 내에 있을 경우 true 반환
    return DistanceToTarget <= Radius)
}

TargetPoint 를 배치하고, 지정해주면 정상적으로 작동되는 것을 확인할 수 있다.


실제 인게임에서 패트롤하는 적은 계속 돌아다니지 않는다.
TargetPoint 에 도달한 뒤, 어느정도 대기시간을 가지고 있다 다시 정찰을 시작한다.
이를 구현하려면 Timer 가 필요하다.
Timmer 를 사용하기 위해서 TimerHandle 이 필요한데, WorldTimeerManager 가 개발자가 설정한 타이머를 추적하기 위해 사용하는 구조체다.
Timer 가 일종의 알람시계 역할을 하고, 일정 시간이 지나면 다음 무언갈 하기 위한 Callback 함수가 필요하다.

  • Enemy.h
// Enemy.h
...
protected:
	...
    // 해당 Target으로 이동시키는 함수
    void MoveToTarget(AActor* Target);
    ...
private:
	...
    다음 Patrol 대기시간, 끝나면 PatrolTimerFinished() 콜백
    FTimerHandle PatrolTimer;
   // PatrolTimer 끝나고 실행될 Callback함수
    void PatrolTimerFinished();
    ...
  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    ...
    EnemyController = Cast<AAIController>(GetController());
    // MoveToTarget(PatrolTarget);
    GetWorldtimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);
}
...
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime)
    ...
    // 타이머 기능 확인용이므로 임시 주석처리
    /*
    if(PatrolTarget && EnemyController)
    {
    	...
    }
    */
}
...
void AEnemy::MoveToTarget(AAtor* Target)
{
	// 유효성검사, 조건에 부합하지 않으면 함수 종료
	if(EnemyController == nullptr || Target == nullptr) return;
    // BeginPlay에 있는 코드 복사후 붙여넣고 수정
    FAIMoveRequest MoveRequest;
    MoveRequest.SetGoalActor(Target);
    MoveRequest.SetAcceptanceRadius(15.f);
    FNavPathSharedPtr NavPath;
    EnemyController->MoveTo(MoveRequest);
}
...
void AEnemy::PatrolTimerFinished()
{
	MoveToTarget(PatrolTarget);
}
...

실행시 PatrolTimer가 작동되어서 TargetPoint로 이동하는 Enemy 를 확인할 수 있다.


Tick 함수가 너무 길어진 관계로 리팩터 기능을 이용해 정리하고, 패트롤 기능을 추가한다.

  • Enemy.h
// Enemy.h
...
protected:
	// PatrolTarget를 선택하고 리턴하는 함수
	AActor* ChoosePatrolTarget();
  	...
  • Enemy.cpp
...
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    ...
    MoveToTarget(PatrolTarget);
    // GetWorldTimerManager.SetTimer(...); Tick함수로 이동
}
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	// InTargetRange에서 첫번째 parameter 유효성 검사하기때문에 삭제
    // if(CombatTarget) {...
	if (!InTargetRange(CombatTarget, CombatRadius))
	{
		// 전투 반경 오버시 CombatTarget에서 제거
		CombatTarget = nullptr;
		if (HealthBarWidget)
		{
			HealthBarWidget->SetVisibility(false);
		}
	}
    if(IntargetRange(PatrolTarget, PatrolRadius))
    {
    	PatrolTarget = ChhoosePatrolTarget();
       	GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);
    }
    // 나머지 코드 싹다 삭제
}
...
AActor* AEnemy::ChosePatrolTarget()
{
	TArray<AActor*> ValidTargets;
	for (auto Target : PatrolTargets)
	{
		if (Target != PatrolTarget)
		{
			ValidTargets.AddUnique(Target);
		}
	}
	// 랜덤으로 도달 가능한 TargetPoint 개수 확인
	const int32 NumOfPatrolTargets = ValidTargets.Num();
	if (NumOfPatrolTargets > 0)
	{
		const int32 TargetSelection = FMath::RandRange(0, NumOfPatrolTargets - 1);
		return ValidTargets[TargetSelection];
	}
    return nullptr;
}
...
void AEnemy::InTargetRange(AActor* Target, double Radius)
{	
	// Tick함수쪽 원본 코드에서 InTargetRange함수 호출전에 Target 유효성 검사 코드 대체
	if(Target == nullptr) return false;
    ...
}

마지막으로 한번더 Tick 함수를 정리해준다.

  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
   	CheckCombatTarget();
    /*
    if(!IntargetRange(PatrolTaregt, PatrolRadius)){...}
    */
    CheckPatrolTarget();
    /*
    if(IntargetRange(PatrolTarget, PatrolRadius)){...}
    */
}

실행하면 정상적으로 타이머에 의해 대기시간을 가지는 것을 확인할 수 있다.
여기에 랜덤하게 대기시간을 부여할수도 있다.

  • Enemy.h
...
private:
	...
    // patrol 최소 대기 시간
    UPROPERTY(EditAnywhere, Category = "AI Navigation", meta = (AllowPrivateAccess = "true")
    float WaitMin = 2.f;
    // patrol 최대 대기 시간
    UPROPERTY(EditAnywhere, Category = "AI Navigation", meta = (AllowPrivateAccess = "true")
    float WaitMax = 5.f;
    ...
  • Enemy.cpp
...
void AEnemy::ChaeckPatrolTarget()
{
	if(InTargetRange(PatrolTarget, PatrolRadius)
    {
    	PatrolTarget = ChoosePatrolTarget();
        const float waitTime = FMath::RandRange(WaitMin, WaitMax);
        GetWorldTimerManager.SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, WaitTime);
  	}
}
...

실행하면 랜덤한 시간만큼 기다리고, 패트롤을 진행한다.

적의 캐릭터 발견시 추적기능

Blueprint에서 구현

Pawn Sensing 컴포넌트를 추가한다.
Pawn Sensing 컴포넌트는 인게임에서 청각에 의해 적을 발견한다던지, 일정 시각 내에 들어온 적을 발견하는 기능들을 만들 때 사용된다.
Viewport 에서 범위를 확인할 수 있고, Details 패널에서 해당 수치들을 조절할 수 있다.
몇몇 종류를 살펴보자면

  • Hearing Threshold
    시야에 탐지되지 않는 상태에서 사운드 감지 가능 범위
  • LosHearing Threshold
    시야에 탐지되는 상태에서 사운드 감지 가능 범위
  • Sight Radius
    시아 반경
  • Sensing Interrval
    주변환경 감지 및 상태 업데이트 빈도
  • Hearing Max Sound Age
    AI가 소리를 듣는데 걸린 제한 시간, 해당 시간이 지나면 해당 소리가 들리지 않게 됨.
  • Peripheral Vision Angle
    시야각
    조절시 원뿔 형태로 시야각 표시
    먼저 시야각과 범위를 적절하게 조절해준다.
    Event Graph 에서 Pawn Sensing 노드를 생성하고 Assign On See Pawn 노드를 검색하여 생성한다.
    위와 같이 노드가 생성되는데, Custom Event 노드를 바인딩(EnemyCustom Event 노드의 Pawn 을 보고 감지했을 때 함수 바인딩)한다고 보면 된다.
    캐릭터를 감지하였을 경우 발생할 이벤트이므로 BP_WraithCharacter 로 캐스팅해준다.
    이제 감지된 경우, Enemy 가 캐릭터를 따라가도록 구현하기 위해 기존에 사용하던 PatrolTarget 을 블루프린트에 노출시키도록 코드를 살짝 수정한다.
  • Enemy.h
// Enemy.h
...
private:
	...
	// 패트롤 타겟지점
UPROPERTY(EditInstanceOnly, Category = "AI Navigation", BlueprintReadWrite, meta = (AllowPrivateAccess = "true"))
AActor* PatrolTarget;
	...

이제 블루프린트에서 PatrolTarget 에 대해 접근할 수 있다.
캐릭터 감지시에 PatrolTarget 으로 지정해주고, Enemy 자신(self)의 AIController 를 이용해 감지한 대상으로 Move to Actor 노드를 이용하여 이동하도록 한다.
실행시 감지범위 내에 들어갈 경우 Enemy 가 캐릭터를 따라오게 된다.
조금더 실제 게임처럼 구현하기 위해 기본속도를 걷는 애니메이션이 재생되는 125로 설정하고
Character Movement 노드에서 Max Walk Speed setter를 통해 300으로 변경되도록 하면 캐릭터 발견시 Enemy 가 뛰어서 캐릭터를 쫓도록 만들 수 있다.

c++에서 구현

BP_Enemy 에서 생성한 노드와 컴포넌트를 전부 삭제하고 코드를 작성한다.
PatrolTargetBlueprintReadWrite , meta 도 필요없으므로 전부 제거한다.

  • Enemy.h
// Enemy.h
...
class UPawnSensingComponent;
...
protected:
	// 델리게이트에 바인딩하기 위한 함수, 	UFUNCTION 매크로 사용해야함
    // 블루프린트에서 On See Pawn 노드의 경우 델리게이트 이용
    // UPawnSensingComponent -> f12 -> OnSeePawn 검색시 FSeePawnDelegate 타입으로 표시됨
    // 콜백함수를 델리게이트에 바인딩해야 하므로, 콜백함수 생성해야함
	UFUNCTION()
	void PawnSeen(APawn* SeenPawn);
...
private:
	/**
    * Component 
    */
    ...
    // PawnSensing Component
    UPROPERTY(VisibleAnywhere)
    UPawnSensingComponent* PawnSensing;
    ...
  • Enemy.cpp
// Enemy.cpp
...
#include "Perception/PawnSpawnSensingComponent.h"
...
AEnemy::AEnemy()
{
	...
    // PawnSensing Component 관련
    PawnSensing = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("PawnSensing"));
    PawnSensing->SightRadius = 1000.f;
    PawnSensing->SetPeripheralVisionAngle(45.f);
}
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    ...
    if(PawnSensing)
    {
    	PawnSensing->OnSeePawn.AddDynamic(this, &AEnemy::PawnSeen);
}
...
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	// 임시로 기능 확인을 위한 디버그메세지 출력
    // 왜인지 모르겠지만 GEngine을 통한 디버그메세지는 오류발생
    UE_LOG(LogTemp, Warning, TEXT("Seen Pawn"));
}
...

실행시 Enemy 가 캐릭터를 감지하고 디버그메세지를 표시하는것을 확인할 수 있다.


Enemy 에게 상태를 추가하고, 상태에 따른 행동을 하도록 수정할 필요가 있다.

  • CharacterTypes.h
// CharacterTypes.h
...
UENUM(BlueprintType)
enum class EEnemyStates : uint8
{
	EES_Patrolling UMETA(DisplayName = "Patrolling"),
    EES_Chasing UMETA(DisplayName = "Chasing"),
    EES_Attacking UMETA(DisplayName = "Attacking")
}
...

이제 Enemy 는 상태에 대한 ENUM 타입의 변수를 가질 수 있다.

  • Enemy.h
// Enemy.h
...
private:
	...
    /**
	* ENUM 변수
	*/
    ...
    // Enemy 상태
    UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
    EEnemyState EnemyState = EEnemyStates::EES_Patrolling;
    ...

이제 EnemyState 에 따라 Enemy 가 행동을 취할 수 있도록 코드를 수정한다.

  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime)
    // 적을 쫓거나 전투중일 경우 전투타겟 확인
    if(EnemyState != EEnemyStates::EES_Patrolling)
    {
    	CheckCombatTarget();
    }
    // 아닐 경우 패트롤타겟 확인
    else
    {
    	CheckPatrolTarget();
    }
}
...
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	// 0.5초마다 PawnSeen이 작동되기때문에, MoveToTarget 함수가 0.5초마다 계속 실행됨
    // 한번 Chasing 상태가 되면 굳이 다시 Chasing할 필요 없으므로 return 호출로 함수 종료
	if(EnemyState == EEnemyStates::EES_Chasing) return;
	// AWraithCharacter로 캐스팅 성공시 해당 panw이 캐릭터인 것 확인
	if(SeenPawn->ActorHasTag(FName("WraithCharacter")))
    {
    	EnemyState = EEnemyStates::EES_chasing;
        // 추적 상태에서 Timer가 작동하면 안됨
        GetWorldTimerManager().ClearTimer(PatrolTimer);
        GetCharacterMovement()->MaxWalkSpeed = 300.f;
        CombatTarget = SeenPawn;
        MoveToTarget(CombatTarget);
        UE_LOG(LogTemp, Warning, TEXT("Seen Pawn, now Chasing"));
    }
}
...

Enemy.cpp 에서 Tag를 사용하기 위해 캐릭터에 태그 추가

  • WraithCharacter.cpp
// WraithCharacter.cpp
...
void AWraithCharacter::BeginPlay()
{
	Super::BeginPlay()
    // 태그 추가
    Tags.Add(FName("WraithCharacter"));
}
...

실행시 로그가 표시되는 것을 확인할 수 있다.


다음에 구현할 것은 공격후 일정거리 이상 벗어나면 더이상 캐릭터를 추격하지 않도록 하는 것이다.

  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::CheckCombatTarget()
{
	if(!InTargetRange(CombatTarget, CombatRadius))
    {
    	CombatTarget= nullptr;
        if(HealthBarWidget)
        {
        	...
        }
        // 추가코드
        // 패트롤 상태로 변경 및 속도 변경, 다시 PatrolTarget으로 이동
        EnemyState = EEnemyStates::EES_Patrolling;
        GetCharacterComponent()->MaxWalkSpeed = 125.f;
        MoveToTarget(PatrolTarget);
    }
}
...

이제 캐릭터가 적 공격시 적이 나를 쳐다보고 따라오도록 만들어야 한다.
그전에 적의 공격범위를 지정해두고, 캐릭터가 추적범위 내에 있으면서 공격범위 밖에 있을때만 추적하도록 해주어야 한다.

  • Enemy.h
// Enemy.h
...
private:
	...
    // 공격범위
    UPROPERTY(EditAnywhere)
    double AttackRadius = 150.f;
    ...
  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::CheckCombatTarget()
{
	// CombatRadius 외부에 위치할 경우
	if(!InTargetRange(CombatTarget, CombatRadius))
    {
    	...
        UE_LOG(LogTemp, Warning, TEXT("Lose Interest"));
    }
    // AttackRadius 외부에 위치할 경우, Tick함수 호출이므로 한번만 EnemyState 변경하도록 조건설정
    else if(!InTargetRange(CombatTarget, AttackRange) && EnemyState != EEnemyStates::EES_Chasing)
    {
    	EnemyState = EEnemyStates::EES_Chasing;
       	GetCharacterMovement()->MaxWalkSpeed = 300.f;
        MoveToTarget(CombatTarget);
        UE_LOG(LogTemp, Warning, TEXT("Chase Player"));
    }
    // AttackRange 내부에 있을 경우, Tick함수 호출이므로 한번만 EnemyState 변경하도록 조건설정
    else if(InTargetRange(CombatTarget, AttackRange) && EnemyState != EEnemyStates::EES_Attacking)
    {
    	EnemyState = EEnemyStates::EES_Attacking;
        // TODO : 공격 애니메이션 몽타추 추가
        // 임시로 UE_LOG사용
        UE_LOG(LogTemp, Warning, TEXT("Attack Player"));
    }
}
...
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	...
    if(SeenPawn->ActorHasTag(FName("WraithCharacter")))
    {
    	...
        UE_LOG(LogTemp, Warning, TEXT("Pawn Seen, Chase Player"));
    }
}
...

조건문에 따라 한번만 정상적으로 작동하는 것을 확인할 수 있다.
로그상 문제점이 하나 발생하는데 Attack이 계속해서 발생한다는 것이다.
이유는 PawnSeen() 에서 델리게이트를 통해 시야 내에 보이면 캐릭터의 상태가 EES_Chasing 으로 변하는데, 조건문상 EES_Attacking 이 아니므로 발생하는 문제이다.
PawnSeen 함수를 수정해야 한다.

  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	if(...) return;
    if(...)
    {
    	...
    	if(EnemyState != EEnemyStates::EES_Attacking)
        {
        	EnemyState = EEnemyStates::EES_Chasing;
            MoveToTarget(CombatTarget);
            UE_LOG(Logtemp, Warning, TEXT("Pawn Seen, Chase Player"));
        }
	}
}

컴파일 후 실행하면 정상적으로 한번만 Attacking 상태로 바뀌는 것을 확인할 수 있다.


이제 캐릭터가 공격시 적이 따라오도록 구현해야 한다.
TakeDamage 쪽에 EventInstigator 가 있고, 이를 통해 공격한 상대를 알 수 있다.

  • Enemy.cpp
// Enemy.cpp
void AEnemy::TakeDamage(...)
{
	...
    EnemyState = EEnemyStates::EES_Chasing;
    GetCharacterMovement()->MaxWalkSpeed = 300.f;
    MoveToTarget(CombatTarget);
    return DamageAmount;
}

컴파일 후 실행하면 공격시 CombatTarget 을 따라오게 된다.

0개의 댓글