LeeTaes 공부노트

[UE Team Project/T.A.] 6. 원거리 공격(활) 구현 / 에임 오프셋 본문

프로젝트/TimelessAdventure

[UE Team Project/T.A.] 6. 원거리 공격(활) 구현 / 에임 오프셋

리태s 2024. 9. 30. 09:27
728x90
반응형

개요

이번 포스팅에서는 활을 사용한 원거리 공격을 구현하는 방법에 대해 정리해보도록 하겠습니다.


구현 아이디어 설명

우선 가장 중요한 것은 활입니다.

 

활 자체에 애니메이션이 있는 애셋도 있지만, 직접 IK를 통해 플레이어의 오른손 위치를 강제로 맞춰주다 보니 애니메이션이 약간 어색하게 보여서 결국 플레이어의 손에 활 시위(joint5 bone)를 맞추는 방식으로 구현하게 되었습니다.

 

여기서 가장 중요한 것은 활 시위가 당겨졌다가 원래 위치로 돌아가야 한다는 점입니다.

 

즉, 위 이미지와 같이 joint5는 플레이어의 손에 부착시켜준다고 해도, 화살을 발사할 때 활 시위가 다시 원래 위치로 돌아와야 하므로, 시작과 동시에 joint5 소켓의 Local Transform을 저장해야 한다는 의미입니다.

 

쉽게 전체 로직을 생각하면 다음과 같습니다.

  1. 시작과 동시에 joint5 소켓의 Local Transform 저장
  2. 당기기 신호가 들어오면 플레이어의 손 위치를 전달받아 joint5의 위치 수정
  3. 발사 신호가 들어오면 joint5의 위치를 1에서 저장한 위치로 설정

플레이어 무기 소켓 생성

우선 무기를 구현하기 앞서 플레이어가 활을 들었을 때, 화살을 장전했을 때의 애니메이션을 기준으로 자연스러운 위치에 소켓을 생성해주도록 합니다.

소켓 생성 결과

 

생성한 소켓을 우클릭하여 프리뷰 메쉬를 설정할 수 있습니다.


활 액터 제작 - BaseWeapon

이제 활을 본격적으로 제작해보도록 하겠습니다.

 

활은 월드에 스폰되어야 하는 물체이므로 AActor를 상속받아야 하며, 화살을 스폰하기 위해 화살의 클래스 정보에 대해 알고 있어야 합니다.

 

또한, 활 액터가 부착되어야 하는 WeaponSocketName, 활 시위의 소켓 이름을 저장해야 하는 StringSocketName, 화살을 스폰하기 위한 ArrowSocketName을 알고 있어야 합니다.

 

저의 경우 무기가 여러 종류가 있기에 Bow class는 WeaponBase를 상속받아 만들었으며, WeaponBase에서 장착과 해제에 관한 내용이 정의되어 있습니다.

 

참고로 무기 SkeletalMesh의 소켓을 동적으로 변경하기 위해서는 SkeletalMeshComponent가 아닌 PoseableMeshComponent으로 컴포넌트를 추가해야 합니다.

 

WeaponBase.h

더보기
UENUM(BlueprintType)
enum class EWeaponType : uint8
{
    WT_Sword,	// 검
    WT_Bow,		// 활
    WT_Torch,	// 횃불
};

/**
 * 
 */
UCLASS()
class TIMELESSADVENTURE_API ATA_WeaponBase : public AActor
{
    GENERATED_BODY()
	
public:
    ATA_WeaponBase();

public:
    FORCEINLINE EWeaponType GetWeaponType() { return WeaponType; }

public:
    // 무기 장착 함수
    virtual void EquipWeapon(class USkeletalMeshComponent* Mesh);
    // 무기 제거 함수
    virtual void RemoveWeapon();

protected:
    virtual void Tick(float DeltaTime) override;

    // 부착할 소켓 이름
    UPROPERTY(EditAnywhere, Category = "Weapon", meta = (AllowPrivateAccess = "true"))
    FName WeaponSocketName;

    // 무기 메쉬
    UPROPERTY(VisibleAnywhere, Category = "Component")
    TObjectPtr<class UPoseableMeshComponent> WeaponMesh;

    // 무기 타입
    EWeaponType WeaponType;

    // 임시 스켈레톤 저장
    TObjectPtr<class USkeletalMeshComponent> TempMesh;
};

WeaponBase.cpp

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


#include "Item/TA_WeaponBase.h"

#include "GameFramework/Character.h"
#include "Components/PoseableMeshComponent.h"

ATA_WeaponBase::ATA_WeaponBase()
{
    PrimaryActorTick.bCanEverTick = true;

    WeaponMesh = CreateDefaultSubobject<UPoseableMeshComponent>(TEXT("WeaponMesh"));
    RootComponent = WeaponMesh;
    WeaponMesh->SetCollisionProfileName(TEXT("NoCollision"));
}

