Chapter 11. Third Person Shooter
11.1 TPS 실습 준비
11.1-1 TPS 애셋 준비
- Epic Games Launcher의 마켓 플레이스
"Animation starter pack" 검색 후 구매 > 프로젝트 추가
모든 프로젝트 표시합니다 체크 > StudyProject 클릭
버전 선택 > 5.0 선택 후 프로젝트에 추가 클릭.
- "Military Weapon Silver" 검색 후 구매 > 프로젝트 추가
모든 프로젝트 표시합니다 체크 > StudyProject 클릭
버전 선택 > 4.27 선택 후 프로젝트에 추가 클릭.
- 총구 소켓 생성
이후에 TPS 전용 공격을 만드려면 총구 소켓이 필수적임.
Content Browser > MilitaryWeapSilver > Weapons > Assult_Rifle_A
Root_Bone1 우클릭 > Add Socket > "MuzzleSocket"
Viewport 좌상단 Perspective 클릭> Bottom 클릭 후 총구 앞에 MuzzleSocket 배치.
Bottom 클릭 후 Left 클릭. MuzzleSocket 배치 확인.
11.1-2 TPS 전용 캐릭터 클래스
- TPSCharacter 클래스 생성
새 C++ 클래스 > Character 부모 클래스 > "STPSCharacter" 클래스 생성
새 블루프린트 애셋 > STPSCharacter 부모 클래스 > "BP_TPSCharacter" 생성
BP_TPSCharacter > Details > Skeletal Mesh Asset에 SK_Mannequin을 지정하는데
해당 스켈레탈 메시 경로를 잘 봐야함. AnimStarterPack 폴더가 경로에 있는걸로 지정.
<hide/>
// STPSCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "InputActionValue.h"
#include "STPSCharacter.generated.h"
UCLASS()
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
GENERATED_BODY()
public:
ASTPSCharacter();
virtual void PossessedBy(AController* NewController) override;
float GetForwardInputValue() const { return ForwardInputValue; }
float GetRightInputValue() const { return RightInputValue; }
protected:
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
private:
void Move(const FInputActionValue& Value);
void Look(const FInputActionValue& Value);
void Attack(const FInputActionValue& InValue);
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
TObjectPtr<class USpringArmComponent> SpringArmComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
TObjectPtr<class UCameraComponent> CameraComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
TObjectPtr<class USPawnInputConfig> PawnInputConfig;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
TObjectPtr<class UInputMappingContext> PawnInputMappingContext;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
float ForwardInputValue;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
float RightInputValue;
};
// STPSCharacter.cpp
#include "STPSCharacter.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Components/InputComponent.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Input/SPawnInputConfig.h"
#include "Kismet/KismetSystemLibrary.h"
ASTPSCharacter::ASTPSCharacter()
{
PrimaryActorTick.bCanEverTick = false;
float CharacterHalfHeight = 90.f;
float CharacterRadius = 60.f;
GetCapsuleComponent()->InitCapsuleSize(CharacterRadius, CharacterHalfHeight);
GetCapsuleComponent()->SetCollisionProfileName(TEXT("SCharacterPreset"));
FVector PivotPosition(0.f, 0.f, -CharacterHalfHeight);
FRotator PivotRotation(0.f, -90.f, 0.f);
GetMesh()->SetRelativeLocationAndRotation(PivotPosition, PivotRotation);
SpringArmComponent = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArmComponent"));
SpringArmComponent->SetupAttachment(RootComponent);
SpringArmComponent->TargetArmLength = 400.f;
SpringArmComponent->SetRelativeRotation(FRotator(-15.f, 0.f, 0.f));
CameraComponent = CreateDefaultSubobject<UCameraComponent>(TEXT("CameraComponent"));
CameraComponent->SetupAttachment(SpringArmComponent);
CameraComponent->SetRelativeLocation(FVector(0.f, 100.f, 0.f)); // TPS 방식의 슈팅 게임 특징.
GetCharacterMovement()->MaxWalkSpeed = 500.f;
GetCharacterMovement()->MinAnalogWalkSpeed = 20.f;
GetCharacterMovement()->JumpZVelocity = 500.f;
GetCharacterMovement()->AirControl = 0.35f;
GetCharacterMovement()->BrakingDecelerationWalking = 2000.f;
SpringArmComponent->TargetArmLength = 450.f;
SpringArmComponent->SetRelativeRotation(FRotator::ZeroRotator);
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
SpringArmComponent->bUsePawnControlRotation = true;
SpringArmComponent->bDoCollisionTest = true;
SpringArmComponent->bInheritPitch = true;
SpringArmComponent->bInheritYaw = true;
SpringArmComponent->bInheritRoll = false;
GetCharacterMovement()->bOrientRotationToMovement = false;
GetCharacterMovement()->bUseControllerDesiredRotation = true;
GetCharacterMovement()->RotationRate = FRotator(0.f, 480.f, 0.f);
}
void ASTPSCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (APlayerController* PlayerController = Cast<APlayerController>(NewController))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
{
Subsystem->AddMappingContext(PawnInputMappingContext, 0);
}
}
}
void ASTPSCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
EnhancedInputComponent->BindAction(PawnInputConfig->MoveAction, ETriggerEvent::Triggered, this, &ThisClass::Move);
EnhancedInputComponent->BindAction(PawnInputConfig->LookAction, ETriggerEvent::Triggered, this, &ThisClass::Look);
EnhancedInputComponent->BindAction(PawnInputConfig->JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump);
EnhancedInputComponent->BindAction(PawnInputConfig->AttackAction, ETriggerEvent::Started, this, &ThisClass::Attack);
}
}
void ASTPSCharacter::Move(const FInputActionValue& Value)
{
if (nullptr != Controller)
{
FVector2D MovementVector = Value.Get<FVector2D>();
ForwardInputValue = MovementVector.X;
RightInputValue = MovementVector.Y;
const FRotator CurrentControlRotation = GetController()->GetControlRotation();
const FRotator CurrentControlRotationYaw(0.f, CurrentControlRotation.Yaw, 0.f);
FVector ForwardDirection = FRotationMatrix(CurrentControlRotationYaw).GetUnitAxis(EAxis::X);
FVector RightDirection = FRotationMatrix(CurrentControlRotationYaw).GetUnitAxis(EAxis::Y);
AddMovementInput(ForwardDirection, MovementVector.X);
AddMovementInput(RightDirection, MovementVector.Y);
}
}
void ASTPSCharacter::Look(const FInputActionValue& Value)
{
if (nullptr != Controller)
{
FVector2D LookAxisVector = Value.Get<FVector2D>();
AddControllerYawInput(LookAxisVector.X);
AddControllerPitchInput(LookAxisVector.Y);
}
}
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Attack() has been called.")));
}
- Input 관리
BP_TPSCharacter > Details > "Input" 검색
Override Input Component Class에 EnhancedInputComponent 지정.
Pawn Input Config에 IC_SPawn 지정. Pawn Input Mapping Context에 IMC_SPawn 지정.
- AnimInstance 생성
새 C++ 클래스 > AnimInstance 부모 클래스 > "STPSAnimInstance"
아래와 같이 작성 후 컴파일.
새 Animation 애셋 > Animation Blueprint
UE4_Mannequin_Skeleton 스켈레톤, ASAnimInstance 부모 클래스
"ABP_TPSCharacter" 생성
Asset Browser > Idle_Rifle_Hip 애니메이션을 Output Pose로 임시 설정.
BP_TPSCharacter > Details > AnimClass에 ABP_TPSCharacter 지정.
State Machine을 만들고, 해당 State Machine에는 Ground와 Jump 두 가지 스테이트만 생성.
<hide/>
// STPSAnimInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimInstance.h"
#include "STPSAnimInstance.generated.h"
UCLASS()
class STUDYPROJECT_API USTPSAnimInstance : public UAnimInstance
{
GENERATED_BODY()
public:
USTPSAnimInstance();
virtual void NativeInitializeAnimation() override;
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
TObjectPtr<class ASTPSCharacter> OwnerCharacter;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
TObjectPtr<class UCharacterMovementComponent> MovementComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
FVector MoveInputWithMaxSpeed;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
FVector MoveInput;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
uint8 bIsFalling : 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
uint8 bIsDead : 1;
};
<hide/>
// STPSAnimInstance.cpp
#include "STPSAnimInstance.h"
#include "STPSCharacter.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/KismetSystemLibrary.h"
USTPSAnimInstance::USTPSAnimInstance()
: bIsDead(false)
{
}
void USTPSAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwnerCharacter = Cast<ASTPSCharacter>(GetOwningActor());
if (OwnerCharacter)
{
MovementComponent = OwnerCharacter->GetCharacterMovement();
}
}
void USTPSAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
if (MovementComponent && OwnerCharacter)
{
// ASTPSCharacter::Move() 함수는 무조건 매 틱마다 호출되는 함수가 아님. 키보드 입력이 있을 시에만 매 틱마다 호출됨.
// 즉, 키보드를 떼면 Move() 함수도 호출되지 않아서 ForwardInputValue가 갱신되지 않음.
// 이때문에 아래와 같이 MovementComponent의 속도를 확인해서 속도가 있을 때만 키보드 입력을 그대로 사용함.
float ForwardInputValue = fabs(MovementComponent->Velocity.X) * OwnerCharacter->GetForwardInputValue();
float RightInputValue = fabs(MovementComponent->Velocity.Y) * OwnerCharacter->GetRightInputValue();
float UpInputValue = MovementComponent->Velocity.Z;
MoveInputWithMaxSpeed = FVector{ ForwardInputValue, RightInputValue, UpInputValue };
float X = fabs(MoveInputWithMaxSpeed.X) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.X / fabs(MoveInputWithMaxSpeed.X);
float Y = fabs(MoveInputWithMaxSpeed.Y) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.Y / fabs(MoveInputWithMaxSpeed.Y);
float Z = fabs(MoveInputWithMaxSpeed.Z) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.Z / fabs(MoveInputWithMaxSpeed.Z);
MoveInput = FVector{ X, Y, Z };
bIsFalling = MovementComponent->IsFalling();
}
}




