• UObject
  • Actor
  • Pawn
  • Controller
  • Character

 

UObject 클래스 상속 vs 일반 C++ 클래스 사용

 

UObject 클래스가 제공하는 기능들

 

  • Garbage Collection : 기본적으로 C++에서 메모리 관리는 프로그래머의 책임이다.
    UObject를 상속받은 경우 UPROPERTY 매크로를 사용하고 상속받지 않은 경우는 스마트 포인터로 관리한다.
  • Reference Updating
  • Reflection : 런타임에 어떤 클래스의 정보를 알고 싶다면? -> C++에는 Reflection이 없어서 못한다.
    다만 언리얼 엔진에서는 억지로 만들었다.
  • Serialization (직렬화) : *별첨
  • Automatic Updating of Default Property Changes
  • Automatic Property Initialization
  • Automatic Editor Integration
  • Type Information Available at Runtime : C++에서의 RTTI는 dynamic_cast가 있다.
    언리얼에서는 UObject를 상속 시, 타입 정보 관리가 추가되어서 Cast<>를 사용할 수 있다.
  • Network Replication : Client/Server 구조의 100명 이하의 게임을 만들 때 (특히 FPS) 유용하다.
    온라인 게임에서는 동기화가 가장 큰 이슈이다. 하지만 UObject를 상속 받으면 Replication을 지원한다.
    참고로 MMORPG를 만든다면 개념이 완전히 달라져서 Replication을 쓸 수 없다. 그때는 별도의 서버를 사용해야 한다.

 

Serialization (직렬화)

 

메모리를 디스크에 저장하고나 네트워크 통신에 사용하기 위한 형식으로 변환하는 것. (일종의 파싱)

참조 형식 데이터는 사용할 수 없고 값 형식 데이터만 사용할 수 있다.

참조 형식 데이터는 실제 데이터가 아닌 메모리 번지 주소를 가지고 있기 때문이다.

 

Actor

 

Actor를 상속 받으면 SRT(Scale, Rotation, Translation)좌표가 생기는것은 아니다.

SRT좌표는 씬 컴포넌트가 담당한다.

 

Actor를 상속받으면 컴포넌트를 붙일 수 있다는 것이 가장 중요한 특징이다.

Unity에서 GameObject를 만들어서 컴포넌트를 조립하는 것과 비슷하다.

 

Pawn

 

병졸..?

 

 

Controller

 

영혼이라고 보면 된다.

 

Character

 

캐릭터는 폰을 상속받는다.

Character Movement라는 컴포넌트를 제공한다. 캡슐 기반의 간단한 이동이 가능하지만 꼭 사용할 필요는 없다.

참고로 UE3에서는 폰만 있다.

 

Pawn과 Controller를 따로 분리한 이유

 

행동이라는 개념을 별도의 Controller로 관리하면 코드 재사용성이 증가한다.

Behavior Tree 등 언리얼에서 제공하는 AI 기능을 이용할 수 있다.

통상적으로 말하는 MVC 개념과도 비슷하다. (Model: Pawn, View: Component, Controller: Controller)

'언리얼 엔진 > 언리얼 엔진4 입문' 카테고리의 다른 글

[UE4 입문] UMG 실습  (0) 2022.09.12
[UE4 입문] 언리얼 컨테이너  (0) 2022.09.12
[UE4 입문] 샘플 분석  (0) 2022.09.12
[UE4 입문] Behavior Tree #2  (0) 2022.09.12
[UE4 입문] Behavior Tree #1  (0) 2022.09.12

남은 총알의 개수를 UI로 띄워보자.

 

테스트로 만들어볼 것이기 때문에 비율 같은것은 따로 계산하지 않고 Text를 하나 적당히 배치한다.

 

// MyHUD.h
class FIRSTPLAYER_API UMyHUD : public UUserWidget
{
public:
    UPROPERTY(meta = (BindWidget))
    class UTextBlock* AmmoText;
};

 

그리고 UserWidget을 상속받는 C++ 클래스를 하나 만들어주고 블루프린트 위젯의 텍스트와 직접 바인딩 시키기 위해 속성도 지정한다. 블루프린트 위젯의 부모 클래스도 MyHUD로 바꿔준다.

 

 