void ATA_WeaponBase::EquipWeapon(USkeletalMeshComponent* Mesh)
{
    if (Mesh)
    {
        // 무기를 플레이어의 WeaponSocket에 부착
        WeaponMesh->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetIncludingScale, WeaponSocketName);
        TempMesh = Mesh;
    }
}

void ATA_WeaponBase::RemoveWeapon()
{
    Destroy();
}

void ATA_WeaponBase::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

활 액터 제작 - Bow

활은 시위를 당기는 애니메이션에서 노티파이로 현재 시위가 당겨지고 있는지 아닌지를 설정해주고, Tick에서 상태에 따라 시위를 손에 부착시키거나 원래 위치로 돌려두는 방식으로 구현하게 되었습니다.

 

우선 필요한 변수들 먼저 선언하도록 해줍니다.

 

Bow.h

더보기
UCLASS()
class TIMELESSADVENTURE_API ATA_Bow : public ATA_WeaponBase
{
	GENERATED_BODY()
	
public:
    ATA_Bow();

public:
    // 시위 당김 여부를 세팅하기 위한 함수
    FORCEINLINE void SetIsHold(bool InValue) { bIsHold = InValue; }

protected:
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaSeconds) override;

private:
    // 화살 위치 반환용 함수
    FVector GetArrowSocketLocation(USkeletalMeshComponent* Mesh);

    // 무기 장착 오버라이딩 (화살통 장착용)
    virtual void EquipWeapon(class USkeletalMeshComponent* Mesh) override;

private:
    // 시위 소켓 이름 저장용 변수
    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName StringSocketName;

    // 화살 소켓 이름 저장용 변수
    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName ArrowSocketName;

    // 화살통 소켓 이름 저장용 변수
    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName QuiverSocketName;

    // 화살통 클래스
    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TSubclassOf<AActor> QuiverClass;

    // 생성된 화살통 임시 저장용 변수
    UPROPERTY(VisibleAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TObjectPtr<AActor> Quiver;

    // 화살 클래스
    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TSubclassOf<class ATA_Arrow> ArrowClass;

    // 생성된 화살 임시 저장용 변수
    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TObjectPtr<class ATA_Arrow> Arrow;

    // 시위 당김 / 풀림 여부 판별
    bool bIsHold;

    // 시위 소켓의 초기 위치
    FVector BaseLocation;
    // 시위 소켓의 임시 위치
    FVector StringLocation;
};

다음으로는 실제 로직에 대해 설명드리도록 하겠습니다.

우선 시작과 동시에 화살 줄(joint5)의 활 기준 로컬 위치를 구해주도록 합니다.

  • 해당 위치는 앞으로 초기화를 위해 사용될 예정입니다. 

다음으로는 Tick() 함수입니다.

 

bIsHold 변수가 True인 경우 줄이 당겨지는 상태라고 인지하고, 플레이어의 손 소켓의 위치를 받아와 줄(joint5)의 위치를 설정합니다. 여기서 WorldSpace 기준으로 플레이어 손 소켓 위치를 받아왔으므로 세팅할 때도 설정을 WorldSpace로 해줘야 합니다.

 

반대로 bIsHold 변수가 False인 경우 BeginPlay()에서 구해둔 원본 위치로 줄(joint5) 위치를 설정합니다. BeginPlay() 함수에서 저장했던 줄의 위치는 로컬좌표였으므로, 해당 메쉬(활) 기준 로컬 좌표(ComponentSpace)로 설정합니다. 

 

Tick()에서 사용된 소켓 위치를 받아오는 함수(월드 좌표 기준)

 

활 시위를 당기는 중요한 로직은 이게 전부이며, 시위를 당기고, 화살을 스폰하고, 화살을 발사하는 이벤트는 모두 애님 노티파이로 설정해주었습니다.

Draw Arrow 몽타주
Shoot 몽타주


활 / 화살 전체 코드

Bow.h

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

#pragma once

#include "CoreMinimal.h"
#include "Item/TA_WeaponBase.h"
#include "TA_Bow.generated.h"

/**
 * 
 */
UCLASS()
class TIMELESSADVENTURE_API ATA_Bow : public ATA_WeaponBase
{
    GENERATED_BODY()
	
public:
    ATA_Bow();

public:
    FORCEINLINE void SetIsHold(bool InValue) { bIsHold = InValue; }

protected:
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaSeconds) override;

public:
    // 화살 생성 함수
    void SpawnArrow(USkeletalMeshComponent* Mesh);
    // 화살 발사 함수
    void ShootArrow();
    // 화살 삭제 함수
    void RemoveArrow();
    // 무기 삭제 함수
    virtual void RemoveWeapon() override;