- Blend Space를 이용한 전진 후진 애니메이션 예제
새 애니메이션 애셋 > Blend Space > UE4_Mannequin_Skeleton 선택 > "BS_Run"
좌측 Asset Details > Horizontal Axis > Name에 GroundSpeed
Minimum Axis Value에 -1.0, Maximum Axis Value에 1.0 설정.
그래프에 -1, 0에 후진 애니메이션 0, 0에 정지 애니메이션 1, 0에 전진애니메이션 배치.
ABP_TPSCharacter > Base Action > Ground State에 아래와 같이 그래프 작성.

- Run 애니메이션이 끊기는 문제
애니메이션이 재생되다가 다른 애니메이션으로 전환될 때 끊기는 부분이 있음.
State Machine을 이용하면 훨씬 부드러워짐.
ABP_TPSCharacter > AnimGraph > BaseAction > Ground 스테이트 더블클릭
우클릭 Add State Machine > "GroundInner" > 아래와 같이 그래프 작성
Idle to Forward 트랜지션 룰에는 아래와 같이 그래프 작성
Forward to Idle 트랜지션 룰에도 아래와 같이 그래프 작성
Idle to Backward 트랜지션 룰에도 아래와 같이 그래프 작성
트랜지션 룰도 공유할 수 있음. 똑같은 룰을 사용한다면 공유를 통해 구현함.
Idle to Forward 트랜지션 클릭 > Details > Transition > Promote To Shared 클릭 > "GoToForward"
Backward to Froward 트랜지션 클릭 > Details > Transition > Use Sharaed 클릭 > "GoToForward"
Forward to Idle 트랜지션 클릭 > Details > Transition > Promote To Shared 클릭 > "GoToIdle"
Backward to Idle 트랜지션 클릭 > Details > Transition > Use Sharaed 클릭 > "GoToIdle"
Idle to Backward 트랜지션 클릭 > Details > Transition > Promote To Shared 클릭 > "GoToBackward"
Forward to Backward 트랜지션 클릭 > Details > Transition > Use Sharaed 클릭 > "GoToBackward"
트랜지션 > Details > Duration 항목의 시간을 늘릴 수록 Blend 사이 시간이 늘어나서 부드럽게 연결됨.





- 위와 같이 상태머신 안에 다시 상태머신을 작성하는 것을 중첩 상태 머신이라 함.
- 하체 회전
지금은 좌우 움직일 시에 애니메이션이 어색함.
상하체를 분리해서 애니메이션 하는 것이 맞음.
UE4_Mannequin_Skeleton > Skeleton Tree > Pelvis 클릭> Details
Rotaion 조절해보면 마네킹이 전체적으로 회전됨.
우리가 원하는 것은 하체만 돌아가는 것을 원함.
전략은 Pelvis를 돌리고 Spine01만 돌린 만큼 다시 원상복귀 시키고자 함.
ABP_TPSCharacter > AnimGraph > BaseAction과 OutputPose 사이를 아래와 같이 작성
Transform (Modify) Bone 클릭 > Details > Bone to Modify에 각각 Pevis와 Spine_01 지정
Rotation Mode에 Add to Existing 선택해서 기존 값에 더해주는 방식으로 지정.
작성 후 컴파일 해보면 우리가 원하는 것대로 정면을 쳐다보며 하체만 돌아감.

- 하체 회전 구현


- 좌우 움직임 개선
완전히 좌측 혹은 완전히 우측으로 움직이는 경우엔 애니메이션 재생이 안됨.
Idle to Forward 트랜지션 룰이 GroundSpeed가 0 보다 클 경우에 전환되게끔 작성되었기 때문.
이를 해결하기 위해서 MoveRight 값으로도 전환되게끔 작성하자.



- 후진 할 때 개선
후진 할 때는 골반이 반대로 돌아야함.

- 방향 전환 할 때 좀 더 부드럽게 하는 방법
지금은 방향 전환시 뚝뚝 끊김. 해당 부분을 해결 해보자.
값을 좀 더 천천히 변화되게끔 해야함. 이를 위해 Lerp(Linear Interpolation) 함수를 활용하면 됨.
<hide/>
// STPSAnimInstance.cpp
...
void USTPSAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
static float CurrentX = 0.f;
static float CurrentY = 0.f;
static float CurrentZ = 0.f;
if (MovementComponent && OwnerCharacter)
{
...
float TargetX = fabs(MoveInputWithMaxSpeed.X) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.X / fabs(MoveInputWithMaxSpeed.X);
CurrentX = FMath::FInterpTo(CurrentX, TargetX, DeltaSeconds, 10.f);
float TargetY = fabs(MoveInputWithMaxSpeed.Y) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.Y / fabs(MoveInputWithMaxSpeed.Y);
CurrentY = FMath::FInterpTo(CurrentY, TargetY, DeltaSeconds, 10.f);
float TargetZ = fabs(MoveInputWithMaxSpeed.Z) < KINDA_SMALL_NUMBER ? 0.f : MoveInputWithMaxSpeed.Z / fabs(MoveInputWithMaxSpeed.Z);
CurrentZ = FMath::FInterpTo(CurrentZ, TargetZ, DeltaSeconds, 10.f);
MoveInput = FVector{ CurrentX, CurrentY, CurrentZ };
...
}
}
- 애니메이션 구조에 대한 고찰
현재의 애니메이션 블루프린트 방식은 각 캐릭터마다 따로 만들어주는 형식.
그러나 공통된 부분들이 많음.
애니메이션 블루프린트도 상속 구조를 활용해서 자신만의 구조를 만들어보는게 좋음.
또한, 각 무기마다 달라지는 모션들에 대응하기 위해 Dynamic Animation Linking도 개인적으로 학습 추천.
혹은 모션 워핑 같은 기법(벽넘기, ...)들도 적용해보면서 애니메이션에 대해 좀 더 심도 있는
학습을 해보는 것이 취업에 많은 도움이 됨.
11.1-3 무기 부착
- 무기를 안들고 있는 Idle 모션
UE4_Mannequin_Skeleton에는 무기를 안들고 있는 Idle 모션이 없음.
애니메이션 리타깃을 통해 해당 모션을 다른 곳에서 가져와보자.
- 무기 소켓 생성
Content Browser > AnimStarterPack > UE4_Mannequin > Mesh
UE4_Mannequin_Skeleton > Skeleton Tree > "hand_r" 검색 후 우클릭
Add Socket > "WeaponSocket" 생성
WeaponSocket 우클릭 > Add Preview Asset > "Assult_Rifle_A" 지정
트랜스폼을 아래와 같이 지정.