// FirstPlayerGameMode.h
public:
    UPROPERTY()
    TSubclassOf<UUserWidget> HUD_Class; // UI 리소스

    UPROPERTY()
    UUserWidget* CurrentWidget; // 현재 띄우고 있는 UI
    
// FirstPlayerGameMode.cpp
AFirstPlayerGameMode::AFirstPlayerGameMode() : Super()
{
    static ConstructorHelpers::FClassFinder<UMyHUD> UI_HUD(TEXT("WidgetBlueprint'/Game/WBP_HUD.WBP_HUD_C'"));
    if (UI_HUD.Succeeded())
    {
        HUD_Class = UI_HUD.Class;

        CurrentWidget = CreateWidget(GetWorld(), HUD_Class);
        if (CurrentWidget)
        {
            CurrentWidget->AddToViewport(); // Z-order를 정하고 뷰포트에 추가함
            // CurrentWidget->RemoveFromViewport(); // 뷰포트에서 제거
        }
    }
}

 

블루프린트로 만든 WBP_HUD를 게임모드 C++ 클래스에서 생성해서 멤버 변수(HUD_Class)로써 가지고 있고 현재 월드에 멤버 변수에 저장한 WBP_HUD를 생성한다.

CurrentWidget은 현재 띄워진 UI를 가지게 되는데 실습에서는 하나의 UI만 사용하므로 별다른 차이는 없다.

 

다만 생성만 하고 뷰포트에 띄운것이 아니므로 AddToViewport 함수를 통해 실제로 출력한다.

반대로 RemoveFromViewport 함수를 통해 뷰포트에서 제거할 수도 있다.

 

 

현재까지 WBP_HUD에서 만들어둔 텍스트를 MyHUD 클래스에서 바인딩 하고, 게임모드 클래스의 생성자에서 UI 리소스(WBP_HUD)를 로드하고 뷰포트에 출력하는 것 까지 구현했다.

 

이제 실제로 총알 개수가 변해야 한다.

어떻게 구현할지 간단하게 생각해보면 해당 UI에서 멤버 변수를 만들고 데이터를 관리해서 직접 UI에 반영시키는 방법이 있을 것이다.

하지만 UI와 실제 데이터와 뒤섞으면 관리하기가 굉장히 어려워진다. 구조가 복잡해지면 해당 데이터가 UI 용도였는지 인게임 로직 용도였는지 알기가 어려워진다.

그렇기 때문에 UI에서 실제 데이터를 관리하지 않도록 주의해야한다.

 

지금은 총알 개수 하나만 테스트를 해보는 것이기 때문에 따로 데이터를 관리하지 않고 캐릭터 클래스에서 총알 관리를 해 볼 것이다.

 

// FirstPlayerCharacter.h
public:
    void RefreshUI();
    
public:
    int32 AmmoCount = 5;
    int32 MaxAmmoCount = 5;
    
// FirstPlayerCharacter.cpp
void AFirstPlayerCharacter::RefreshUI()
{
	// 월드의 게임모드를 가져와서 캐스팅 한다
    AFirstPlayerGameMode* GameMode = Cast<AFirstPlayerGameMode>(UGameplayStatics::GetGameMode(GetWorld()));
    if (GameMode)
    {
        UMyHUD* MyHUD = Cast<UMyHUD>(GameMode->CurrentWidget);
        if (MyHUD)
        {
            const FString AmmoStr = FString::Printf(TEXT("Ammo %01d/%01d"), AmmoCount, MaxAmmoCount);
            MyHUD->AmmoText->SetText(FText::FromString(AmmoStr));
        }
    }
}

void AFirstPlayerCharacter::OnFire()
{
    if (AmmoCount <= 0)
        return;

    AmmoCount -= 1;

    RefreshUI();
    
    // ..생략
}

 

BeginPlay에서도 UIRefresh를 추가해주면 시작 즉시 5/5로 초기화 된다.

위에서 설명했듯이 실제 캐릭터의 데이터는 다른 클래스나 구조체에서 관리하는 것이 좋을 것이다.

 

 

총알 하나만 관리하는데도 생각할 것들이 생긴다. 구조가 더 복잡해질때를 고려해야한다.

게임 컨텐츠와 UI가 맞물리다 보면 어디서 데이터를 관리해야 하는지가 생각보다 복잡해 질 수 있다.

