내가 개발중인 캐릭터인 CRUSHER 라는 캐릭터는 초반부터 지금까지 상당히 많은 변화를 거쳐왔다.

거쳐오는 도중 다음날이 바로 발표이거나 급하게 결과를 봐야하는 경우에 먼 미래를 위해 튼실하게 만드는 것 보다는, 지금 당장의 결과를 위해 땜빵치고(hack) 넘어가는 코드가 너무 많아져버렸다.

언젠가 고쳐야지, 언젠가 다시 제대로 짜놔야지 하며 하나 둘 추가되어가던 hack들은 하나 하나씩 기술 부채(Tech Debt)가 되었고, 다시 캐릭터를 수정하려 왔을 때 코드를 알아먹기 너무나 힘들어져 캐릭터를 리팩토링 하기로 결심했다.

그냥 hack 이야기가 나와서 기억난 포프님 영상

문제점

문제점들은 상당히 많았다. 간단히 요약해 보자면,

  • 개발 초기 언리얼 엔진에 대한, 부족한 이해도로 인해 난잡한 코드
  • 발표로 인해 정말 작동만 하기 위해 사용한 수많은 hack 코드들
  • 잦은 hack 코드를 사용함에 따른 기술 부채의 축적
  • 그로 인해 기획자나 아트팀이 원하는 대로 바꾸기엔 힘들어진 코드들

위의 상황들을 겪어오며, 최근 캐릭터 공격 모션이 건너뛰는 문제까지 겹치게 되었고, 이럴 바에는 차라리 새로 짜야겠다라는 결심을 하게 되었다.

먼저 캐릭터에 대해 조금 더 설명을 해보자면 지금까지 크러셔라는 캐릭터는 스킬과 공격에 관한 조건들이 많이 변경되어 왔었다.

  • 맨 처음 크러셔 스팩
    • 돌진, 점프 스매시, 차징 공격, 약간의 버프
  • 그 다음 크러셔 스팩
    • 돌진, 점프 스매시, 차징 공격, 쌍칼이 나오는 궁, 그리고 공격 모드
  • 그 다음 다음 크러셔 스팩
    • 돌진, 실드 생성, 끌어 들이는 스킬, 여러가지 버프 궁
  • 현재 크러셔 스팩
    • 돌진, 실드 생성, 버프 궁

스팩은 총 4~6회 정도 변경되어 왔으며, 대부분 발표가 얼마 남지 않은 날이거나, 시간은 충분했으나 내가 다른 작업에 바빳던 시간이 많았던 것 같다. 그래서 계속 언급하는 당장의 문제를 해결하기 위한 hack들이 추가되기 시작했다.

그에 대한 예로는 이런 것들이 있다.

void AHACKED_CRUSHER::OnSkill1RushState()
{
	if (!NetBaseCP->HasAuthority()) return;
	//prevent malfunctions
	if (bIsRushing)
		return;
	if (currentSkillNumber != 1 && bIsRushing)
		return;
	if (currentSkillNumber == 3)
		return;
	if (magFieldActor != nullptr)
		return;
	if (rushCharging <= rushMinCharge)
	{
		StopSkill1();
		return;
        }
	bIsChargingKeyPressed = false;
	bIsRushing = true;
	rocketRushSphere->Activate();
	originalPos = GetActorLocation();
	rocketRushDirection = GetActorForwardVector();
	if (rushCharging < 1.0f) {rushDistance = (rushCharging / rushMaxCharge)*rushMaxDistance;}
	else if (rushCharging >= 1.0f) {rushDistance = rushMaxDistance;}
}

prevent malfunctions 라는 주석만을 갖고 잔뜩 늘어난 if문들.

void AHACKED_CRUSHER::OnSkill1ChargingState()
{
	/*if (currentSkillNumber != 1)
		return;

	bIsChargingKeyPressed = true;
	bIsRushing = false;*/
}