- STPSCharacter 클래스 수정
STPSCharacter 클래스 내용을 아래와 같이 수정 후 컴파일.
BP_TPSCharacter에서 WeaponSkeletalMeshComponent의 Mesh에 Rifle 지정.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* WeaponSkeletalMeshComponent;
};
<hide/>
// STPSCharacter.cpp
...
ASTPSCharacter::ASTPSCharacter()
{
...
FName WeaponSocket(TEXT("WeaponSocket"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
WeaponSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponSkeletalMeshComponent"));
WeaponSkeletalMeshComponent->SetupAttachment(GetMesh(), WeaponSocket);
}
}
...
- 총 보관 소켓 생성
UE4_Mannequin_Skeleton > Preview Animation > Equip_Rifle_Standing 더블클릭
총을 보관하는 위치를 살펴보기 위해 애니메이션을 일시중지하고 뒤쪽으로 손이 갈때를 확인.
Pelvis 우클릭 > New Socket > "RifleHolder" 추가. 아래와 같이 설정.

- 총 보관 소켓 전용 메시 컴포넌트
BP_TPSCharacter > Details > WeaponHolderSkeletalMeshComponent에서 라이플 지정.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* WeaponHolderSkeletalMeshComponent;
};
<hide/>
// STPSCharacter.cpp
...
ASTPSCharacter::ASTPSCharacter()
{
...
FName RifleHolderSocket(TEXT("RifleHolder"));
//if (true == GetMesh()->DoesSocketExist(RifleHolder))
{
WeaponHolderSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponHolderSkeletalMeshComponent"));
WeaponHolderSkeletalMeshComponent->SetupAttachment(GetMesh(), RifleHolderSocket);
}
}
...
- 무기 교체 구현 준비
숫자 키 1을 누르면 라이플, 2를 누르면 맨손으로 교체하고자 함.
Content Browser > InputActions > 새 Input 애셋 > Input Action
"IA_QuickSlot1", "IA_QuickSlot2" 생성
SPawnInputConfig 클래스 내용을 아래와 같이 수정 후 IC_Pawn에 적절히 인풋 액션 지정.
IMC_Pawn에도 IA_QuickSlot1과 IA_QuickSlot2를 추가하고 단축키 지정.
[EEquipState에서 EEquipItemType으로 전면 변경 필요]
<hide/>
// SPawnInputConfig.h
...
class STUDYPROJECT_API USPawnInputConfig : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> QuickSlot1Action;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> QuickSlot2Action;
};
<hide/>
// STPSCharacter.h
...
UENUM(BlueprintType)
enum class EEquipItemType : uint8
{
Fist,
Rifle,
End
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnEquipStateChanged, EEquipItemType, InOldState, EEquipItemType, InNewState);
UCLASS()
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
void QuickSlot1(const FInputActionValue& InValue);
void QuickSlot2(const FInputActionValue& InValue);
public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
EEquipItemType CurrentEquipState = EEquipItemType::Fist;
FOnEquipStateChanged OnEquipStateChanged;
private:
...
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::QuickSlot1(const FInputActionValue& InValue)
{
if (EEquipItemType::Rifle == CurrentEquipState)
{
return;
}
OnEquipStateChanged.Broadcast(CurrentEquipState, EEquipItemType::Rifle);
CurrentEquipState = EEquipItemType::Rifle;
}
void ASTPSCharacter::QuickSlot2(const FInputActionValue& InValue)
{
if (EEquipItemType::Fist == CurrentEquipState)
{
return;
}
OnEquipStateChanged.Broadcast(CurrentEquipState, EEquipItemType::Fist);
CurrentEquipState = EEquipItemType::Fist;
}
- 애님 노티파이를 이용한 무기 교체 구현
Content Browser > Animations > 새 Animation 애셋 > Animation Montage > "AM_EquipRifle"
타임 테이블에 Equip_Rifle_Standing을 드래그 드랍. 적절한 위치에 "HoldRifle"이라는 노티파이 생성.
STPSAnimInstance 클래스와 STPSCharacter 클래스의 내용을 아래와 같이 수정. 컴파일 하고 실행.
ABP_TPSCharacter > Class Details > EquipAnimation에 AM_RifleEquip 지정.
AnimGraph도 아래와 같이 수정. Blend Poses 노드의 Active Enum Value 우클릭 > Bind Pin을 활용.
[FOnRifleHolded에서 FOnWeaponHolded로 변경하는게 맞음.]
<hide/>
// STPSAnimInstance.h
...
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRifleHolded);
UCLASS()
class STUDYPROJECT_API USTPSAnimInstance : public UAnimInstance
{
...
public:
...
UFUNCTION()
void EquipRifleAnimation(EEquipState InOldState, EEquipState InNewState);
private:
UFUNCTION()
void AnimNotify_HoldRifle();
public:
FOnRifleHolded OnRifleHolded;
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
EEquipState EquipState;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
TObjectPtr<class UAnimMontage> EquipAnimation;
};
<hide/>
// STPSAnimInstance.cpp
...
void USTPSAnimInstance::NativeInitializeAnimation()
{
...
if (OwnerCharacter)
{
MovementComponent = OwnerCharacter->GetCharacterMovement();
if (false == OwnerCharacter->OnIronSight.IsAlreadyBound(this, &ThisClass::OnIronSight))
{
OwnerCharacter->OnEquipStateChanged.AddDynamic(this, &ThisClass::EquipRifleAnimation);
}
}
}
...
void USTPSAnimInstance::EquipRifleAnimation(EEquipState InOldState, EEquipState InNewState)
{
if (false == Montage_IsPlaying(EquipAnimation))
{
Montage_Play(EquipAnimation);
EquipState = InNewState;
}
}
void USTPSAnimInstance::AnimNotify_HoldRifle()
{
OnRifleHolded.Broadcast();
}
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TObjectPtr<class USTPSAnimInstance> AnimInstance;
};
<hide/>
// STPSCharacter.cpp
...
#include "STPSAnimInstance.h"
ASTPSCharacter::ASTPSCharacter()
{
...
FName WeaponSocket(TEXT("WeaponSocket"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
WeaponSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponSkeletalMeshComponent"));
WeaponSkeletalMeshComponent->SetupAttachment(GetMesh(), WeaponSocket);
WeaponSkeletalMeshComponent->SetHiddenInGame(true);
}
FName RifleHolderSocket(TEXT("RifleHolder"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
WeaponHolderSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponHolderSkeletalMeshComponent"));
WeaponHolderSkeletalMeshComponent->SetupAttachment(GetMesh(), RifleHolderSocket);
WeaponHolderSkeletalMeshComponent->SetHiddenInGame(false);
}
}
void ASTPSCharacter::PossessedBy(AController* NewController)
{
...
AnimInstance = Cast<USTPSAnimInstance>(GetMesh()->GetAnimInstance());
AnimInstance->OnRifleHolded.AddDynamic(this, &ThisClass::OnRifleHolded);
}
...
void ASTPSCharacter::OnRifleHolded()
{
if (true == WeaponSkeletalMeshComponent->bHiddenInGame)
{
WeaponSkeletalMeshComponent->SetHiddenInGame(false, true);
}
else
{
WeaponSkeletalMeshComponent->SetHiddenInGame(true, true);
}
if (true == WeaponHolderSkeletalMeshComponent->bHiddenInGame)
{
WeaponHolderSkeletalMeshComponent->SetHiddenInGame(false, true);
}
else
{
WeaponHolderSkeletalMeshComponent->SetHiddenInGame(true, true);
}
}



- 조준 구현 준비
마우스 우클릭을 꾹 누르고 있으면 조준, 떼면 비조준을 구현하고자 함.
Content Browser > InputActions > 새 Input 애셋 > Input Action > "IA_IronSight" 생성
SPawnInputConfig 클래스 내용을 아래와 같이 수정 후 IC_Pawn에 적절히 인풋 액션 지정.
IMC_Pawn에도 IA_IronSight를 추가하고 우클릭 지정.
<hide/>
// SPawnInputConfig.h
...
class STUDYPROJECT_API USPawnInputConfig : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> IronSightAction;
};
<hide/>
// STPSCharacter.h
...
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnEquipStateChanged, EEquipState, InOldState, EEquipState, InNewState);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnIronSight);
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
void QuickSlot2(const FInputActionValue& InValue);
void StartIronSight(const FInputActionValue& InValue);
void EndIronSight(const FInputActionValue& InValue);
public:
...
FOnEquipStateChanged OnEquipStateChanged;
FOnIronSight OnIronSight;
public:
...
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
...
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
...
EnhancedInputComponent->BindAction(PawnInputConfig->IronSightAction, ETriggerEvent::Started, this, &ThisClass::StartIronSight);
EnhancedInputComponent->BindAction(PawnInputConfig->IronSightAction, ETriggerEvent::Completed, this, &ThisClass::EndIronSight);
}
}
...
void ASTPSCharacter::StartIronSight(const FInputActionValue& InValue)
{
OnIronSight.Broadcast();
}
void ASTPSCharacter::EndIronSight(const FInputActionValue& InValue)
{
OnIronSight.Broadcast();
}
...
<hide/>
// STPSAnimInstance.h
...
class STUDYPROJECT_API USTPSAnimInstance : public UAnimInstance
{
...
public:
...
UFUNCTION()
void EquipRifleAnimation(EEquipState InOldState, EEquipState InNewState);
UFUNCTION()
void OnIronSight();
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
uint8 bIronSightOn : 1;
};
<hide/>
// STPSAnimInstance.cpp
...
USTPSAnimInstance::USTPSAnimInstance()
: bIsDead(false)
, bIronSightOn(false)
{
}
void USTPSAnimInstance::NativeInitializeAnimation()
{
...
if (OwnerCharacter)
{
...
if (false == OwnerCharacter->OnIronSight.IsAlreadyBound(this, &ThisClass::OnIronSight))
{
OwnerCharacter->OnEquipStateChanged.AddDynamic(this, &ThisClass::EquipRifleAnimation);
}
if (false == OwnerCharacter->OnIronSight.IsAlreadyBound(this, &ThisClass::OnIronSight))
{
OwnerCharacter->OnIronSight.AddDynamic(this, &ThisClass::OnIronSight);
}
}
}
...
void USTPSAnimInstance::OnIronSight()
{
bIronSightOn = ~bIronSightOn;
}
...



- 중첩 상태 머신을 통한 애니메이션 구조 개선
위 스크린샷들을 보면 상당히 유사한 부분들이 많음.
만약 폭탄, 권총, 밀리, 샷건, SMG가 갑자기 추가된다면? 위 스크린샷 3곳에서 똑같은걸 계속해줘야함.
실수해서 권총을 들었는데 라이플이 나올수도 있음.
이 부분들을 중첩 상태머신을 사용하면 상태머신 하나씩 추가하는 방식으로 개선 가능.
ABP_TPSCharacter > AnimGraph > BaseAction > Ground > GroundInner의 내부를 아래와 같이 수정.
다시 Fist > Add StateMachine > "FistInner"






