언리얼 엔진 네트워크 FPS 게임 개발 일지#4 탄퍼짐, 반동 구간 반복, 반동 안정성

김진우·2022년 6월 14일
0
post-thumbnail

탄퍼짐 구현

탄퍼짐은 움직임에 따른 탄퍼짐과 반동 중 무작위 방향으로 퍼지는 탄착군으로 나눌 수 있다. 이것을 구현하기 위해 무기 클래스를 만들 때 MovementStability 변수와 Accuracy 변수를 만들어 두었다.
아래 코드는 총을 발사할 때 카메라 방향으로 LineTrace를 실행하는 함수이다.

bool AHitScanWeapon::LineTrace(FHitResult& HitResult)
{
	AFpsCharacter* Character = Cast<AFpsCharacter>(GetOwner());
	if (!IsValid(Character))
	{
		UE_LOG(LogTemp, Log, TEXT("AHitScanWeapon::LineTrace / Owner Character is invalid"));
		return false;
	}
	APlayerController* PlayerController = Cast<APlayerController>(Character->GetController());
	if (!IsValid(PlayerController))
	{
		UE_LOG(LogTemp, Log, TEXT("AHitScanWeapon::LineTrace / PlayerController is invalid"));
		return false;
	}

	// Get Player view point
	FVector PlayerViewPointLocation;
	FRotator PlayerViewPointRotation;
	PlayerController->GetPlayerViewPoint(
		PlayerViewPointLocation,
		PlayerViewPointRotation
	);

	//Recoil
	float RecoilOffset = GetRecoilOffset(); // Accuracy + MovementStability;
	PlayerViewPointRotation.Pitch += BulletRecoil.Y;
	PlayerViewPointRotation.Pitch = FMath::RandRange(PlayerViewPointRotation.Pitch, PlayerViewPointRotation.Pitch + RecoilOffset);
	PlayerViewPointRotation.Yaw += BulletRecoil.Z;
	PlayerViewPointRotation.Yaw = FMath::RandRange(PlayerViewPointRotation.Yaw - RecoilOffset, PlayerViewPointRotation.Yaw + RecoilOffset);
	PlayerViewPointRotation.Roll += BulletRecoil.X;

	// Get end point
	FVector LineTraceEnd = PlayerViewPointLocation + PlayerViewPointRotation.Vector() * Reach;

	bool IsHit = GetWorld()->LineTraceSingleByChannel(
		HitResult,
		PlayerViewPointLocation,
		LineTraceEnd,
		ECollisionChannel::ECC_Pawn,
		LineTraceCollisionQueryParams
	);

	if (!IsHit)
	{
		HitResult.ImpactPoint = LineTraceEnd;
	}

	return IsHit;
}

핵심은 PlayerController->GetPlayerViewPoint(...) 함수로 가져온 카메라 RotatorPitchYaw 값을 AccuracyMovementStability에 따라서 랜덤으로 변경시키는 것이다. 변화값은 GetRecoilOffset 함수로 구현했다.

float AHitScanWeapon::GetRecoilOffset()
{
	AFpsCharacter* FpsCharacter = Cast<AFpsCharacter>(GetOwner());
	if (!IsValid(FpsCharacter)) return 0.f;

	float CharacterSpeed = FpsCharacter->GetVelocity().Size();
	UCharacterMovementComponent* MovementComponent = FpsCharacter->GetCharacterMovement();
	return Accuracy * (CurrentRecoilRecoveryTime / RecoilRecoveryTime) + MovementStability * (CharacterSpeed / MovementComponent->MaxWalkSpeed);
}

정확도는 반동 유지 시간에 따라 정확도가 떨어진다. 따라서 총기 정확도에 따른 변화값은 Accuracy * (CurrentRecoilRecoveryTime / RecoilRecoveryTime)이다. 움직였을 때에도 마찬가지로 정확도에 영향을 주기 때문에 MovementStability * (CharacterSpeed / MovementComponent->MaxWalkSpeed)을 추가했다.

반동 구간반복

반동 분석

우선 발로란트의 반동을 분석하면 다음과 같다.

  1. 예열 단계. 오른쪽 위로 올라가는 반동이다. 명중률이 100%에서 서서히 낮아지는 구간이다.
  1. 우상탄 단계. 오른쪽 위로 총알이 모인다. 이 상태로 0.2 ~ 0.6초 정도 유지된다.

  2. 좌측 반향전환 단계. 반동이 왼쪽으로 움직인다.

  3. 좌상탄 단계. 왼쪽 위로 총알이 모인다. 2번과 마찬가지로 0.2 ~ 0.6초 정도 유지된다.

  1. 우측 방향전환 단계. 반동이 오른쪽으로 움직인다.

  2. 2번부터 반복한다.