통째로 주석되었지만, 이젠 더이상 사용하지 않는 코드.

void AHACKED_CRUSHER::OnAttackMontageEnded(UAnimMontage * montage, bool bInterrupted)
{
	if (currentSkillNumber != 3)
	{
		if(!isAttacking || !(currentCombo > 0)) return;
		isAttacking = false;
		AttackEndComboState();
	}
}

void AHACKED_CRUSHER::AttackStartComboState()
{
	canNextCombo = true;
	isComboInputOn = false;
	if (!FMath::IsWithinInclusive<int32>(currentCombo, 0, maxCombo - 1)) return;
	currentCombo = FMath::Clamp<int32>(currentCombo + 1, 1, maxCombo);
	_bCanMove = false;
}

void AHACKED_CRUSHER::AttackEndComboState()
{
	isComboInputOn = false;
	canNextCombo = false;
	currentCombo = 0;
	isAttacking = false;
	_bCanMove = true;
}

void AHACKED_CRUSHER::AttackCheck()
{
	if (NetBaseCP->HasAuthority()) GetWorld()->GetFirstPlayerController()->ClientPlayCameraShake(CS_CrusherPA, 1.0f);

	TArray<FHitResult> DetectResult;
	FCollisionQueryParams params(NAME_None, false, this);
	GetWorld()->SweepMultiByChannel(
		DetectResult,
		GetActorLocation(),
		GetActorLocation() + GetActorForwardVector() * attackRange,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel1,
		FCollisionShape::MakeSphere(attackRadius),
		params);

	if (DetectResult.Num() != 0)
	{
		OnAttackHittedDelegate.Broadcast();

		for (auto Hit : DetectResult)
		{
			if (Hit.GetActor())
			{
				if (Cast<AHACKED_AI>(Hit.GetActor()))
				{
					AHACKED_AI* hackedAI = Cast<AHACKED_AI>(Hit.GetActor());
					if(!hackedAI) continue;
					UGameplayStatics::ApplyDamage(hackedAI, attackDamage, GetController(), this, UPhysicalDamageType::StaticClass());
					if (currentCombo == 3)
					{
						hackedAI->OnRigidityAI();
					}
				}
			}
		}
	}
}

지금은 이해가 되지만, 작성할 당시 엔진의 기능들을 잘 몰라서, 그저 복붙한 교수님의 책에서 나온 코드들 (캐릭터 공격, 애님 인스턴스 등등)

//최대 충전 시간
	const float rushMaxCharge= 2.0f;
	//최소 충전 시간
	const float rushMinCharge = 0.4f;
	//How much distance to RocketRush
	UPROPERTY(EditAnywhere)
	float rushDistance = 0.0f;
	//Max Distance of RocketRush
	const float rushMaxDistance = 2000.0f;
	//러쉬 속도
	UPROPERTY(EditAnywhere)
	float rushSpeed = 3000.0f;
	//스킬 충전용 변수
	UPROPERTY(EditAnywhere)
	float rushCharging=0.0f;
	//총 러쉬 시간
	UPROPERTY(EditAnywhere)
	float totalRushTime = 0.1f;
	//러쉬 맞을 때 데미지
	UPROPERTY(EditAnywhere)
	float rushDamage = 20.0f;
	//적이 밀려날 거리
	UPROPERTY(EditAnywhere)
	float pushEnemyDistance = 10.0f;

테스트나 구조 변경을 위해 리터럴로 수정해 버려서 실제로 코드에 반영되지 않는 몇가지 프로퍼티 변수들.

void UCRUSHER_AnimInstance::JumpToAttackMontageSection(int32 newSection)
{
	CHECK(Montage_IsPlaying(attackMontage));
	Montage_JumpToSection(GetAttackMontageSectionName(newSection), attackMontage);
}

Montage_JumpToSection을 사용했기 때문에 애니메이션이 끝까지 재생되지 않고 뚝뚝 끊겨 넘어가는 문제, 이로 인해 루트모션의 이동에서 문제가 발생함