11.2 공격 구현
11.2-1 단발 사격
- 투사체 액터
슈팅 게임에서는 두 가지 방식으로 공격을 구현할 수 있음.
쏘는 즉시 적이 맞고 연산 부하가 비교적 적은 LineTrace 방식
투사체가 날아가 적을 맞추고 연산 부하가 비교적 큰 Projectile 방식.
이번 예제에서는 Projectile 방식으로 구현 해보고자 함.
새 C++ 클래스 > Actor 부모 클래스 > "SProjectileActor" 클래스 생성
새 블루프린트 클래스 > SProjectileActor 부모 클래스 > StaticMesh에 적절하게 지정.
[투사체 방식 별로임. 그냥 LineTrace 방식으로 ㄱㄱ. 회사에서도 이렇게한다고 소개.]
[투사체 방식으로 진행. 대신 로켓 런쳐부터 구현.]
<hide/>
// SProjectileActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SProjectileActor.generated.h"
UCLASS()
class STUDYPROJECT_API ASProjectileActor : public AActor
{
GENERATED_BODY()
public:
ASProjectileActor();
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
class USphereComponent* GetCollisionComp() const { return CollisionComponent; }
class UStaticMeshComponent* GetMesh() const { return StaticMeshComponent; }
class UProjectileMovementComponent* GetProjectileMovement() const { return ProjectileMovementComponent; }
private:
UPROPERTY(VisibleDefaultsOnly, Category = ASProjectileActor, meta = (AllowPrivateAccess = true))
TObjectPtr<class USphereComponent> CollisionComponent;
UPROPERTY(VisibleDefaultsOnly, Category = ASProjectileActor, meta = (AllowPrivateAccess = true))
TObjectPtr<class UStaticMeshComponent> StaticMeshComponent;
UPROPERTY(VisibleDefaultsOnly, Category = ASProjectileActor, meta = (AllowPrivateAccess = true))
TObjectPtr<class UProjectileMovementComponent> ProjectileMovementComponent;
UPROPERTY(EditDefaultsOnly, Category = ASProjectileActor, Meta = (AllowPrivateAccess = true))
TObjectPtr<class UParticleSystemComponent> ParticleSystemComponent;
};
<hide/>
// SProjectileActor.cpp
#include "SProjectileActor.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Components/SphereComponent.h"
#include "Particles/ParticleSystemComponent.h"
ASProjectileActor::ASProjectileActor()
{
CollisionComponent = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionComponent"));
SetRootComponent(CollisionComponent);
CollisionComponent->InitSphereRadius(5.0f);
CollisionComponent->BodyInstance.SetCollisionProfileName("Projectile");
CollisionComponent->OnComponentHit.AddDynamic(this, &ThisClass::OnHit);
CollisionComponent->SetWalkableSlopeOverride(FWalkableSlopeOverride(WalkableSlope_Unwalkable, 0.f));
CollisionComponent->CanCharacterStepUpOn = ECB_No;
StaticMeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMeshComponent"));
StaticMeshComponent->SetupAttachment(GetRootComponent());
StaticMeshComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent"));
ProjectileMovementComponent->UpdatedComponent = CollisionComponent;
ProjectileMovementComponent->InitialSpeed = 3000.f;
ProjectileMovementComponent->MaxSpeed = 3000.f;
ProjectileMovementComponent->bRotationFollowsVelocity = true;
ProjectileMovementComponent->bShouldBounce = true;
InitialLifeSpan = 3.0f;
ParticleSystemComponent = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("ParticleSystemComponent"));
ParticleSystemComponent->SetupAttachment(GetRootComponent());
ParticleSystemComponent->SetAutoActivate(false);
}
void ASProjectileActor::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
if ((OtherActor != nullptr) && (OtherActor != this) && (OtherComp != nullptr) && OtherComp->IsSimulatingPhysics())
{
OtherComp->AddImpulseAtLocation(GetVelocity() * 100.0f, GetActorLocation());
ParticleSystemComponent->SetRelativeLocation(FVector::ZeroVector);
ParticleSystemComponent->Activate(true);
Destroy();
}
}
- 사격 구현
ASTPSCharacter 클래스 내용을 아래와 같이 수정 후 컴파일.
BP_TPSCharacter > Details > Projectile Actor Class에 BP_ProjectileActor 지정.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TSubclassOf<class ASProjectileActor> ProjectileActorClass;
};
<hide/>
// STPSCharacter.cpp
...
#include "STPSAnimInstance.h"
#include "SProjectileActor.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Kismet/KismetMathLibrary.h"
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
APlayerController* PlayerController = Cast<APlayerController>(GetController());
FVector MuzzleLocation = WeaponSkeletalMeshComponent->GetSocketLocation(FName("MuzzleSocket"));
FHitResult HitResult;
FVector Start;
Start = CameraComponent->GetComponentLocation();
FVector End = Start + CameraComponent->GetForwardVector() * 5000.f;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.AddIgnoredComponent((const UPrimitiveComponent*)(CameraComponent));
QueryParams.bTraceComplex = true;
bool bIsCollide = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_Visibility, QueryParams);
//DrawDebugLine(GetWorld(), Start, End, FColor(255, 0, 0, 255), false, 20.f, 0U, 5.f);
//DrawDebugSphere(GetWorld(), Start, 3.f, 16, FColor(0, 255, 0, 255), false, 20.f, 0U, 5.f);
//DrawDebugSphere(GetWorld(), End, 3.f, 16, FColor(0, 0, 255, 255), false, 20.f, 0U, 5.f);
FVector SpawnLocation = MuzzleLocation;
FRotator SpawnRotation = FRotator::ZeroRotator;
FTransform SpawnTransform = {};
if (true == bIsCollide)
{
SpawnRotation = UKismetMathLibrary::FindLookAtRotation(MuzzleLocation, HitResult.Location);
SpawnTransform = UKismetMathLibrary::MakeTransform(SpawnLocation, SpawnRotation);
DrawDebugLine(GetWorld(), SpawnLocation, HitResult.Location, FColor(255, 255, 255, 64), false, 0.1f, 0U, 0.5f);
}
else
{
SpawnRotation = UKismetMathLibrary::FindLookAtRotation(MuzzleLocation, End);
SpawnTransform = UKismetMathLibrary::MakeTransform(SpawnLocation, SpawnRotation);
DrawDebugLine(GetWorld(), SpawnLocation, End, FColor(255, 255, 255, 64), false, 0.1f, 0U, 0.5f);
}
FActorSpawnParameters ActorSpawnParams;
ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;
ASProjectileActor* SpawnedActor = GetWorld()->SpawnActor<ASProjectileActor>(ProjectileActorClass, SpawnTransform, ActorSpawnParams);
}
}
- bTraceComplex
좀 더 정밀한 모양(복잡한 모양)의 충돌체와의 충돌 검지를 켤지 말지에 대한 속성.
예로들어, Content Browser > StarterContent > Props > SM_Chair 더블클릭.
Details > Collision Complexity를 Use Complex Collision As Simple로 설정하면
쿼리(충돌 검지) 요청시 정밀한 모양(복잡한 모양)에 대한 쿼리를 제공함.
충돌 계산의 부하는 올라가지만 그만큼 정밀한 게임 플레이가 가능해짐.
SM_Chair > Toolbar > Collision > Auto Convex Collision 클릭 후 우하단 Convex Decomposition을 통해
복잡한 모양의 충돌체를 손쉽게 제작 가능.
- 사격 애니메이션 구현 문제
Content Browser > Fire_Rifle_Hip 검색 후 우클릭 > Create > Create AnimMontage > "AM_FireRifle"
이렇게 만들면 스켈레톤을 실수 잘못 지정하지 않음. 애니메이션 시퀀스도 자동으로 지정됨.
DefaultGroup.DefaultSlot 옆 역삼각형 클릭 > Slot Manager
Add Group > "UpperBodyGroup" 생성 후 우클릭 > Add Slot > "UpperBodySlot"
DefaultGroup.DefaultSlot 옆 역삼각형 클릭 > Slot Name > UpperBodyGroup.UpperBodySlot 지정.
애니메이션이 T Pose라면 Asset Browser > Fire_Rifle_Hip 검색 후 다시 타임테이블에 드래그 드랍.
AM_EquipRifle > DefaultGroup.DefaultSlot 역삼각형 클릭 > Slot Name > UpperBodyGroup.UpperBodySlot 지정.
ABP_TPSCharacter > Slot 'DefaultSlot' 노드 클릭 > Details > Slot Name > UpperBodyGroup.UpperBodySlot 지정.
아래와 같이 그래프 작성. Layered blend per bone은 본을 기준으로 애니메이션을 달리 재생시켜주는 노드.
Layered blend per bone 노드 클릭 > Details > Layer Setup > 아래 그림을 참고


- UpperBodySlot 노드의 Source 핀 연결 불가 문제
Component To Local의 Out 핀을 Layered blend per bone의 Base Pose 핀에 연결 중인데,
동시에 UpperBodySlot 노드의 Source 핀에도 연결 할 수가 없음.
이럴 때는 Cache를 활용함. 해당 포즈를 저장해두는 역할의 노드.

- 사격 애니메이션 로직 구현
아래와 같이 작성 후 컴파일. BP_TPSCharacter > Details > Rifle Fire Anim Montage에 AM_FireRifle 지정.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TObjectPtr<class UAnimMontage> RifleFireAnimMontage;
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
...
if (false == AnimInstance->Montage_IsPlaying(RifleFireAnimMontage))
{
AnimInstance->Montage_Play(RifleFireAnimMontage);
}
}
}
...
- 사격 애니메이션이 재생되지 않는 문제.
지금은 우클릭을 해도 움찔대지 않음. 이는 블랜드 시간 문제.
AM_FireRifle의 플레이 시간이 0.23초 정도로 매우 짧음.
그런데 Asset Details의 Blend In 속 Blend Time은 0.25초라서 블랜드 하느라 사격 애니메이션이 재생되지 않음.
Blend In의 Blend Time을 0.1, Blend Out의 Blend Time을 0.1로 수정.
- 조준 개선 준비사항
지금은 아래를 보고 쏴도 총구는 전방을 향해 있음. 상당히 부자연스러움.
Blend Space 2D를 이용해서 해결해보고자 함.
Content Browser > AnimStarterPack > Aim_Space_Hip 애니메이션 복사
Animations 폴더에 붙혀넣기. 이름을 Aim_CenterMid로 변경.
타임 테이블에서 To Front 버튼 클릭 후 가장 왼쪽 지점에 우클릭 후 Romove Frame from 1 to 87 클릭.
그럼 중간에서 가운데를 총구가 바라보는 애니메이션 시퀀스 완성.
이걸 계속 반복해서 아래 표를 완성해야함. 귀찮다면 아래 알집 다운로드 후 Animations 폴더에 압축풀기.
| 시작 키프레임 | 제거 프레임 1 | 제거 프레임 2 | |
| Aim_CenterMid | 0 | - | 1 ~ 87 |
| Aim_CenterUp | 10 | 0 ~ 10 | 1 ~ 77 |
| Aim_CenterDown | 20 | 0 ~ 20 | 1 ~ 67 |
| Aim_LeftMid | 30 | 0 ~ 30 | 1 ~ 57 |
| Aim_LeftUp | 40 | 0 ~ 40 | 1 ~ 47 |
| Aim_LeftDown | 50 | 0 ~ 50 | 1 ~ 37 |
| Aim_RightMid | 60 | 0 ~ 60 | 1 ~ 27 |
| Aim_RightUp | 70 | 0 ~ 70 | 1 ~ 17 |
| Aim_RightDown | 80 | 0 ~ 80 | 1 ~ 8 |
- 각 포즈를 통해 에임 오프셋을 만들기 위한 설정
9개의 애니메이션 시퀀스 전부 블럭 지정 > 우클릭 > Asset Actions > Bulk Edit via Property Matrix
이 기능은 여러 개의 애셋이 가진 속성을 배열을 통해 한 번에 수정할 수 있게끔 해줌.
우측 탭 > Additive Settings > Additive Anim Type에 Mesh Space
Base Pose Type에 Selected animation frame
Base Pose Animation에는 우측 배열모양 버튼 클릭 > Idle_Rifle_Hip 지정.
모두 지정했으면 Ctrl + Shift + S
- 에임 오프셋 생성
Content Browser > AnimStarterPack > UE4_Mannequin_Skeleton 우클릭 > Create > Aim Offset > "AO_TPS"
AO_TPS > Asset Details > Horizontal Axis > Name에 "Yaw"
Minimum에 -90, Maximum에 90
AO_TPS > Asset Details > Vertical Axis > Name에 "Yaw"
Minimum에 -90, Maximum에 90
Preview Base Pose에 Idle_Rifle_Hip 지정.
Asset Browser에서 아래 그림을 참고하여 적절한 위치에 포즈 애셋 배치.
Ctrl + 좌클릭을 통해 그래프를 찍으면 제대로 총구가 향하는지 확인 가능.