구현 아이디어

반복 재생을 위해서 2번 구간에 해당하는 시간(초)를 저장하는 RecoilLoopStartingTime 변수를 두고, FTimeline::SetTimelineFinishedFunc 에 등록된 콜백함수에 FTimeline::SetPlaybackPosition를 호출한 다음 타임라인을 재생해서 예열 단계를 제외한 나머지 부분을 반복하도록 만들었다. 하지만 발로란트와 같은 반동을 구현하기 위해서는
2번과 4번에 해당하는 반동을 랜덤한 시간으로 유지하는 것이 핵심이다. 현재는 CurveTimeline 기능을 활용해서 고정된 시간동안 반동을 재생하도록 구현된 상태이다.

아이디어 1. 구간마다 다른 Curve 적용 후 DeltaTime에 스케일 값 적용

반동 Curve를 구간별로 나눠서 무기 블루프린트와 연결한다. 각 구간이 끝날 때마다 FTimeline::SetTimelineFinishedFunc로 등록된 콜백함수가 호출되기 때문에 시간을 지정할 필요 없이 자연스럽게 여러 구간을 관리할 수 있는 장점이 있다.
반복마다 또는 특정 구간마다 FTimeline::TickTimeline 함수에 인자값으로 전달되는 DeltaTime 값에 0.5f ~ 2.f 정도의 랜덤 스케일 값을 곱해주며 Timeline의 재생 속도를 빠르거나 느리게 만든다.

아이디어 2. 최대 반동 벡터값에 따라 자동으로 반동 구현

발로란트의 총기 반동을 자세히 보면 한 발을 발사할 때마다 에임이 위쪽으로 튄 후 다음 총알이 발사되기 전까지 내려가는 것을 확인할 수 있다. 이 구간만 Curve로 파일을 만든 다음, 최대 반동에 도달했을 때 Yaw 값만 방향을 바꿔주는 방식으로 반동을 구현하는 방법이 있다.

아이디어 3. 기본 반동 경로 Curve + 아이디어 2번 혼합 방식

현재 방식에서 RecoilLoopStartingTime 변수를 제거하고, TimeLine이 종료되면 절대값이 가장 큰 반동 벡터를 기준으로 (Yaw, Pitch) 부터 (-Yaw, Pitch)까지 왕복하는 반동을 코드로 구현하는 방법이다.
코드를 크게 변경해야 하지만 변수를 따로 관리할 필요도 없고, Curve 파일도 기본 반동 경로 파일과 총알 반동 파일 두 개만 관리하면 되기 때문에 좋을 것이다.

구현

아이디어 1번은 무기 하나에 많으면 4개의 Curve 파일을 만들어야 한다는 단점이 있다. 관리가 매우 불편하고 작성할 문서의 양도 늘어날 것이다. 아이디어 2번은 반동 패턴이 다른 무기를 만들 경우에 확장성이 떨어지는 문제가 있다. 따라서 혼합 방식으로 구현했으며 Curve 파일 수는 줄이고 확장성이 떨어지는 단점도 해결했다. 또 카메라가 Curve값에 따라서 위와 좌우로 들어올려지는 기능(반동 제어) 뿐 아니라 총을 한 발 발사할 때마다 카메라가 잠깐씩 위로 튀는 기능(반동 안정성)도 구현했다.

WeaponBase.h

class FPS_API AWeaponBase : public ... {
.
.
.
    /**************************
			  Recoil
	***************************/
	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Timeline", Meta = (AllowPrivateAccess = "true"))
	UCurveVector* CameraRecoilControlCurve;

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Timeline", Meta = (AllowPrivateAccess = "true"))
	UCurveVector* BulletRecoilCurve;

	FTimeline RecoilControlTimeline;

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Timeline", Meta = (AllowPrivateAccess = "true"))
	UCurveVector* CameraRecoilStabilityCurve;

	FTimeline RecoilStabilityTimeline;

	bool IsOnAutomaticRecoil;

	FVector MaximumCameraRecoilControl;
	FVector CurrentCameraRecoilControl;

	FVector MaximumBulletRecoil;
	FVector CurrentBulletRecoil;

	float MaximumHoldTime;
	float CurrentHoldTime;
    
    //Timeline Callback
	void InitializeRecoilTimeline();
    
	UFUNCTION()
	virtual void OnCameraRecoilControlProgress(FVector CameraRecoil);
	
	UFUNCTION()
	virtual void OnBulletRecoilProgress(FVector BulletRecoil);

	UFUNCTION()
	virtual void OnRecoilControlTimelineFinish();