그 외에도 자잘한 문제들이 조금씩 있었는데, 이제 해결을 해보겠다.

리팩토링

학기 종료까지 3주가 남은 시점에서 더이상의 스팩 변경을 하지 않기로 확약을 받아낸 후 작업을 진행했다.

바뀐점은 요악해보자면 이러하다.

  • currentSkillNumber 변수로 더이상 스킬을 관리하지 않음
    • 버프스킬이나 중첩된 스킬을 사용할 때 유연함을 위해서 버림
  • 돌진 스킬의 로직을 제대로 분리해냄
    • 시작, 충전, 조건, 진행, 끝 이렇게 핵심적인 시퀀스를 나눠 구현
  • 뚝뚝 끊기던 캐릭터의 공격이나 스킬들을 부드럽게 만듬
    • 애님 몽타주에서 입력을 받자마자 다음 섹션으로 넘어가 버리던 기능을 끝내고 이어가도록 바꿈
    • 루트 모션을 부드럽게 적용시킴
    • 루트본이 없던 캐릭터에 루트본을 씌우는 방법을 찾아 적용시켜 문제 해결
  • 공격 시스템을 캐릭터보다 AnimInstance로 관리 하도록 함
    • 공격로직은 가장 자주 쓰이고 애님인스턴스에 종속적임 그래서 옮겨버림
  • 공격 판정을 기본 캡슐 오버랩 형태가 아닌, LineTrace를 사용함
    • 더 세밀하고 실제 모션에 맞는 공격 결과를 도출해 내게 되었음.
    • 회전형 공격이 많은 크러셔의 경우에 이게 더 적합하고 잘 어울림.
  • 코드를 다시 한 번 짜게 되면서 내가 만든 캐릭터에 대해 다시 완벽한 이해를 하게 되었음.
    • 사실 이게 주인공이라고 봐도 무방.

공격 시스템 변경

언리얼에서 가장 적합한 공격시스템이 무엇일까, 고민하던 나는 잘못된 주제로 고민을 했었다.

진정한 문제는 우리 게임에 가장 적합하고 지금 캐릭터에 가장 적합한 공격 시스템을 구현해야했던 것인데 말이다.

현재 크러셔는 원을 그리며 회전해 공격하는 방식으로 변했고, 이전 처럼 앞의 캐릭터만 공격하는 방식이 아니라, 캐릭터 앞에 앞으로 누운 캡슐로 오버렙을 판정해 공격하는 로직은 더 이상 사용할 수 없게 되었다.

처음에는 실드쪽에 Sphere를 하나 붙여 판정을 해볼까 했는데, 이쁘지 않고, 이전에 크러셔에서 존재하던 문제였던 캐릭터 판정을 다시 받게 될까봐 포기했다.

얼마 지나지 않아서 LineTrace를 쓰는게 좋다고 생각이 되어서 실드를 부착한 본을 기준으로 시작과 끝을 받을 소켓을 달아 탐지하도록 했다.

Attack 구조

캐릭터에 유일하게 존재하는 마우스 왼쪽 클릭시 콜 되는 Attack 시작 코드

void AHACKED_CRUSHER::Attack()
{
	RPC(NetBaseCP, AHACKED_CRUSHER, Attack, ENetRPCType::MULTICAST, true);
	
	if (!_bCanAttack) return;

	if (!animInstance->IsAttackMontagePlaying())
	{
		animInstance->PlayAttackMontage();
	}

	if (animInstance->CanDoNextAttack())
	{
		animInstance->DoNextAttack();
	}
}

Anim Instance 관련 코드

헤더

	/*-------------------------------------------------- ## Attack ## --------------------------------------------------*/