- 에임 오프셋 로직 구현
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
public:
...
float GetCurrentAimPitch() const { return CurrentAimPitch; }
float GetCurrentAimYaw() const { return CurrentAimYaw; }
protected:
...
private:
...
float CurrentAimPitch = 0.f;
float CurrentAimYaw = 0.f;
};
<hide/>
// STPSCharacter.cpp
...
ASTPSCharacter::ASTPSCharacter()
{
PrimaryActorTick.bCanEverTick = true;
...
}
void ASTPSCharacter::Tick(float DeltaSeconds)
{
FRotator ControlRotation = GetController()->GetControlRotation();
CurrentAimPitch = ControlRotation.Pitch;
CurrentAimYaw = ControlRotation.Yaw;
}
...
<hide/>
// STPSAnimInstance.h
...
class STUDYPROJECT_API USTPSAnimInstance : public UAnimInstance
{
...
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess = true))
FRotator ControlRotation;
};
<hide/>
// STPSAnimInstance.cpp
...
void USTPSAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
...
if (MovementComponent && OwnerCharacter)
{
...
ControlRotation.Pitch = OwnerCharacter->GetCurrentAimPitch();
ControlRotation.Yaw = OwnerCharacter->GetCurrentAimYaw();
}
}
...

- 에임 오프셋 참고자료
- 노티파이를 이용한 머즐 이펙트와 사운드
AM_FireRifle > Notifies 테이블 우클릭 > Add Notify > Play Sound 검색 후 엔터
PlaySound 노티파이 클릭 > Details > Sound에 RifleA_Fire01 지정.
만약 애니메이션과 사운드가 완전 매치되는 경우엔 이렇게 지정해주는 것이 편리함.
하지만 총이 달라진다면 사운드도 달라져야해서 C++ 작업이 필요함.(직접 해보시길.)
힌트: AnimNotify_PlaySound 정의, 델리게이트로 PC에게 알려줌, 총기류(equipstate)에 따라 PlaySoundAtLocation()
Notifies 옆 역삼각형 > Add Notify Track > 2번 트랙 추가
2번 트랙 우클릭 > Add Notify > Play Particle Effect 검색 후 엔터
PlayParticleEffect 노티파이 클릭 > Details > 아래와 같이 지정.
힌트: PlaySound하는 곳에서 무기메시->GetSocketLocation()을 가지고 SpawnEmitterAttached()

- 발사 시 카메라 쉐이크
Content Browser > Blueprints > 새 블루프린트 애셋 > CameraShakeBase 부모 클래스
"BP_FireShake" 생성 > Details 내의 값들을 아래 그림을 보고 설정.
STPSCharacter 클래스 내용도 아래와 같이 수정.
BP_TPSCharacter > Details > FireShake에 BP_FireShake 지정.

<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TSubclassOf<class UCameraShakeBase> FireShake;
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
...
PlayerController->ClientStartCameraShake(FireShake);
}
}
...
11.2-2 줌
- 크로스헤어 제작
아래 파일을 다운로드 > Content Browser > UI > 우클릭 > Import to Game/UI 클릭 후 아래 파일 임포트.
UI > 우클릭 > 새 User Interface 애셋 > Widget Blueprint > UserWidget > "WBP_Crosshair"
WBP_Crosshair는 아래 그림 참조.

- 크로스헤어 구현
Content Browser > Blueprints > 새 블루프린트 애셋 > PlayerController 부모 클래스 > "BP_TPSPC"
BP_TPSPC > Event Graph를 아래와 같이 작성.
World Settings > Selected GameMode > Player Controller Class에 BP_TPSPC 지정.