private:
    // 화살 위치 반환용 함수
    FVector GetArrowSocketLocation(USkeletalMeshComponent* Mesh);

    virtual void EquipWeapon(class USkeletalMeshComponent* Mesh) override;

private:
    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName StringSocketName;

    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName ArrowSocketName;

    UPROPERTY(EditAnywhere, Category = "SocketName", meta = (AllowPrivateAccess = "true"))
    FName QuiverSocketName;

    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TSubclassOf<AActor> QuiverClass;

    UPROPERTY(VisibleAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TObjectPtr<AActor> Quiver;

    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TSubclassOf<class ATA_Arrow> ArrowClass;

    UPROPERTY(EditAnywhere, Category = "Bow", meta = (AllowPrivateAccess = "true"))
    TObjectPtr<class ATA_Arrow> Arrow;

    bool bIsHold;

    FVector BaseLocation;
    FVector StringLocation;
};

Bow.Cpp

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


#include "Item/TA_Bow.h"
#include "Item/TA_Arrow.h"

#include "Components/PoseableMeshComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "Kismet/GameplayStatics.h"

#include "DrawDebugHelpers.h"

ATA_Bow::ATA_Bow()
{
    WeaponType = EWeaponType::WT_Bow;

    WeaponSocketName = TEXT("BowSocket");
}

void ATA_Bow::BeginPlay()
{
    Super::BeginPlay();

    // 화살 줄의 로컬 위치를 구해줍니다.
    // * GetTransform().InverseTransformPosition(소켓 위치) : 월드 상의 소켓 위치를 월드변환행렬의 역함수를 곱해 로컬 위치를 구합니다.
    BaseLocation = GetTransform().InverseTransformPosition(WeaponMesh->GetSocketLocation(StringSocketName));
}

void ATA_Bow::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    if (bIsHold)
    {
        // 화살 위치를 전달받아 StringLocation에 저장합니다.
        StringLocation = GetArrowSocketLocation(TempMesh);
        // 화살 줄 Bone의 위치를 당겨진 상태로 설정합니다.
        WeaponMesh->SetBoneLocationByName(StringSocketName, StringLocation, EBoneSpaces::WorldSpace);
    }
    else
    {
        // 화살 줄 Bone의 위치를 기본 위치로 설정합니다.
        WeaponMesh->SetBoneLocationByName(StringSocketName, BaseLocation, EBoneSpaces::ComponentSpace);
    }
}

void ATA_Bow::SpawnArrow(USkeletalMeshComponent* Mesh)
{
    if (ArrowClass)
    {
        Arrow = GetWorld()->SpawnActor<ATA_Arrow>(ArrowClass);
        if (Arrow)
        {
            Arrow->AttachToComponent(Mesh, FAttachmentTransformRules::KeepRelativeTransform, ArrowSocketName);
        }
    }
}

void ATA_Bow::ShootArrow()
{
    if (Arrow)
    {
        // 화면 중앙으로 라인 트레이스를 진행하여 총알이 충돌할 위치를 구해줄 예정입니다.
        // * 뷰포트의 크기를 가져옵니다.
        FVector2D ViewportSize;
        if (GEngine && GEngine->GameViewport)
        {
            GEngine->GameViewport->GetViewportSize(ViewportSize);
        }

        // * 뷰포트의 중앙 좌표를 구해줍니다. (Screen 좌표)
        FVector2D CrosshairLocation(ViewportSize.X / 2.0f, ViewportSize.Y / 2.0f);
        // * Screen 좌표로부터 World 기준 좌표와 방향을 구해주도록 합니다.
        FVector CrosshairWorldPosition;
        FVector CrosshairWorldDirection;

        bool bScreenToWorld = UGameplayStatics::DeprojectScreenToWorld
        (
            UGameplayStatics::GetPlayerController(this, 0),
            CrosshairLocation,
            CrosshairWorldPosition,
            CrosshairWorldDirection
        );

        // 스크린의 중앙 좌표를 구한 경우
        if (bScreenToWorld)
        {
            FHitResult HitResult;
            FVector Start = CrosshairWorldPosition;
            FVector End = Start + CrosshairWorldDirection * 2000.0f;

            // 라인 트레이스 진행
            bool bIsSuccess = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECollisionChannel::ECC_Visibility);

            if (bIsSuccess)
            {
                //DrawDebugSphere(GetWorld(), HitResult.ImpactPoint, 10, 12, FColor::Red, true);

                Arrow->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
                Arrow->FireArrow(HitResult.ImpactPoint);
                Arrow = nullptr;
                return;
            }
        }

        Arrow->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
        Arrow->FireArrow(CrosshairWorldPosition + CrosshairWorldDirection * 2000.0f);
        Arrow = nullptr;
    }
}

void ATA_Bow::RemoveArrow()
{
    if (Arrow)
    {
        Arrow->Destroy();
    }
}