private:
	//VARIABLES
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		UAnimMontage* attackMontage;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		bool bIsAttackState = false;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		int currentAttackSectionNumber = 0;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		int currentComboNumber = 0;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		bool bCanDoNextAttackInput = false;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		bool isTracerActive = false;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		FVector attackTraceStartLoc;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		FVector attackTraceEndLoc;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		TArray<AHACKED_AI*> hitted;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		bool bIsHit = false;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Attack", meta = (AllowPrivateAccess = "true"))
		bool bDoOnce = false;
	UPROPERTY(VisibleAnywhere)
		USoundBase* enemyAttackSound1;
	UPROPERTY(VisibleAnywhere)
		USoundBase* enemyAttackSound2;
	UPROPERTY(VisibleAnywhere)
		USoundBase* enemyAttackSound3;
	UPROPERTY(EditAnywhere)
		USoundAttenuation* crusher_attack_attenuation;
public:
	//FUNCTIONS
	void PlayAttackMontage();
	void DoNextAttack();
	void UpdateTracerLocation();
	void LineTracing();
	void GiveDamage(AHACKED_AI* damageTo);
	void PlayAttackHitSound();
	FORCEINLINE bool IsAttackMontagePlaying() { return Montage_IsPlaying(attackMontage)|| Montage_IsPlaying(ultimateAttackMontage); }
	FORCEINLINE bool IsAttackMontageActivated() { return Montage_IsActive(attackMontage) || Montage_IsActive(ultimateAttackMontage); }
	FORCEINLINE bool CanDoNextAttack() { return bCanDoNextAttackInput; }
	FORCEINLINE bool GetAttackState() { return bIsAttackState; }
	//NOTIFIES
	UFUNCTION()
		void AnimNotify_CheckNextAttack();
	UFUNCTION()
		void AnimNotify_StartShieldAttackHitTrace();
	UFUNCTION()
		void AnimNotify_StopShieldAttackHitTrace();

구현

void UCRUSHER_AnimInstance::PlayAttackMontage()
{
	//If Montage isn't playing, Play it
	if (!IsAttackMontagePlaying())
	{
		if (bIsAdrenalSurge)
		{
			Montage_Play(ultimateAttackMontage);
		}
		else
		{
			Montage_Play(attackMontage);
		}
		currentAttackSectionNumber = 0;
		currentComboNumber = 0;
		bCanDoNextAttackInput = true;
	}
}

void UCRUSHER_AnimInstance::DoNextAttack()
{
	if (bCanDoNextAttackInput)
	{
		if(bIsAdrenalSurge)
		{
			switch (currentAttackSectionNumber)
			{
			case 0:
				currentAttackSectionNumber = 1;
				currentComboNumber = 1;
				break;
			case 1:
				Montage_SetNextSection(FName("Attack0"), FName("Attack1"), ultimateAttackMontage);
				currentAttackSectionNumber = 2;
				currentComboNumber = 2;
				break;
			case 2:
				Montage_SetNextSection(FName("Attack1"), FName("Attack2"), ultimateAttackMontage);
				currentAttackSectionNumber = 3;
				currentComboNumber = 3;
				break;
			default:
				//If the value exceed 0~2 change it to 0
				currentAttackSectionNumber = 0;
				currentComboNumber = 0;
				break;
			}
		}
		else
		{
			switch (currentAttackSectionNumber)
			{
			case 0:
				currentAttackSectionNumber=1;
				currentComboNumber = 1;
				break;
			case 1:
				Montage_SetNextSection(FName("Attack0"), FName("Attack1"), attackMontage);
				currentAttackSectionNumber=2;
				currentComboNumber = 2;
				break;
			case 2:
				Montage_SetNextSection(FName("Attack1"), FName("Attack2"), attackMontage);
				currentAttackSectionNumber=3;
				currentComboNumber = 3;
				break;
			default:
				//If the value exceed 0~2 change it to 0
				currentAttackSectionNumber = 0;
				currentComboNumber = 0;
				break;
			}
		}
		bCanDoNextAttackInput = false;
	}
}