따로 UI매니저를 만들어서 공통으로 접근할 수 있는것을 만들어서 관리 한다던가 하는 방법을 사용할 수 있다.

C++에서 사용하던 표준 컨테이너들(vector, map 등등)은 언리얼 프로젝트에서는 사용하지 않는 것이 좋다.

대신 언리얼에서 제공하는 컨테이너들을 사용하면 된다.

 

https://docs.unrealengine.com/4.26/ko/ProgrammingAndScripting/ProgrammingWithCPP/UnrealArchitecture/TArrays/

위의 문서에서 컨테이너들에 대한 정보들을 볼 수 있다.

 

vector ≒ TArray

unordered_map TMap

unordered_set TSet

string ≒ FString

 

TArray에서 주의해야 할 점은 vector의 clear와 TArray의 Empty가 동일하다.

vector의 empty를 기대하고 Empty를 사용했다가는 컨테이너가 다 비워져버린다.

 

STL 컨테이너의 개념과 크게 다르지는 않지만 활용 방법이 약간씩 다르므로 문서를 참고해서 사용하면 될 것이다.

 

'언리얼 엔진 > 언리얼 엔진4 입문' 카테고리의 다른 글

[UE4 입문] 핵심 5개 클래스 -完-  (0) 2022.09.12
[UE4 입문] UMG 실습  (0) 2022.09.12
[UE4 입문] 샘플 분석  (0) 2022.09.12
[UE4 입문] Behavior Tree #2  (0) 2022.09.12
[UE4 입문] Behavior Tree #1  (0) 2022.09.12

언리얼에서 기본적으로 제공해주는 샘플 코드를 분석하는 것만으로도 기본적인 작동 방식이나 구현 방법을 알 수 있어서 상당히 도움이 된다.

 

보통 다수의 인원이 작업하는 프로젝트들은 svn이나 git을 이용해 버전 관리를 해서 공동작업을 어렵지 않게 하는데, 블루프린트로 작업을 하게되면 공동작업에 있어서 충돌이 많이 일어나기 때문에 버전 관리가 어렵다.

 

UI와 관련된 위젯을 제외하고는 웬만해서 C++로 작업하는게 더 나을 수 있다.

UI는 보통 UI/UX팀에서 만들어서 보내주는 경우가 많다.

 

C++로 오랫동안 작업하고 익숙한 사람들은 블루프린트를 모를 수도 있다.

블루프린트 노드들을 보고 흐름을 읽을 줄만 알면 된다.

 

UI의 공통적인 부분(ex:스킬. 스킬은 제각기 다르지만 사용시 쿨타임이 돈다거나 하는 부분은 공통적)은 위젯 블루프린트로 만들어서 기능을 재사용 하면 좋다.

'언리얼 엔진 > 언리얼 엔진4 입문' 카테고리의 다른 글

[UE4 입문] UMG 실습  (0) 2022.09.12
[UE4 입문] 언리얼 컨테이너  (0) 2022.09.12
[UE4 입문] Behavior Tree #2  (0) 2022.09.12
[UE4 입문] Behavior Tree #1  (0) 2022.09.12
[UE4 입문] AI Controller  (0) 2022.09.11

AI 주변에 플레이어가 없으면 주변을 탐색하는 행동은 RPG에서는 매우 흔한일이다.

하지만 현재 구현한 것 만으로는 오로지 탐색만 하기 때문에 플레이어가 접근 시 다른 행동을 하게끔 해야한다.

그것을 할 수 있도록 하는것이 셀렉터이다.

타겟을 찾았으면 왼쪽으로 분기하고 못찾았으면 오른쪽으로 분기하게끔 하면 된다.

 

그렇지만 매 틱마다 계속 서치를 하는것은 부담이 되기 때문에 주기적으로(1~2초) 한번씩 탐색하는 것이 일반적이다.

물론 언리얼은 그것에 대한 것도 준비가 되어있다. 서비스라는 개념이다.

 

우선 서비스부터 만들어 보도록 한다.

 

 

// BTService_SearchTarget.h
public:
    UBTService_SearchTarget();

    virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
    
// BTService_SearchTarget.cpp
#include "MyAIController.h"
#include "MyCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"