void ATA_Bow::RemoveWeapon()
{
    if (Arrow)
    {
        Arrow->Destroy();
    }

    if (Quiver)
    {
        Quiver->Destroy();
    }

    Super::RemoveWeapon();
}

FVector ATA_Bow::GetArrowSocketLocation(USkeletalMeshComponent* Mesh)
{
    // 화살 소켓 위치를 반환합니다.
    return Mesh->GetSocketLocation(ArrowSocketName);
}

void ATA_Bow::EquipWeapon(USkeletalMeshComponent* Mesh)
{
    Super::EquipWeapon(Mesh);

    // 화살통 스폰
    Quiver = GetWorld()->SpawnActor<AActor>(QuiverClass, GetActorTransform());
    Quiver->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetIncludingScale, QuiverSocketName);
}

Arrow.h

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

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TA_Arrow.generated.h"

UCLASS()
class TIMELESSADVENTURE_API ATA_Arrow : public AActor
{
    GENERATED_BODY()
	
public:
    ATA_Arrow();

protected:
    virtual void BeginPlay() override;

public:	
    virtual void Tick(float DeltaTime) override;

    void FireArrow(FVector Pos);

protected:
    UPROPERTY(VisibleAnywhere)
    TObjectPtr<class UStaticMeshComponent> BaseMesh;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<class UStaticMeshComponent> ShaftMesh;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<class UStaticMeshComponent> IronMesh;

    UPROPERTY(VisibleAnywhere, Category = "Component")
    TObjectPtr<class USphereComponent> SphereComp;

    UPROPERTY(VisibleAnywhere, Category = "Component")
    TObjectPtr<class UProjectileMovementComponent> ProjectileMovementComp;

};

Arrow.Cpp

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


#include "Item/TA_Arrow.h"

#include "GameFramework/ProjectileMovementComponent.h"
#include "Components/SphereComponent.h"

ATA_Arrow::ATA_Arrow()
{
    PrimaryActorTick.bCanEverTick = true;

    BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
    RootComponent = BaseMesh;

    ShaftMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ShaftMesh"));
    ShaftMesh->SetupAttachment(BaseMesh);

    IronMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("IronMesh"));
    IronMesh->SetupAttachment(ShaftMesh);

    SphereComp = CreateDefaultSubobject<USphereComponent>(TEXT("SphereComp"));
    SphereComp->SetupAttachment(IronMesh);

    ProjectileMovementComp = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComp"));
    ProjectileMovementComp->InitialSpeed = 3000.0f;
    ProjectileMovementComp->MaxSpeed = 5000.0f;
    ProjectileMovementComp->ProjectileGravityScale = 0.0f;
}

void ATA_Arrow::BeginPlay()
{
    Super::BeginPlay();
    
    ProjectileMovementComp->SetActive(false);
}

void ATA_Arrow::FireArrow(FVector Pos)
{
    FVector Direction = (Pos - GetActorLocation()).GetSafeNormal();
    ProjectileMovementComp->Velocity = Direction * ProjectileMovementComp->InitialSpeed;
    ProjectileMovementComp->Activate();
}

void ATA_Arrow::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

에임 오프셋

에임 오프셋을 추가하여 상, 하, 좌, 우 컨트롤러의 방향에 맞춰 자연스럽게 플레이어 조준 애니메이션을 처리해줄 수 있습니다.

 

간단히 상, 하 에임 오프셋을 추가해보도록 하겠습니다.

 

우선 에임 오프셋에 사용되는 모든 애니메이션은 1프레임으로 설정해줘야 하며, 모든 에임 오프셋의 기초가 될 포즈를 만들어주도록 합니다.

Aim_Zero

다음으로 필요한 방향의 에임 애니메이션을 추가해주도록 합니다.

좌(Up) / 우(Down)

참고로 위에서 만든 AimZero 애니메이션 이외에 중앙 애니메이션을 따로 추가해줘야 합니다.

전방(Center)

위와 같이 만들었다면 다음으로 AimZero를 제외한 모든 에임 애니메이션의 BasePose를 AimZero로 설정해주도록 합니다.

 

이후 에임 오프셋 애니메이션을 제작하고 해당 애니메이션들을 각도에 맞춰 지정해주도록 합니다.

저의 경우 상/하 애니메이션만 필요하기에 각도는 -90 ~ 90도로 설정하였습니다.

 

다음으로 애니메이션 블루프린트의 필요한 위치에 해당 에임 오프셋을 추가해주도록 합니다.

 

참고로 컨트롤러 에임 값은 캐릭터 클래스의 GetBaseAimRotation()으로 얻어올 수 있으며, 저는 상하 회전값만 알면 되므로 그 중 Pitch만을 사용하였습니다.


결과

화살 구현 결과

728x90
반응형