void UCRUSHER_AnimInstance::UpdateTracerLocation()
{
	attackTraceStartLoc = player->GetMesh()->GetSocketLocation(FName("ShieldTraceStart"));
	attackTraceEndLoc = player->GetMesh()->GetSocketLocation(FName("ShieldTraceEnd"));
}

void UCRUSHER_AnimInstance::LineTracing()
{
	TArray<FHitResult> hit;
	bool ishit = false;
	
	//DrawDebugLine(GetWorld(), attackTraceStartLoc, attackTraceEndLoc, FColor::Green, false, 2.f, false, 4.f);
	//Do LineTrace And Get HACKED_AI to hitted Array while giving them a damage
	GetWorld()->LineTraceMultiByChannel(
		hit,
		attackTraceStartLoc,
		attackTraceEndLoc,
		ECollisionChannel::ECC_GameTraceChannel1);

	if (hit.IsValidIndex(0))
	{
		for (FHitResult hitIter : hit)
		{
			if (hitIter.GetActor()->IsA(AHACKED_AI::StaticClass()))
			{
				AHACKED_AI* hittedEnemy = Cast<AHACKED_AI>(hitIter.GetActor());
				if (!hitted.Contains(hittedEnemy))
				{
					if (!bDoOnce)
					{
						bIsHit = true;
						bDoOnce = true;
						PlayAttackHitSound();
						GetWorld()->GetFirstPlayerController()->ClientPlayCameraShake(player->CS_CrusherPA, 1.0f);
					}
					player->SpawnPrimaryHitEffect(hitIter.ImpactPoint);
					//HLOG("%s", *hittedEnemy->GetName());
					GiveDamage(hittedEnemy);
					hitted.Add(hittedEnemy);
				}
			}
		}
		
	}
}

void UCRUSHER_AnimInstance::GiveDamage(AHACKED_AI* damageTo)
{
	//Normal Attack (Not AdrenalSurged)
	if (!bIsAdrenalSurge) {
		FPlayerDamageEvent damageEvent;
		damageEvent.bCanChargeUlt = true;
		damageEvent.bCanHitAnimation = true;

		switch (currentComboNumber)
		{
		case 1:
			damageTo->TakeDamage(player->normalDamage, damageEvent, player->GetController(), player);
			break;
		case 2:
			damageTo->TakeDamage(player->normalDamage, damageEvent, player->GetController(), player);
			break;
		case 3:
			damageTo->TakeDamage(player->criticalDamage, damageEvent, player->GetController(), player);
			break;
		default:
			break;
		}
	}
	//Ultimate Attack (AdrenalSurged)
	else
	{
		FPlayerDamageEvent damageEvent;
		damageEvent.bCanHitAnimation = true;

		switch (currentComboNumber)
		{
		case 1:
			damageTo->TakeDamage(player->ultNormalDamage, damageEvent, player->GetController(), player);
			break;
		case 2:
			damageTo->TakeDamage(player->ultNormalDamage, damageEvent, player->GetController(), player);
			break;
		default:
			break;
		}
	}
}

void UCRUSHER_AnimInstance::PlayAttackHitSound()
{
	switch (rand() % 3)
	{
	case 0:
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), enemyAttackSound1, player->GetActorLocation(), 1.0f, 1.0f, 0.0f, crusher_attack_attenuation);
		break;
	case 1:
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), enemyAttackSound2, player->GetActorLocation(), 1.0f, 1.0f, 0.0f, crusher_attack_attenuation);
		break;
	case 2:
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), enemyAttackSound3, player->GetActorLocation(), 1.0f, 1.0f, 0.0f, crusher_attack_attenuation);
		break;
	default:
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), enemyAttackSound1, player->GetActorLocation(), 1.0f, 1.0f, 0.0f, crusher_attack_attenuation);
		break;
	}

}

void UCRUSHER_AnimInstance::AnimNotify_CheckNextAttack()
{
	if (!bCanDoNextAttackInput)
		bCanDoNextAttackInput = true;
	//HLOG("CHECK NEXT ATTACK");
}