UBTService_SearchTarget::UBTService_SearchTarget()
{
    NodeName = TEXT("SearchTarget");
    Interval = 1.0f; // Tick의 주기 설정
}

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

    auto CurrentPawn = OwnerComp.GetAIOwner()->GetPawn();
    if (CurrentPawn == nullptr)
        return;

    UWorld* World = CurrentPawn->GetWorld();
    FVector Center = CurrentPawn->GetActorLocation();
    float SearchRadius = 500.f;

    if (World == nullptr)
        return;

    TArray<FOverlapResult> OverlapResults;
    // 3번째 인자는 무시할 액터. 즉, 자기 자신은 무시하겠다는 뜻이다
    FCollisionQueryParams QueryParams(NAME_None, false, CurrentPawn);

    // 월드에 충돌체를 생성해서 충돌된 오브젝트들을 첫번째 인자에 넣어준다
    bool bResult = World->OverlapMultiByChannel(
        OverlapResults,
        Center,
        FQuat::Identity,
        ECollisionChannel::ECC_GameTraceChannel2,
        FCollisionShape::MakeSphere(SearchRadius),
        QueryParams);

    if (bResult)
    {
        for (auto& OverlapResult : OverlapResults)
        {
            // MyCharacter로 캐스팅해서 유효한 경우만 블랙보드에 값을 넘겨준다
            AMyCharacter* MyCharacter = Cast<AMyCharacter>(OverlapResult.GetActor());
            if (MyCharacter && MyCharacter->GetController()->IsPlayerController())
            {
            OwnerComp.GetBlackboardComponent()->SetValueAsObject(TEXT("Target"), MyCharacter);

            DrawDebugSphere(World, Center, SearchRadius, 16, FColor::Green, false, 0.2f);

            return;
            }
        }
        DrawDebugSphere(World, Center, SearchRadius, 16, FColor::Red, false, 0.2f);
    }
    else
    {
        // 서치 실패시 nullptr을 넘겨준다
        OwnerComp.GetBlackboardComponent()->SetValueAsObject(TEXT("Target"), nullptr);
        DrawDebugSphere(World, Center, SearchRadius, 16, FColor::Red, false, 0.2f);
    }
}

 

생성자에서 Interval값을 조절하여 TickNode의 호출 간격을 조절할 수 있다.

 

이제 블랙보드에서 키를 추가해주고 셀렉터에 서비스를 추가해주자.

 

 

 

범위 안에 없을 때

 

범위 안에 있을 때

매 틱마다 실행되는게 아닌 1초마다 실행되고 충돌(서치) 여부를 DrawDebugSphere를 통해 시각적으로 알 수 있다.

 

아직 갈길이 멀다. 범위 안에서 탐지시 타겟을 추적하는 기능이 필요하다.

 

데코레이터를 추가해서 컴포짓 노드의 분기 조건을 만들어준다.

왼쪽은 범위 안에 들어와서 타겟이 있는경우, 오른쪽은 타겟이 없는 경우에 실행된다.

처음에는 범위 밖에 있으므로 오른쪽으로 분기하여 5초 대기-랜덤한 목적지 이동을 실행하지만 이동이 끝난시점에 범위 안에 있다면 왼쪽으로 분기하여 타겟을 추적한다.

 

데코레이터는 if-else문과 유사하다고 생각하면 된다.

 

추적까지 성공했다면 이제 일정 범위 안에 접근시 공격하는것까지 추가해보자.

'공격이 가능한가?' 를 따져야하는 일종의 조건식이므로 데코레이터를 만들어준다.

 

// BTDecorator_CanAttack.h
public:
    UBTDecorator_CanAttack();

    virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemoty) const override;
    
// BTDecorator_CanAttack.cpp
#include "MyAIController.h"
#include "MyCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTDecorator_CanAttack::UBTDecorator_CanAttack()
{
    NodeName = TEXT("CanAttack");
}

bool UBTDecorator_CanAttack::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemoty) const
{
    bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemoty);

    auto CurrentPawn = OwnerComp.GetAIOwner()->GetPawn();
    if (CurrentPawn == nullptr)
        return false;

    auto Target = Cast<AMyCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(FName(TEXT("Target"))));

    if (Target == nullptr)
        return false;

    // 타겟과의 거리가 200 미만이면 공격 실행을 위해 true반환
    return bResult && Target->GetDistanceTo(CurrentPawn) <= 200.f;
}

 