- 줌 구현
카메라의 FOV 값을 조절하면 줌 효과를 낼 수 있음.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
public:
ASTPSCharacter();
virtual void Tick(float DeltaSeconds) override;
...
protected:
...
private:
...
float TargetFOV = 70.f;
float CurrentFOV = 70.f;
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Tick(float DeltaSeconds)
{
...
CurrentFOV = FMath::FInterpTo(CurrentFOV, TargetFOV, DeltaSeconds, 35.f);
CameraComponent->SetFieldOfView(CurrentFOV);
}
...
void ASTPSCharacter::StartIronSight(const FInputActionValue& InValue)
{
OnIronSight.Broadcast();
TargetFOV = 45.f;
}
void ASTPSCharacter::EndIronSight(const FInputActionValue& InValue)
{
OnIronSight.Broadcast();
TargetFOV = 70.f;
}
...
- 맞은 위치에 이펙트 구현
아래 파일 다운로드 후 Content 폴더에 압축 풀기.
ATPSCharacter 클래스 내용을 아래와 같이 수정 후 컴파일.
BP_TPSCharacter > Details > ImpactedLocationWall과 ImpactedLocationFlesh에
각각 P_RifleImpact와 P_blood_splash_02 지정.
Viewport에 BP_TPSCharacter 드래그 드랍 후 테스트.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TObjectPtr<class UParticleSystem> ImpactedLocationWallEffect;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TObjectPtr<class UParticleSystem> ImpactedLocationFleshEffect;
};
<hide/>
// STPSCharacter.cpp
...
#include "Particles/ParticleSystemComponent.h"
#include "Kismet/GameplayStatics.h"
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
...
if (true == bIsCollide)
{
SpawnRotation = UKismetMathLibrary::FindLookAtRotation(MuzzleLocation, HitResult.Location);
SpawnTransform = UKismetMathLibrary::MakeTransform(SpawnLocation, SpawnRotation);
DrawDebugLine(GetWorld(), SpawnLocation, HitResult.Location, FColor(255, 255, 255, 64), false, 0.1f, 0U, 0.5f);
if (ACharacter* Character = Cast<ACharacter>(HitResult.GetActor()))
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactedLocationFleshEffect, HitResult.Location);
}
else
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactedLocationWallEffect, HitResult.Location);
}
}
else
{
...
}
...
}
}
...
11.2-3 Death 구현
- TakeDamage() 함수 활용
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
public:
...
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
protected:
...
private:
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
float MaxHP = 100.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
float CurrentHP = 100.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
float ATK = 10.f;
};
<hide/>
// STPSCharacter.cpp
...
#include "Engine/DamageEvents.h"
...
float ASTPSCharacter::TakeDamage(float Damage, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float ActualDamage = Super::TakeDamage(Damage, DamageEvent, EventInstigator, DamageCauser);
ActualDamage = CurrentHP < ActualDamage ? CurrentHP : ActualDamage;
CurrentHP = FMath::Clamp(CurrentHP - ActualDamage, 0.f, MaxHP);
if (CurrentHP < KINDA_SMALL_NUMBER)
{
GetMesh()->SetSimulatePhysics(true);
}
return ActualDamage;
}
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
...
if (true == bIsCollide)
{
...
if (ACharacter* Character = Cast<ACharacter>(HitResult.GetActor()))
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactedLocationFleshEffect, HitResult.Location);
FDamageEvent DamageEvent = {};
Character->TakeDamage(ATK, DamageEvent, GetController(), this);
}
else
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactedLocationWallEffect, HitResult.Location);
}
}
...
}
}
...
- 캐릭터 Death시 땅으로 들어가는 문제
BP_TPSCharacter > Details > Collision > Mesh > Collision Enabled가 Query Only임.
그래서 물리를 껐을때 아무런 물리작용이 이뤄지지 않음.
Collision Enabled로 지정 후 다시 테스트.
- 캐릭터가 하늘로 날아가버리는 문제
앞으로 어떤 물체가 버벅 거리면서 날아가는 증상이 있다면
거의 대부분 해당 물체와 붙어 있는 메시 혹은 컴포넌트를 의심해야함.
이 경우엔 캐릭터에 붙어 있는 총 스켈레탈 메시가 문제일 수 있음.
NoCollision으로 바꾸거나 케릭터 메시와 충돌되지 않게끔 처리.
정확하게 확인해보려면 메시가 사용중인 PhysicsAsset을 먼저 체크
Content Browser > AnimStarterPack > SK_Mannequin_Physics 더블클릭
Toolbar에 있는 >> 버튼 클릭 Simulate 클릭 시 테스트 가능.
여기서부터 날아간다면 캐릭터에 달린 피직스가 문제.
특정 피직스 바디(보라색의 캡슐)를 클릭하면 인접한 피직스 바디의 정보를 볼 수 있음.
만약 인접한 피직스 바디가 회색이라면, 서로 충돌하지 않는다는 뜻.
근데 똑같이 보라색이라면 충돌이 켜져 있어서 날아가게 됨.(투닥거리는 느낌)
여기서는 날아가지 않는다면 인게임에서 캐릭터에 부착되는 오브젝트가 문제.
만약 딱히 문제는 되지 않는데 문제 상황을 재현해보고 싶다면
BP_TPSCharacter > WeaponSkeletalMeshComponent 클릭 > Details
Collision Presets에 Block All 지정 후 테스트. 결과 확인하면 다시 원상복구.
- 캐릭터의 피직스 애셋 수정 방법
Content Browser > AnimStarterPack > SK_Mannequin_Physics 더블클릭
왼쪽 손의 손가락 하나를 추가해보자.
Skeleton Tree > 톱니바퀴 모양 버튼 클릭 > Show All Bones
hand_l 피직스 바디 클릭 > R 키를 누르면 스케일을 줄일 수 있음. 손가락 덮지 않게 수정.
수정 후 W 키를 눌러서 위치도 수정.
thumb_03_l 스켈레톤 클릭 > Add/Replace Physics Body 클릭
W, E, R키 눌러서 적절히 수정. 테스트 해보았으면 다시 Ctrl + Z로 원상복구.
만약 랙돌이 너무 흐물거린다면 Constraint를 조절해주면 됨.
두 피직스 바디 사이의 회전값 혹은 거리값에 제약을 주는 것.
생성 방법은 특정 피직스 바디 Skeleton Tree에서 우클릭 > Constraints > 인접한 피직스 바디 선택.
해당 Constraints 클릭 > Details > Angular Limits > Swing 1 Motion, Swing 2 Motion, Twist Motion 모두
Limited 체크 후 바로 아래 값을 25.0 이런식으로 설정.
Skeleton Tree > 톱니바퀴 클릭 > Hide All Bones > 모든 피직스 바디 블럭지정 후 Details
Angular Damping에 0.3 설정하면 회전하려는 힘에 반발력을 줄 수 있음.
- 죽은 캐릭터 근처에 가면 알수없는 벽이 있는 문제
죽은 캐릭터의 캡슐 컴포넌트가 살아있어서 생기는 문제.
아래와 같이 코드 작성 후 컴파일.
<hide/>
// STPSCharacter.cpp
...
float ASTPSCharacter::TakeDamage(float Damage, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
...
CurrentHP = FMath::Clamp(CurrentHP - ActualDamage, 0.f, MaxHP);
if (CurrentHP < KINDA_SMALL_NUMBER)
{
GetMesh()->SetSimulatePhysics(true);
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
return ActualDamage;
}
...
- 부분 랙돌을 이용한 피격 모션 대체
<hide/>
// STPSCharacter.h
...
UCLASS()
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
UFUNCTION()
void OnHittedRagdollRestoreTimerElapsed();
public:
...
private:
...
FTimerHandle HittedRagdollRestoreTimer;
FTimerDelegate HittedRagdollRestoreTimerDelegate;
};
<hide/>
// STPSCharacter.cpp
...
float ASTPSCharacter::TakeDamage(float Damage, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
...
if (CurrentHP < KINDA_SMALL_NUMBER)
{
...
}
else
{
FName PivotBoneName = FName(TEXT("spine_01"));
GetMesh()->SetAllBodiesBelowSimulatePhysics(PivotBoneName, true);
float BlendWeight = 1.f; // 피격 애니메이션 포즈가 없음. 랙돌 포즈에 완전 치우쳐지게끔 가중치를 1.f로 지정.
GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(PivotBoneName, BlendWeight);
HittedRagdollRestoreTimerDelegate.BindUObject(this, &ThisClass::OnHittedRagdollRestoreTimerElapsed);
GetWorld()->GetTimerManager().SetTimer(HittedRagdollRestoreTimer, HittedRagdollRestoreTimerDelegate, 1.f, false);
}
...
}
...
void ASTPSCharacter::OnHittedRagdollRestoreTimerElapsed()
{
FName PivotBoneName = FName(TEXT("spine_01"));
GetMesh()->SetAllBodiesBelowSimulatePhysics(PivotBoneName, false);
float BlendWeight = 0.f;
GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(PivotBoneName, BlendWeight);
}
- 위 렉돌 블랜드 예제의 문제점
다시 회복하는데 바로 회복되어 버림. Tick() 함수와 FInterpTo() 함수를 활용해서 서서히 회복되게끔 구현.
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
float TargetRagDollBlendWeight;
float CurrentRagDollBlendWeight;
uint8 bIsNowRagdollBlending : 1;
};
<hide/>
// STPSCharacter.cpp
...
ASTPSCharacter::ASTPSCharacter()
: bIsNowRagdollBlending(false)
{
...
}
void ASTPSCharacter::Tick(float DeltaSeconds)
{
...
if (true == bIsNowRagdollBlending)
{
CurrentRagDollBlendWeight = FMath::FInterpTo(CurrentRagDollBlendWeight, TargetRagDollBlendWeight, DeltaSeconds, 10.f);
FName PivotBoneName = FName(TEXT("spine_01"));
GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(PivotBoneName, CurrentRagDollBlendWeight);
if (CurrentRagDollBlendWeight - TargetRagDollBlendWeight < KINDA_SMALL_NUMBER)
{
GetMesh()->SetAllBodiesBelowSimulatePhysics(PivotBoneName, false);
bIsNowRagdollBlending = false;
}
}
}
...
float ASTPSCharacter::TakeDamage(float Damage, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
...
if (CurrentHP < KINDA_SMALL_NUMBER)
{
...
}
else
{
FName PivotBoneName = FName(TEXT("spine_01"));
GetMesh()->SetAllBodiesBelowSimulatePhysics(PivotBoneName, true);
TargetRagDollBlendWeight = 1.f;
//GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(PivotBoneName, CurrentRagDollBlendWeight);
HittedRagdollRestoreTimerDelegate.BindUObject(this, &ThisClass::OnHittedRagdollRestoreTimerElapsed);
GetWorld()->GetTimerManager().SetTimer(HittedRagdollRestoreTimer, HittedRagdollRestoreTimerDelegate, 1.f, false);
}
...
}
...
void ASTPSCharacter::OnHittedRagdollRestoreTimerElapsed()
{
FName PivotBoneName = FName(TEXT("spine_01"));
TargetRagDollBlendWeight = 0.f;
CurrentRagDollBlendWeight = 1.f;
bIsNowRagdollBlending = true;
}
- 피격 렉돌의 문제점
피격 동안에 캐릭터 Death시 문제가 됨.
피격 동안에 캐릭터의 현재 HP가 0에 가까워지면 피격 랙돌을 끄고 전체적인 랙돌이 되게끔 예외처리.
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Tick(float DeltaSeconds)
{
...
if (true == bIsNowRagdollBlending)
{
...
if (CurrentRagDollBlendWeight - TargetRagDollBlendWeight < KINDA_SMALL_NUMBER)
{
GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(PivotBoneName, 1.f);
GetMesh()->SetAllBodiesBelowSimulatePhysics(PivotBoneName, false);
bIsNowRagdollBlending = false;
}
if (CurrentHP < KINDA_SMALL_NUMBER)
{
GetMesh()->SetAllBodiesBelowPhysicsBlendWeight(FName(TEXT("root")), 1.f); // 모든 본에 렉돌 가중치
GetMesh()->SetSimulatePhysics(true);
bIsNowRagdollBlending = false;
}
}
}
...
- 부위 판정 실습
LineTrace의 HitResult 속성에는 충돌한 Bone의 이름을 가져올 수도 있음.
아래 코드를 작성 후 컴파일 하면 문제가 생기는데, 왜 생기는지 고민해보자.
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Attack(const FInputActionValue& InValue)
{
if (nullptr != ProjectileActorClass && EEquipState::Fist != EquipState)
{
...
if (true == bIsCollide)
{
...
// Persistence를 true로 교체.
DrawDebugLine(GetWorld(), SpawnLocation, HitResult.Location, FColor(255, 255, 255, 64), true, 0.1f, 0U, 0.5f);
if (ACharacter* Character = Cast<ACharacter>(HitResult.GetActor()))
{
...
FString BoneNameString = HitResult.BoneName.ToString();
UKismetSystemLibrary::PrintString(this, BoneNameString);
DrawDebugSphere(GetWorld(), HitResult.Location, 3.f, 16, FColor(255, 0, 0, 255), true, 20.f, 0U, 5.f);
}
...
}
else
{
...
}
...
}
}
...

- 맞은 본 이름 전달 문제
만약 위 문제를 어떻게 잘 해결 했다해도 문제가 생김.
HP를 처리하는 코드는 맞은 캐릭터에서 처리함.
따라서 맞은 캐릭터에게 본 이름을 전달해주어야 함.
힌트: ApplyPointDamage() 함수와 PointDamage() 함수
- 등에 달린 총에 쏴도 피가 튀는 문제
힌트: 컴포넌트 태그
11.3 Unreal Modular Characters
11.3-1 실습 준비
- 의류 장착 구현 개념
만약 옷을 필드에서 주웠다고 가정해보자.
해당 의류를 장착하려면 의류 메시가 필요함.
또 의류가 본을 따라가게 하려면 스키닝 데이터가 필요함.
즉, 의류에 대한 리깅이 필요함. 지정된 캐릭터 메시에 옷을 입히고
리깅을 통해 스키닝 데이터를 만들어서 본을 따라가게끔 만들고
마지막으로 잘라야함. 캐릭터 메시 통짜로 .fbx에 들어가 있기 때문.
그래서 옷만 따로 쓰려면 적절하게 잘라줘야함.
자르기 위해서는 파츠 종류를 나눠야함.
예제에서는 머리, 상체, 하체, 발, 모자로 나누고자 함.
상체는 몸통과 팔, 손이 포함되는 것.
파츠를 정했다면 각 파츠 사이의 경계를 정해줘야함. 이를 심라인이라고 함.
- 블랜더를 통한 심라인 지정
원래는 메시를 블랜더로 로드하고 특정 정점을 클릭 + L 키를 누르면 전체가 주황색됨.
좌하단 Mesh > Edge > Edge Split 기능으로 심라인을 기준하여 자름.
- 미리 작업된 애셋
아래 파일 다운로드 후 Content 폴더에 추가. [업로드 실패]
11.3-2 필드 아이템
- 필드 아이템 전용 콜리전 생성
Project Settings > Collision > New Object Channel > "FieldItem"
Default Response에 Ignore 지정. 진로 방해를 하면 안되면서도 부하를 줄이기 위함.
Preset 탭으로 가서 다른 콜리전 프리셋과의 반응을 지정해줌. 어렵다면 이전 Collision 단원 참고.
FieldItem 프리셋은 Attack 채널만 Block 설정. 나머지 채널에 대해서는 Ignore.