void UCRUSHER_AnimInstance::AnimNotify_StartShieldAttackHitTrace()
{
	isTracerActive = true;

	//HLOG("START SHIELD TRACE");
}

void UCRUSHER_AnimInstance::AnimNotify_StopShieldAttackHitTrace()
{
	isTracerActive = false;
	hitted.Empty();
	bDoOnce = false;
	//HLOG("STOP SHIELD TRACE");
}

잘 작동하고 공격도 잘 어울리게 들어가서 만족스러웠다.

그 이후로 애니메이션이 끊기는 문제는 교수님의 코드에서 벗어나 이제 어느정도 엔진에 익숙해져서 작접 모두 작성해 보았다.

Montage_JumpToSection을 사용하지 않고, Montage_SetNextSection을 사용해서 바로 다음 섹션으로 점프해 애니메이션을 끊어버리는 것이 아닌, 시간에 따라 애니메이션을 계속 진행하되, 다음 공격을 하라는 인풋이 들어올 경우 지금 섹션의 다음 섹션을 설정해 넘어가 주도록 하여서, 끊기는 애니메이션을 없도록 하였다.

Root Motion적용의 경우, 무슨 짓을 해도 캐릭터가 하늘에 떠있는 이상한 상태로 표시가 되어서 계속 찾아보던 중, 본의 캐릭터의 맨 위에 Root라는 이름의 본이 달려있어야 한다. 이전에 올린 글을 찾아 해결했다.

공격에 관해 내가 작성하지도 않고, 문제가 많았던 레거시 코드를 걷어내고 나서 정말 후련하다고 느꼈다.

다음 문제는 Rocket Rush라는 돌진 스킬의 변경이다.

이전 Rocket Rush는 캐릭터 바로 앞에 꽤 큰 Sphere 하나를 달아두고, 거기에 닿을 때, 애들을 튕겨내는 방식을 사용했다. 그 이유는 크러셔가 나갈 때, Velocity가 얼마나 감쇠하냐에 따라 멈추는 로직이 있었는데, 몬스터와 닿을 때 바로 멈춰버리는 문제가 있었기 때문에 그렇게 만들었었다.

이 방식에는 문제가 상당히 많았는데, 그 문제들은 이러하다.

  • Sphere의 크기가 너무 크다.
  • 캐릭터에 Sphere가 컴포넌트로 붙어있어, 캐릭터가 닿지 않았는데 캐릭터를 탐지해야 하는 곳에서 탐지되어 버린다.
  • Sphere와 캐릭터 그리고 닿은 몬스터와의 거리가 눈으로 보인다. 직접 닿는 느낌이 없었다.

이 부분은 Character에서 상속받은 CapsuleComponent에서 OnComponentHit을 받아서. 날려버리는 방식으로 바꾸고, 몬스터를 날리기 위한 로직을 사용해 다시 만들어 보았는데, 잘 작동했다. 지금 와서 알게된 문제지만 그 당시 엔진에 대해 잘 몰라 OnComponentHit을 AddDynamic으로 추가하기 전에 그 함수가 UFUNCTION() 으로 선언이 되어있지 않아서 일어났던 문제였다. 안되던게 아니라 내가 잘 몰라서 못했던 것이었다.

이제 캐릭터와 잘 부딪히는 느낌이 들어 다행이라고 생각한다. 별다른 기능은 아니지만 그래도 이전 코드보다 확실히 나아져서 다행이다.

이런 별다르게 큰 기능이 없는 캐릭터도, 리팩토링을 해야하는 상황이 이렇게 고난한데, 큰 규모의 프로젝트에서 시도되는 리팩토링들은 점진적으로 변하겠지만서도… 참 대단하다고 느껴진다.

이 외에도 좀더 만든 부분들이 있지만, 그닥 중요하지 않아서, 졸리니까 여기서 끝내겠다.