프로그래밍/Unreal Engine

230405_언리얼 포트폴리오(Project_Zombie_FPS) : WeaponActor/Bullet(Projectile)

이동헌 2023. 4. 5. 23:19

이번 글은 내가 프로젝트에서 무기를 구현한 방식에 대한 글이다.

 

1. Weapon Rigging(Feat. Blender)

  언리얼의 액터는 기본적으로 SceneComponent(좌표, 스케일 등 정보를 담는 컴포넌트)를 가지고 있다. 그런데 내가 원하는 무기는 자신만의 애니메이션이 필요했다. 예를 들어 장전, 저격총의 볼트 액션 등인데, 이를 구현하기 위해서는 무기 또한 스켈레탈 메시로 구현해야 한다. 이를 Rigging(= 무기의 뼈를 생성해주는 것)이라 부른다.

 

  Rigging을 하기 위해서 나는 일반적인 스태틱메시에 해당하는 3D Model을 찾고, Blender라는 프로그램을 통해 뼈를 생성하고, 파츠에 할당하였다. Blender를 사용할 줄 몰랐지만 무수한 삽질을 통해 이제 리깅하는 정도는 다룰 줄 알게 되었다...

 

2. 상속 구조

  무기의 가장 상위 클래스는 AWeaponActor이고, 이를 상속받은 무기 두 종류 ASniperRifleActor와 AAssaultRifleActor가 있다. 일반적인 C++ 상속과 같이 WeaponActor에는 공통적인 속성과 함수 원형들을 선언하여 플레이어에서 AWeaponActor포인터를 통해 일반화시킬 수 있도록 구현하였다(무슨 무기인지 상관없이 Weapon->Function()으로 일반화 가능).

 

3. ASniperRifleActor

  이제 ASniperRifleActor클래스를 살펴보면 다음과 같다.

헤더파일(ASniperRifleActor.h)

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

#pragma once

#include "CoreMinimal.h"
#include "WeaponActor.h"
#include "SniperRifleActor.generated.h"

/**
 * 
 */
UCLASS()
class PROJECT_ZOMBIE_FPS_API ASniperRifleActor : public AWeaponActor
{
    GENERATED_BODY()
	
public:
    // Sets default values for this actor's properties
    ASniperRifleActor();

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public:
    // Called every frame
    virtual void Tick(float DeltaTime) override;

public:
    // AWeaponActor에서 상속받은 무기의 기본 함수들
    void UseWeapon() override;
    void BoltAction() override;
    void ReloadWeapon() override;
    void MeleeAttack() override;
    void PlayWeaponMontage(FName MontageKey) override;
    void HideWeapon() override;
    void ReadyWeapon() override;

protected:
    UPROPERTY()
        class USkeletalMeshComponent* MyMesh;

    // 총알 방향 화살표, 위치는 총구 끝
    UPROPERTY()
        class UArrowComponent* BulletArrow;
    // 탄피 방향 화살표
    UPROPERTY()
        class UArrowComponent* SleeveArrow;

    UPROPERTY()
        class UAnimMontage* Montage;

    bool isFired = false;

public:
    bool GetFired() const { return isFired; }
    void SetFired(bool fired) { isFired = fired; }

    virtual void SpawnSleeve() override;

    virtual void ZoomIn() override;
    virtual void ZoomOut() override;
};

Cpp파일은 길기때문에 한 블럭씩 살펴보자.

 

생성자