- 필드 아이템 클래스
새 C++ 클래스 > Actor 부모 클래스 > "SFieldItem"
아래와 같이 작성 후 컴파일.
새 블루프린트 애셋 > FieldItem 부모 클래스 > "BP_FieldItem"
<hide/>
// SFieldItem.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SFieldItem.generated.h"
UCLASS()
class STUDYPROJECT_API ASFieldItem : public AActor
{
GENERATED_BODY()
public:
ASFieldItem();
USkeletalMeshComponent* GetMesh() { return SkeletalMeshComponent; }
private:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = AFieldItem, meta = (AllowPrivateAccess = true))
TObjectPtr<class UBoxComponent> BoxComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = AFieldItem, meta = (AllowPrivateAccess = true))
TObjectPtr<class USkeletalMeshComponent> SkeletalMeshComponent;
};
<hide/>
// SFieldItem.cpp
#include "SFieldItem.h"
#include "Components/BoxComponent.h"
ASFieldItem::ASFieldItem()
{
PrimaryActorTick.bCanEverTick = false;
BoxComponent = CreateDefaultSubobject<UBoxComponent>(TEXT("BoxComponent"));
SetRootComponent(BoxComponent);
BoxComponent->SetCollisionProfileName(FName(TEXT("FieldItem")));
SkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("SkeletalMeshComponent"));
SkeletalMeshComponent->SetupAttachment(GetRootComponent());
SkeletalMeshComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
- 상호작용 키 준비
Content Browser > InputAction > 새 Input 애셋 > Input Action > "IA_Interaction"
SPawnInputConfig 클래스 내용을 아래와 같이 수정
IC_Pawn과 IMC_Pawn 내용을 수정. F키에 상호작용을 설정.
<hide/>
// SPawnInputConfig.h
...
class STUDYPROJECT_API USPawnInputConfig : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> InteractionAction;
};
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
private:
...
void EndIronSight(const FInputActionValue& InValue);
void Interaction(const FInputActionValue& InValue);
...
public:
...
};
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
...
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
...
EnhancedInputComponent->BindAction(PawnInputConfig->InteractionAction, ETriggerEvent::Started, this, &ThisClass::Interaction);
}
}
...
void ASTPSCharacter::Interaction(const FInputActionValue& InValue)
{
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Interaction()")));
}
...
11.3-2 아이템 상호작용
- 아이템 상호작용 구현
<hide/>
// STPSCharacter.cpp
...
void ASTPSCharacter::Interaction(const FInputActionValue& InValue)
{
FHitResult HitResult;
FVector Start;
Start = CameraComponent->GetComponentLocation();
FVector End = Start + CameraComponent->GetForwardVector() * 5000.f;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.AddIgnoredComponent((const UPrimitiveComponent*)(CameraComponent));
QueryParams.bTraceComplex = true;
bool bIsCollide = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_GameTraceChannel2, QueryParams);
if (true == bIsCollide)
{
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Interaction(%s)"), *HitResult.GetComponent()->GetName()));
}
}
...
- 필드 아이템 관리
아이템을 관리 할 때는 ID라는 정수를 가지고 관리함.
SGameInstance 클래스 > FTableRowBase 구조체 상속 > "FSFieldItemDataTableRow"
컴파일 후 Content Browser > DataAssets > 새 DataTable 애셋 > FSFieldItemDataTableRow
"DT_FieldItem" 생성 후 아래와 같이 설정.
<hide/>
// SGameInstance.h
...
UENUM(BlueprintType)
enum class EItemEquipType : uint8
{
Pistol,
SniperRifle,
Rifle,
End,
};
USTRUCT(BlueprintType)
struct FSFieldItemDataTableRow : public FTableRowBase
{
GENERATED_BODY()
public:
FSFieldItemDataTableRow()
{
}
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FSFieldItemDataTableRow)
int32 ItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FSFieldItemDataTableRow)
FString ItemName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FSFieldItemDataTableRow)
EItemEquipType ItemEquipType;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = FSFieldItemDataTableRow)
TSoftObjectPtr<class USkeletalMesh> ItemSkeletalMesh;
};
UCLASS()
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
...
public:
...
const UDataTable* GetFieldItemDataTable() { return FieldItemDataTable; }
FSFieldItemDataTableRow* USGameInstance::GetFieldItemDataTableRowWithID(int32 InID);
protected:
...
private:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="USGameInstance", Meta=(AllowPrivateAccess=true))
class UDataTable* CharacterStatDataTable;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = USGameInstance, Meta = (AllowPrivateAccess = true))
class UDataTable* FieldItemDataTable;
...
};
<hide/>
// SGameInstance.cpp
...
USGameInstance::USGameInstance()
...
{
...
static ConstructorHelpers::FObjectFinder<UDataTable> DTFieldItem(TEXT("/Script/Engine.DataTable'/Game/DataAssets/DT_FieldItem.DT_FieldItem'"));
if (true == DTFieldItem.Succeeded())
{
FieldItemDataTable = DTFieldItem.Object;
}
}
...
FSFieldItemDataTableRow* USGameInstance::GetFieldItemDataTableRowWithID(int32 InID)
{
if (nullptr != FieldItemDataTable)
{
return FieldItemDataTable->FindRow<FSFieldItemDataTableRow>(*FString::FromInt(InID), TEXT(""));
}
return nullptr;
}
...

- ID값에 따른 비동기 로딩
SFieldItem 클래스 내용을 아래와 같이 수정.
필드 상에 배치해둔 BP_FieldItem의 ItemID를 수정해보자.
<hide/>
// FieldItem.h
...
class STUDYPROJECT_API AFieldItem : public AActor
{
...
public:
...
virtual void BeginPlay() override;
private:
UFUNCTION()
void OnAssetLoadCompleted();
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = AFieldItem, meta = (AllowPrivateAccess = true))
int32 ItemID;
struct FSFieldItemDataTableRow* CurrentFieldItemDataTableRow;
TSharedPtr<struct FStreamableHandle> StreamableHandle;
};
<hide/>
// FieldItem.cpp
...
#include "SGameInstance.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/AssetManager.h"
...
void AFieldItem::BeginPlay()
{
Super::BeginPlay();
USGameInstance* SGI = Cast<USGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
if (true == ::IsValid(SGI))
{
CurrentFieldItemDataTableRow = SGI->GetFieldItemDataTableRowWithID(ItemID);
if (nullptr != CurrentFieldItemDataTableRow)
{
StreamableHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad(
(CurrentFieldItemDataTableRow->ItemSkeletalMesh).ToSoftObjectPath(),
FStreamableDelegate::CreateUObject(this, &ThisClass::OnAssetLoadCompleted)
);
}
}
}
void AFieldItem::OnAssetLoadCompleted()
{
StreamableHandle->ReleaseHandle();
TSoftObjectPtr<USkeletalMesh> LoadedAssetPath(CurrentFieldItemDataTableRow->ItemSkeletalMesh);
if (true == LoadedAssetPath.IsValid())
{
SkeletalMeshComponent->SetSkeletalMesh(LoadedAssetPath.Get());
}
}
- 아이템 장착 기획
처음에는 아무런 무기 없이 맨손 시작.
아이템은 맨손 상태에서만 주울 수 있음.
아이템을 주우면 손에 장착됨.
퀵슬롯(1, 2, 3)을 누르면 해당 소켓(Rifle, Pistol, Melee)으로 보관 후 맨손 상태.
즉, 몸에 부착된 아이템은 인벤토리에 있는 것이고 손에 있는게 장비창.
아이템 장착을 구현하기 전에 케릭터 몸에 부착된 아이템을 먼저 제거
- 아이템 장착 준비
UE4_Mannequin_Skeleton > Skeletal Tree > 아래 그림을 참고하여 소켓 생성
Content Browser > InputActions > 새 Input 애셋 > InputAction > "IA_QuickSlot3"
SPawnInputConfig 클래스 내용을 아래와 같이 수정. IC_SPawn과 IMC_SPawm 수정.
3번키를 단축키로 사용.