	UFUNCTION()
	virtual void OnCameraRecoilStabilityProgress(FVector CameraRecoil);
.
.
.
}
WeaponBase.cpp

void AWeaponBase::InitializeRecoilTimeline()
{
	if (CameraRecoilControlCurve == nullptr || BulletRecoilCurve == nullptr || CameraRecoilStabilityCurve == nullptr)
	{
		return;
	}

	FOnTimelineVector CameraRecoilControlCallback;
	FOnTimelineVector BulletRecoilCallback;

	FOnTimelineEventStatic TimelineFinishCallback;

	CameraRecoilControlCallback.BindUFunction(this, FName("OnCameraRecoilControlProgress"));
	BulletRecoilCallback.BindUFunction(this, FName("OnBulletRecoilProgress"));
	TimelineFinishCallback.BindUFunction(this, FName("OnRecoilControlTimelineFinish"));

	RecoilControlTimeline.AddInterpVector(CameraRecoilControlCurve, CameraRecoilControlCallback);
	RecoilControlTimeline.AddInterpVector(BulletRecoilCurve, BulletRecoilCallback);
	RecoilControlTimeline.SetTimelineFinishedFunc(TimelineFinishCallback);

	FOnTimelineVector CameraRecoilStabilityCallback;
	CameraRecoilStabilityCallback.BindUFunction(this, FName("OnCameraRecoilStabilityProgress"));
	RecoilStabilityTimeline.AddInterpVector(CameraRecoilStabilityCurve, CameraRecoilStabilityCallback);
}

// Called when the game starts or when spawned
void AWeaponBase::BeginPlay()
{
	Super::BeginPlay();

	InitializeRecoilTimeline();
}

// Called every frame
void AWeaponBase::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (IsOnAutomaticRecoil)
	{
		AutomaticRecoilControlTick(DeltaTime);
	}
	else
	{
		RecoilControlTick(DeltaTime);
	}
	RecoilStabilityTick(DeltaTime);
}

void AWeaponBase::RecoilControlTick(float DeltaTime)
{
	RecoilControlTimeline.TickTimeline(DeltaTime);
	if (RecoilControlTimeline.IsPlaying())
	{
		CurrentRecoilRecoveryTime += DeltaTime;
		if (CurrentRecoilRecoveryTime > RecoilRecoveryTime) CurrentRecoilRecoveryTime = RecoilRecoveryTime;
	}
	else if (0.f <= CurrentRecoilRecoveryTime)
	{
		CurrentRecoilRecoveryTime -= DeltaTime;
		float ReducedTimelinePlayback = RecoilControlTimeline.GetPlaybackPosition() * (CurrentRecoilRecoveryTime / RecoilRecoveryTime);
		RecoilControlTimeline.SetPlaybackPosition(ReducedTimelinePlayback, false);
	}
}

void AWeaponBase::RecoilStabilityTick(float DeltaTime)
{
	RecoilStabilityTimeline.TickTimeline(DeltaTime / ActionDelay);
}

#define SPRAY_SPEED 10.f
void AWeaponBase::AutomaticRecoilControlTick(float DeltaTime)
{
	// If is the side to aim and the current aim is over that.
	if ((0.f < MaximumCameraRecoilControl.Z && MaximumCameraRecoilControl.Z <= CurrentCameraRecoilControl.Z)
		|| (MaximumCameraRecoilControl.Z < 0.f && CurrentCameraRecoilControl.Z <= MaximumCameraRecoilControl.Z))
	{
		UE_LOG(LogTemp, Log, TEXT("AWeaponBase::AutomaticRecoilControlTick over"));
		if (MaximumHoldTime == 0.f)
		{
			MaximumHoldTime = FMath::RandRange(0.3f, 0.7f);
			UE_LOG(LogTemp, Log, TEXT("MaximumHoldTime : %f "), MaximumHoldTime);
		}
		
		CurrentHoldTime += DeltaTime;

		if (MaximumHoldTime < CurrentHoldTime)
		{
			MaximumHoldTime = 0.f;
			CurrentHoldTime = 0.f;
			MaximumCameraRecoilControl.Z *= -1.f;
			MaximumBulletRecoil.Z *= -1.f;
		}
	}
	else
	{
		UE_LOG(LogTemp, Log, TEXT("AWeaponBase::AutomaticRecoilControlTick turn"));
		float SprayDelta = SPRAY_SPEED * DeltaTime;
		float SprayValue = MaximumCameraRecoilControl.Z - CurrentCameraRecoilControl.Z;
		if (SprayValue * SprayValue < 1.f) 
			SprayValue = SprayValue < 0 ? -1.f : 1.f;
		CurrentCameraRecoilControl.Z += SprayValue * SprayDelta;


		SprayValue = MaximumBulletRecoil.Z - CurrentBulletRecoil.Z;
		if (SprayValue * SprayValue < 1.f)
			SprayValue = SprayValue < 0 ? -1.f : 1.f;
		CurrentBulletRecoil.Z += SprayValue * SprayDelta;
	}

	for (IWeaponEvent* Observer : EventObservers)
	{
		Observer->OnCameraRecoilControlProgress(CurrentCameraRecoilControl);
		Observer->OnBulletRecoilProgress(CurrentBulletRecoil);
	}
}
#undef SPRAY_SPEED