ASniperRifleActor::ASniperRifleActor()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;
	
    WeaponType = EWeaponType::SniperRifle;
    SetWeaponName(TEXT("SniperRifle"));

    MyMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("MyMesh"));
    if (MyMesh != nullptr)
        RootComponent = MyMesh;

    // * 메쉬
    ConstructorHelpers::FObjectFinder<USkeletalMesh> MeshAsset(TEXT("SkeletalMesh'/Game/MyBlueprint/HuntingRifle/Meshes/SniperRifle.SniperRifle'"));
    if (MeshAsset.Succeeded() && MyMesh != nullptr)
    {
        MyMesh->SetSkeletalMesh(MeshAsset.Object);
    }

    // * 화살표
    BulletArrow = CreateDefaultSubobject<UArrowComponent>(TEXT("BulletArrow"));
    if (BulletArrow != nullptr)
    {
        BulletArrow->AttachTo(MyMesh);
        BulletArrow->SetRelativeLocation(FVector(105.f, 0.f, 10.25f));
        BulletArrow->bHiddenInGame = true;
    }

    SleeveArrow = CreateDefaultSubobject<UArrowComponent>(TEXT("SleeveArrow"));
    if (SleeveArrow != nullptr)
    {
        SleeveArrow->AttachTo(MyMesh);
        SleeveArrow->SetRelativeLocation(FVector(4.5f, 5.f, 10.f));
        SleeveArrow->SetRelativeRotation(FRotator(0.f, 5.f, 0.f));
        SleeveArrow->bHiddenInGame = true;
    }

    // * 애니메이션
    ConstructorHelpers::FClassFinder<UAnimInstance> AnimAsset(TEXT("AnimBlueprint'/Game/MyBlueprint/HuntingRifle/Animation/BP_SniperRifleAnim.BP_SniperRifleAnim_C'"));
    if (AnimAsset.Succeeded())
    {
        MyMesh->SetAnimInstanceClass(AnimAsset.Class);
    }

    // * 몽타주
    ConstructorHelpers::FObjectFinder<UAnimMontage> MontageAsset(TEXT("AnimMontage'/Game/MyBlueprint/HuntingRifle/Animation/BP_SniperRifleMontage.BP_SniperRifleMontage'"));
    if (MontageAsset.Succeeded())
    {
        Montage = MontageAsset.Object;
    }

    Damage = 80.f;
    MinZoomRate = 2.f;
    MaxZoomRate = 16.f;
}

MyMesh : 기본적으로 메쉬를 가지고 있지 않기 때문에 블렌더로 직접 생성한 SkeletalMesh를 불러와 RootComponent로 설정하였다.

BulletArrow, SleeveArrow : 각각 발사 시 총알과 탄피가 생성되는 위치, 방향 정보를 가진 화살표이고, 실제 게임에선 보이지 않게 하기 위해 bHiddenInGame = true로 해주었다.

애니메이션은 MyMesh의 애니메이션 클래스를 설정해주었고, 이로인해 무기 애니메이션 사용법이 달라진다(바로 소개할 예정).

Montage : 평범한 애니메이션 몽타주를 불러오는 방식이다.

나머지 인수 초기화는 총의 데미지, 그리고 최소/최대 배율이다.

 

  이렇게 하고 나니, 몽타주를 사용하는 방식에 문제가 생겼다. 원래 플레이어, 좀비 등등 ACharacter를 상속받은 클래스들의 몽타주를 재생할 때는 ACharacter::PlayAnimMontage 함수를 사용하였는데, 보다시피 ACharacter의 함수이므로 액터는 가지고 있지 않았다. 그래서 PlayAnimMontage함수를 들어가 보았다.

 

ACharacter::PlayAnimMontage 함수 구현은 다음과 같다.

float ACharacter::PlayAnimMontage(class UAnimMontage* AnimMontage, float InPlayRate, FName StartSectionName)
{
    UAnimInstance * AnimInstance = (Mesh)? Mesh->GetAnimInstance() : nullptr; 
    if( AnimMontage && AnimInstance )
    {
        float const Duration = AnimInstance->Montage_Play(AnimMontage, InPlayRate);

        if (Duration > 0.f)
        {
            // Start at a given Section.
            if( StartSectionName != NAME_None )
            {
                AnimInstance->Montage_JumpToSection(StartSectionName, AnimMontage);
            }

            return Duration;
        }
    }	

    return 0.f;
}

  위를 보면 Mesh의 AnimInstance를 가져온 뒤 AnimInstance::Montage_Play, AnimInstance::Montage_JumpToSection 함수를 이용하여 구현되어 있었다. 그래서 나도 PlayWeaponMontage라는 함수를 아래와 같이 구현하여 PlayAnimMontage 사용하듯 사용해보니 정상적으로 잘 작동되었다.