공격이 불가능한 경우에는 추적을 해야하므로 오른쪽 분기는 inversed(반대조건)을 체크해준다.

이제 조건과 분기는 만들었으므로 태스크를 만들어서 실제로 실행해 볼 때이다.

 

 

// BTTask_Attack.h
public:
    UBTTask_Attack();

    virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
    virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

private:
    bool bIsAttacking = false;

// BTTask_Attack.cpp
#include "MyAIController.h"
#include "MyCharacter.h"

UBTTask_Attack::UBTTask_Attack()
{
    NodeName = TEXT("Attack");
    
    // 기본은 false이고 true 설정시 TickTask가 실행된다
    bNotifyTick = true;
    bIsAttacking = false;
}

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

    auto MyCharacter = Cast<AMyCharacter>(OwnerComp.GetAIOwner()->GetPawn());
    if (MyCharacter == nullptr)
        return EBTNodeResult::Failed;

    MyCharacter->Attack();
    bIsAttacking = true;

    return Result;
}

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

    // 공격이 끝났으면 작업의 끝을 알린다
    if (bIsAttacking == false)
        FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}

 

타겟을 탐지하고(서비스:SearchTarget) -> 200만큼 범위안에 들어왔는지 체크하고(데코레이터:CanAttack) -> 공격

이 순서로 작동하게 될 것이다.

다만 현재는 태스크 실행시 Attack 함수를 호출하여 애니메이션 몽타주를 재생하고  bIsAttacking을 true로 변경하는 것은 있지만 다시 false로 되돌리는 부분이 없다.

 

그럼 공격이 끝난 시점은 어떻게 구분하면 되는가?

일전에 MyCharacter에서도 공격 시작시 애니메이션 몽타주가 재생되면서 IsAttacking이 true로 설정되었다가 몽타주 재생이 끝나면 OnAttackMontageEnded 함수에 의해 false로 되돌리는 부분이 있었다. 그것을 OnMontageEnded 델리게이트에 등록함으로써 몽타주 종료시 호출되게끔 했었다.

 

그것과 마찬가지로 델리게이트에 등록해서 몽타주 재생이 끝날 시 호출하면 될 것이다. 다만 직접 호출은 아니고 공격 모션이 끝나면 bIsAttacking을 false로 변경하는 함수를 구독시키고 Broadcast로 쏴줄 것이다.

 

 

// MyCharacter.h
DECLARE_MULTICAST_DELEGATE(FOnAttackEnd);

public:
    FOnAttackEnd OnAttackEnd;
    
// MyCharacter.cpp
void AMyCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
    IsAttacking = false;
    // 구독자가 누군지 몰라도 등록된 콜백을 호출하라고 신호를 보냄
    OnAttackEnd.Broadcast();
}

 

// BTTask_Attack.cpp
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
    MyCharacter->OnAttackEnd.AddLambda([this]()
        {
            bIsAttacking = false;
        });
}

 

이제 공격 시작시 true로 변경하고 몽타주 재생이 끝나면 콜백이 호출되어 false로 변경되고 TickTask에 의해 계속 검사되고 있었으므로 작업의 끝을 알리게 된다.

 

마지막으로 만들어준 Attack 노드를 붙여주면 범위 내에 감지 후 일정거리내에 도달시 공격 모션을 실행하게 된다.

 

 

[정리]

 

  • 태스크 : 보라색 노드. AI의 행동이나 블랙보드의 값 조정같은 작업을 한다. 반드시 컴포짓 노드를 거쳐 실행되어야 한다.
  • 데코레이터 : 파란색 노드. 특정 노드의 실행 여부를 결정짓는 노드이다.
  • 서비스 : 초록색 노드. 노드가 실행되는 동안 같이 실행된다.
  • 비헤이비어 트리가 복잡해질수록 이점은 있지만 초기에 세팅하는게 어렵다.

'언리얼 엔진 > 언리얼 엔진4 입문' 카테고리의 다른 글

[UE4 입문] 언리얼 컨테이너  (0) 2022.09.12
[UE4 입문] 샘플 분석  (0) 2022.09.12
[UE4 입문] Behavior Tree #1  (0) 2022.09.12
[UE4 입문] AI Controller  (0) 2022.09.11
[UE4 입문] UI 실습  (0) 2022.09.03

+ Recent posts