void AWeaponBase::OnAction()
{
	UE_LOG(LogTemp, Log, TEXT("AWeaponBase::OnAction()"));
	if (CurrentAmmo <= 0) return;
	CurrentAmmo -= !IsAmmoInfinite;

	MulticastRPCOnActionFx();

	RecoilStabilityTimeline.SetPlaybackPosition(0.f, false);
	if (!RecoilStabilityTimeline.IsPlaying()) RecoilStabilityTimeline.Play();

	for (IWeaponEvent* Observer : EventObservers)
	{
		Observer->OnActionEvent(this);
	}
}
RecoilComponent.h

class AFpsCharacter;
class AHands;

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class FPS_API URecoilComponent : public USceneComponent, public IFpsCharacterEvent, public IWeaponEvent
{
	GENERATED_BODY()

	FString RecoilDataContext;
	float CurrentRecoveryTime;
	bool bIsActionPressed;

	FVector RecoilControl;
	FVector RecoilStability;

public:	
	// Sets default values for this component's properties
	URecoilComponent();

	void Initialize(AFpsCharacter* FpsCharacter);

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

public:	
	// Called every frame
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

	// IFpsCharacterEvent
	virtual void OnEquipHands(AHands* Hands) override;
	virtual void OnUnequipHands(AHands* Hands) override;

	// IWeaponEvent
	virtual void OnCameraRecoilControlProgress(FVector CameraRecoil) override;
	virtual void OnCameraRecoilStabilityProgress(FVector CameraRecoil) override;
	virtual void OnRecoilStop(float RecoveryTime) override;
};
RecoilComponent.cpp


// Sets default values for this component's properties
URecoilComponent::URecoilComponent()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = true;
}

// Called when the game starts
void URecoilComponent::BeginPlay()
{
	Super::BeginPlay();

	// ...
	
}

void URecoilComponent::Initialize(AFpsCharacter* FpsCharacter)
{
	FpsCharacter->AddObserver(this);
}

// Called every frame
void URecoilComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	FRotator Rotation = FRotator(RecoilControl.Y + RecoilStability.Y, RecoilControl.Z + RecoilStability.Z, RecoilControl.X + RecoilStability.X);

	if (0.f <= CurrentRecoveryTime)
	{
		float ReducedRecoveryTime = CurrentRecoveryTime - DeltaTime;
		Rotation.Pitch *= ReducedRecoveryTime / CurrentRecoveryTime;
		Rotation.Yaw *= ReducedRecoveryTime / CurrentRecoveryTime;
		Rotation.Roll *= ReducedRecoveryTime / CurrentRecoveryTime;
		CurrentRecoveryTime = ReducedRecoveryTime;
	}

	SetRelativeRotation(Rotation);
}

void URecoilComponent::OnEquipHands(AHands* Hands)
{
	AWeaponBase* WeaponBase = Cast<AWeaponBase>(Hands);
	if (!IsValid(WeaponBase)) return;
	CurrentRecoveryTime = 0.f;
	WeaponBase->AddObserver(this);
}

void URecoilComponent::OnUnequipHands(AHands* Hands)
{
	AWeaponBase* WeaponBase = Cast<AWeaponBase>(Hands);
	if (!IsValid(WeaponBase)) return;

	WeaponBase->RemoveObserver(this);
}

void URecoilComponent::OnCameraRecoilControlProgress(FVector CameraRecoil)
{
	RecoilControl = CameraRecoil;
}

void URecoilComponent::OnCameraRecoilStabilityProgress(FVector CameraRecoil)
{
	RecoilStability = CameraRecoil;
}

void URecoilComponent::OnRecoilStop(float RecoveryTime)
{
	UE_LOG(LogTemp, Log, TEXT("OnRecoilStop %f"), RecoveryTime);
	CurrentRecoveryTime = RecoveryTime;
}

개선점

현재 코드에서는 발사를 멈추면 타임라인이 역재생되면서 반동이 회복되는 문제가 있다. 현재 에임 위치에서 영점(0, 0, 0)까지 최단거리로 반동이 회복되도록 코드를 수정할 필요가 있다.

profile
게임 개발자입니다.

0개의 댓글