void ASniperRifleActor::PlayWeaponMontage(FName MontageKey)
{
    Super::PlayWeaponMontage(MontageKey);

    if (MyMesh != nullptr && Montage != nullptr)
    {
        UAnimInstance* AnimInstance = MyMesh->GetAnimInstance();
        // 재생
        AnimInstance->Montage_Play(Montage);
        // Key에 해당하는 몽타주 섹션으로 이동
        AnimInstance->Montage_JumpToSection(MontageKey, Montage);
    }
}

 

4. Bullet

  한편, 총을 쏘면 총알과 탄피가 생성된다. 이 때 총알은 내가 보는 방향으로 날아가야 하며 히트스캔 방식인지 투사체 방식인지에 따라 판정이 다르다. 나는 이전 미완성 포트폴리오에서는 히트스캔을 사용하였고, 이번엔 투사체 방식을 사용하여 총알을 구현하였다.

 

여기서 핵심은 다음과 같다.

1. 총알의 속도 정하기

2. 영점 거리를 정하기

3. 플레이어가 조준한 지점이 영점 거리만큼 떨어진 표적을 맞출때 명중하도록 투사체 목표지점 정하기

4. 총알 생성 후 투사체 움직임 시작

 

위와 같은 로직이고, 나는 간단히 아래와 같이 구현하였다.

// 총알 속도(cm/s = unit/s)
float BulletSpeed = Bullet->GetBulletSpeed();
// 영점 거리(1 cm = 1 unit)
// 플레이어는 m단위로 가지고 있으므로 * 100
float ZeroPoint = Player->GetZeroPoint() * 100.f;
// 영점까지 가는데 낙하하는 시간
float ZeroPointTime = ZeroPoint / BulletSpeed;
// 영점거리 기준 조준점보다 높여야 할 거리(=낙하하는 만큼)
float HeightOffset = ZeroPointTime * ZeroPointTime * 980.665f / 2.f;

// 시작은 총구
const FVector Start{ BulletArrow->GetComponentLocation() };
// 끝은 크로스헤어 기준 영점거리 앞 + 낙하하는 거리만큼 상향시킨 곳
const FVector End{ CrosshairWorldPosition + CrosshairWorldDirection * ZeroPoint + FVector(0.f, 0.f, HeightOffset) };

 

  짤막한 그림판..그림이다. x미터를 가는동안 걸리는 시간 t를 구하고, t동안 중력의 영향을 받아 떨어지는 거리 d를 계산한 뒤, 그만큼 높게 목표를 설정해주어 발사시키면 된다. 이때, 영점거리를 x에 넣어주면 t와 d, target의 위치까지 자연스럽게 정해진다. 그러면 시작 지점은 BulletArrow 컴포넌트의 위치에서 시작하여(총알 시작 위치를 나타내는 컴포넌트이므로) 바라보는 방향으로 x만큼 앞, d만큼 위로 간 지점이 끝 지점이 된다.

  이렇게 하여 Bullet 이라는 액터에 있는 ProjectileMovementComponent의 속도 설정을 해주니 총알이 영점에 정확히 맞았다. 아, 참고로 언리얼의 단위 1은 1cm이다. 그래서 중력 가속도 또한 9.8m/s^2이므로 980.665로 계산해준 것이다.

 

아래는 구현 후 예시이다. 사진으로 담기 위해 AR무기를 예로 들었다.

자세히 보면 왼쪽은 가까운 거리라 조준점보다 더 아래인 총구 위치에 총알자국이 난 것이고, 오른쪽은 조준한 위치에 그대로 총알자국이 났다. 총알이 총구에서 나가므로 아주 가까운 거리에선 조준한 지점에 안맞을 수 있다는 현실적인 모습이 반영되었다^^..

 

아무튼 어쩌다보니 애니메이션이 필요한 무기의 구현과 총알 발사 방식 구현에 대해 설명하는 글이 되었다. 블로그 글을 잘 써보진 않아서 어디까지 써야할지 잘 모르겠어서 여기까지 해야겠다.