<hide/>
// SPawnInputConfig.h
...
class STUDYPROJECT_API USPawnInputConfig : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> QuickSlot3Action;
};
- 무기 장착 구조 개선
개선 후에는 ABP_TPSCharacter 애셋에서도 컴파일 에러남. Blend Poses(EEquipState) 노드에서 발생.
이 경우에는 해당 노드를 지우고 다시 생성해주면 됨.
BP_TPSCharacter > WeaponSkeletalMeshComponent에 부착되어 있던 Rifle은 삭제. 빈손으로 만들고,
Pistol, Rifle, SniperRifle SkeletalMeshComponent에 각각 알맞은 무기의 메시를 부착 후 플레이.
잘 동작하는 것을 확인하면 모든 SkeletalMeshComponent에 부착된 메시를 제거.
<hide/>
// STPSCharacter.h
...
UENUM(BlueprintType)
enum class EEquipState : uint8
{
Fist,
Pistol,
SniperRifle,
Rifle,
End
};
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
protected:
...
private:
...
void QuickSlot1(const FInputActionValue& InValue);
void QuickSlot2(const FInputActionValue& InValue);
void QuickSlot3(const FInputActionValue& InValue);
...
public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
EEquipState PrevEquipState = EEquipState::Fist;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
EEquipState CurrEquipState = EEquipState::Fist;
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* WeaponSkeletalMeshComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* PistolHolderSkeletalMeshComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* SniperRifleHolderSkeletalMeshComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
USkeletalMeshComponent* RifleHolderSkeletalMeshComponent;
...
};
<hide/>
// STPSCharacter.cpp
...
ASTPSCharacter::ASTPSCharacter()
: bIsNowRagdollBlending(false)
{
...
FName WeaponSocket(TEXT("WeaponSocket"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
WeaponSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponSkeletalMeshComponent"));
WeaponSkeletalMeshComponent->SetupAttachment(GetMesh(), WeaponSocket);
WeaponSkeletalMeshComponent->SetSkeletalMesh(nullptr);
}
FName PistolHolderSocket(TEXT("PistolHolder"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
PistolHolderSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("PistolHolderSkeletalMeshComponent"));
PistolHolderSkeletalMeshComponent->SetupAttachment(GetMesh(), PistolHolderSocket);
PistolHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
}
FName RifleHolderSocket(TEXT("RifleHolder"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
RifleHolderSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("RifleHolderSkeletalMeshComponent"));
RifleHolderSkeletalMeshComponent->SetupAttachment(GetMesh(), RifleHolderSocket);
RifleHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
}
FName SniperRifleHolderSocket(TEXT("SniperRifleHolder"));
//if (true == GetMesh()->DoesSocketExist(WeaponSocket))
{
SniperRifleHolderSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("SniperRifleHolderSkeletalMeshComponent"));
SniperRifleHolderSkeletalMeshComponent->SetupAttachment(GetMesh(), SniperRifleHolderSocket);
SniperRifleHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
}
}
...
...
void ASTPSCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
...
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
...
EnhancedInputComponent->BindAction(PawnInputConfig->QuickSlot1Action, ETriggerEvent::Started, this, &ThisClass::QuickSlot1);
EnhancedInputComponent->BindAction(PawnInputConfig->QuickSlot2Action, ETriggerEvent::Started, this, &ThisClass::QuickSlot2);
EnhancedInputComponent->BindAction(PawnInputConfig->QuickSlot3Action, ETriggerEvent::Started, this, &ThisClass::QuickSlot3);
...
}
}
...
void ASTPSCharacter::QuickSlot1(const FInputActionValue& InValue)
{
PrevEquipState = CurrEquipState;
if (EEquipState::Fist == CurrEquipState)
{
if (nullptr != PistolHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipItemType::Pistol);
CurrEquipState = EEquipItemType::Pistol;
}
}
else
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipState::Fist);
CurrEquipState = EEquipState::Fist;
}
}
void ASTPSCharacter::QuickSlot2(const FInputActionValue& InValue)
{
PrevEquipState = CurrEquipState;
if (EEquipState::Fist == CurrEquipState)
{
if (nullptr != SniperRifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipItemType::SniperRifle);
CurrEquipState = EEquipItemType::SniperRifle;
}
}
else
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipState::Fist);
CurrEquipState = EEquipState::Fist;
}
}
void ASTPSCharacter::QuickSlot3(const FInputActionValue& InValue)
PrevEquipState = CurrEquipState;
if (EEquipState::Fist == CurrEquipState)
{
if (nullptr != RifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipItemType::Rifle);
CurrEquipState = EEquipItemType::Rifle;
}
}
else
{
OnEquipStateChanged.Broadcast(CurrEquipState, EEquipState::Fist);
CurrEquipState = EEquipState::Fist;
}
}
...
void ASTPSCharacter::OnRifleHolded()
{
switch (CurrEquipState)
{
case EEquipState::Fist:
{
switch (PrevEquipState)
{
case EEquipState::Fist:
break;
case EEquipState::Pistol:
PistolHolderSkeletalMeshComponent->SetSkeletalMesh(WeaponSkeletalMeshComponent->GetSkeletalMeshAsset());
break;
case EEquipState::SniperRifle:
SniperRifleHolderSkeletalMeshComponent->SetSkeletalMesh(WeaponSkeletalMeshComponent->GetSkeletalMeshAsset());
break;
case EEquipState::Rifle:
RifleHolderSkeletalMeshComponent->SetSkeletalMesh(WeaponSkeletalMeshComponent->GetSkeletalMeshAsset());
break;
case EEquipState::End:
break;
default:
break;
}
WeaponSkeletalMeshComponent->SetSkeletalMesh(nullptr);
break;
}
case EEquipState::Pistol:
WeaponSkeletalMeshComponent->SetSkeletalMesh(PistolHolderSkeletalMeshComponent->GetSkeletalMeshAsset());
PistolHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
break;
case EEquipState::SniperRifle:
WeaponSkeletalMeshComponent->SetSkeletalMesh(SniperRifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset());
SniperRifleHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
break;
case EEquipState::Rifle:
WeaponSkeletalMeshComponent->SetSkeletalMesh(RifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset());
RifleHolderSkeletalMeshComponent->SetSkeletalMesh(nullptr);
break;
case EEquipState::End:
break;
default:
break;
}
}
...
- 무기 습득
무기에 상호작용 키를 누르면 손에 해당 무기가 부착되게끔 하고자 함.
손에 무기가 있는지 판단하고, 없다면 해당 무기가 부착되고, 바닥에 있던 무기는 삭제.
손에 무기가 이미 있다면 습득되지 않게끔 구현.
<hide/>
// STPSCharacter.cpp
...
#include "SFieldItem.h"
...
void ASTPSCharacter::Interaction(const FInputActionValue& InValue)
{
...
bool bIsCollide = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_GameTraceChannel2, QueryParams);
if (true == bIsCollide)
{
ASFieldItem* Item = Cast<ASFieldItem>(HitResult.GetActor());
if (nullptr != Item && EEquipItemType::Fist == CurrEquipState)
{
switch (Item->GetFieldItemDataTableRow()->ItemEquipType)
{
case EEquipItemType::Pistol:
{
if (nullptr != PistolHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
return;
}
break;
}
case EEquipItemType::SniperRifle:
{
if (nullptr != SniperRifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
return;
}
break;
}
case EEquipItemType::Rifle:
{
if (nullptr != RifleHolderSkeletalMeshComponent->GetSkeletalMeshAsset())
{
return;
}
break;
}
default:
break;
}
WeaponSkeletalMeshComponent->SetSkeletalMesh(Item->GetMesh()->GetSkeletalMeshAsset());
CurrEquipState = Item->GetFieldItemDataTableRow()->ItemEquipType;
Item->Destroy();
}
}
}
...
- 상호작용 UI 제작
무기에 가까이 가서 크로스 헤어를 대면 해당 무기의 이름이 나오게끔 하고자 함.
새 C++ 클래스 > UserWidget 부모 클래스 > "SInteractionPrompt"
Content Browser > UI > 새 User Interface 애셋 > Widget Blueprint > SInteractionPrompt 부모 클래스
"WBP_Interaction_Prompt" 생성.
아래 그림을 참고하여 만들되, 똑같이 할 필요는 없음. UI는 따로 천천히 배워나가면 되는 부분.
STPSCharacter 클래스도 아래와 같이 수정 후 컴파일. BP_TPSCharacter > WidgetComponent
Widget Class에 WBP_Interaction_Prompt 지정.
<hide/>
// SInteractionPrompt.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SInteractionPrompt.generated.h"
UCLASS()
class STUDYPROJECT_API USInteractionPrompt : public UUserWidget
{
GENERATED_BODY()
public:
void SetItemNameText(const FString& InItemName);
private:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> WeaponNameText;
};
<hide/>
// SInteractionPrompt.cpp
#include "SInteractionPrompt.h"
#include "Components/TextBlock.h"
void USInteractionPrompt::SetItemNameText(const FString& InItemName)
{
WeaponNameText->SetText(FText::FromString(InItemName));
}
<hide/>
// STPSCharacter.h
...
class STUDYPROJECT_API ASTPSCharacter : public ACharacter
{
...
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess = true))
TObjectPtr<class UWidgetComponent> WidgetComponent;
};
<hide/>
// STPSCharacter.cpp
...
#include "Components/WidgetComponent.h"
#include "SInteractionPrompt.h"
ASTPSCharacter::ASTPSCharacter()
...
{
...
WidgetComponent = CreateDefaultSubobject<UWidgetComponent>(TEXT("WidgetComponent"));
WidgetComponent->SetVisibility(false, true);
}
void ASTPSCharacter::Tick(float DeltaSeconds)
{
...
FHitResult HitResult;
FVector Start;
Start = CameraComponent->GetComponentLocation();
FVector End = Start + CameraComponent->GetForwardVector() * 5000.f;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.AddIgnoredComponent((const UPrimitiveComponent*)(CameraComponent));
QueryParams.bTraceComplex = true;
bool bIsCollide = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_GameTraceChannel2, QueryParams);
bool bIsCollide = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_GameTraceChannel2, QueryParams);
do {
if (nullptr == WidgetComponent->GetWidget())
{
break;
}
if (true == WidgetComponent->GetWidget()->IsInViewport())
{
WidgetComponent->GetWidget()->RemoveFromViewport();
WidgetComponent->SetVisibility(false, true);
}
if (false == bIsCollide)
{
break;
}
ASFieldItem* Item = Cast<ASFieldItem>(HitResult.GetActor());
if (nullptr == Item)
{
break;
}
USInteractionPrompt* Prompt = Cast<USInteractionPrompt>(WidgetComponent->GetWidget());
if (nullptr == Prompt)
{
break;
}
if (false == Prompt->IsInViewport())
{
Prompt->AddToViewport();
}
Prompt->SetItemNameText(Item->GetFieldItemDataTableRow()->ItemName);
FVector2D ScreenPosition;
GetController<APlayerController>()->ProjectWorldLocationToScreen(Item->GetActorLocation(), ScreenPosition);
Prompt->SetPositionInViewport(ScreenPosition);
WidgetComponent->SetVisibility(true, true);
} while (false); // 이 뒤 로직도 수행 해야하기 때문에 return을 통한 early exit이 아니라 do-while 구문을 사용함.
}
...


11.2-2 점사
11.2-3 연발
11.2-4 줌
11.3 퀘스트
11.3-1 AI NPC
11.3-2 수거 퀘스트
11.3-3 제거 퀘스트
11.4 지형지물
11.4-1 폭발물
11.4-2 차량