이전까지 연습했던 내용을 바탕으로 엘든링과 비슷한 느낌의 다크소울류 게임을 제작하려고 한다.
기획 내용은 일반 몬스터 하나와 보스 몬스터 하나 해서 총 두가지의 몬스터를 제작할 것이다.
RAS는 Roll And Slash의 약자이다.
기본적인 내용(캐릭터 세팅 등)은 넘어가고 2주간 고민했던 내용들을 적어보려고 한다.
Combo Attack
콤보 공격은 액션게임이라면 필수불가결하다. 스킬을 사용하는 것이 아닌 기본 공격의 연계로서 다크 소울류에서도 많이 등장한다.
지금 구현할 콤보는 다음과 같다.
- A - B - C - D - E (좌클릭 - 좌클릭 - 좌클릭 - 좌클릭 - 좌클릭)
- A - B - C - H - I (좌클릭 - 좌클릭 - 좌클릭 - 좌클릭 + Left Shift - 좌클릭)
- F - G - H - I (좌클릭 + Left Shift - 좌클릭 - 좌클릭 - 좌클릭)
여기서 알파벳당 하나의 모션을 의미한다.
이렇게 만들 때 숫자가 적기에 당연히 하드 코딩해도 괜찮지만 확장성을 위해, Data Asset을 이용할 것이다.
설계
- 콤보 공격의 판정을 위해서 흐름은 다음과 같다.
- 각 알파벳 모션에서 다음 모션까지 입력을 받을 수 있는 시간이 정해져있다.
- 입력이 만약 제 시간안에 들어올 경우, 입력에 맞는 다음 상태(알파벳)을 찾는다.
- 만약 알파벳이 존재한다면 다음 상태로 넘어간다.
- 없거나 마지막 알파벳일 경우 콤보를 종료한다.
이러한 정보를 알파벳당 가지기 위해 Data Asset에서 가져야할 정보는 이름, 다음 상태 전이, 시간 등이 있을 것이다.
Data Asset
데이터 에셋은 다음과 같이 구성한다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "ComboAttackData.generated.h"
/** 공격 입력 타입 */
UENUM(BlueprintType)
enum class EAttackType : uint8 {
LeftClick UMETA(DisplayName = "Left Click"),
Shift UMETA(DisplayName = "Shift"),
F UMETA(DisplayName = "F")
};
/** 하나의 전이 정보를 나타내는 구조체 */
USTRUCT(BlueprintType)
struct FComboTransition
{
GENERATED_BODY()
// 입력한 공격 타입
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
EAttackType AttackType;
// 이 입력을 받았을 때 전이할 다음 상태 이름
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
FName NextState;
};
/** 하나의 콤보 상태 정보를 나타내는 구조체 */
USTRUCT(BlueprintType)
struct FComboState
{
GENERATED_BODY()
// 상태 이름
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
FName StateName;
// 이 상태에서 입력을 받을 수 있는 유효 시간
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
float EffectiveTime;
// 상태 전이를 위한 입력과 다음 상태 정보 목록
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
TArray<FComboTransition> Transitions;
// 현재 상태가 콤보의 마지막 공격 상태인지 여부
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
bool bIsLast;
};
/**
*
*/
UCLASS()
class PROJECTRAS_API UComboAttackData : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UComboAttackData();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combo")
TArray<FComboState> ComboStates;
};
이렇게 구성하고 에디터에서 데이터 에셋을 만든다음 내용을 채워준다.

대략적으로 다음과 같이 채워준다.
A에서 B로 넘어가기 위해서는 0.62초안에 키 입력을 받아야 하며, B로 넘어가는 조건은 LeftClick이다.
이렇게 모든 상태에 대해서 전이 방식을 정해두면 나중에 콤보가 추가되거나 할 때, 확장하기 쉽다.
애니메이션은 이래 되어있다.

키 입력 받기
키 입력은 위에서 세팅한 것 처럼 LeftShift에 대해 입력을 받아야한다. 물론 추가적인 콤보를 위해 F 의 입력 까지 만들어보자.

Trigger를 Hold로 설정한 다음, Hold가 시작 됐을 때, Shift를 누르고 있다는 것을 인지하고 End 됐을 때, Shift가 눌리지 않고 있다는 것을 체크한다.
void ARASPlayer::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
UEnhancedInputComponent* EnhancedInput = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
EnhancedInput->BindAction(RollAction, ETriggerEvent::Triggered, this, &ARASPlayer::Roll);
EnhancedInput->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ARASPlayer::Move);
EnhancedInput->BindAction(LookAction, ETriggerEvent::Triggered, this, &ARASPlayer::Look);
EnhancedInput->BindAction(LockOnAction, ETriggerEvent::Triggered, this, &ARASPlayer::LockOn);
EnhancedInput->BindAction(LeftAttackAction, ETriggerEvent::Triggered, this, &ARASPlayer::PressComboAction);
EnhancedInput->BindAction(ShiftAttackAction, ETriggerEvent::Started, this, &ARASPlayer::PressShift);
EnhancedInput->BindAction(ShiftAttackAction, ETriggerEvent::Completed, this, &ARASPlayer::PressShiftEnd);
EnhancedInput->BindAction(FAttackAction, ETriggerEvent::Started, this, &ARASPlayer::PressF);
EnhancedInput->BindAction(FAttackAction, ETriggerEvent::Completed, this, &ARASPlayer::PressFEnd);
EnhancedInput->BindAction(QAttackAction, ETriggerEvent::Triggered, this, &ARASPlayer::PressQ);
EnhancedInput->BindAction(EAttackAction, ETriggerEvent::Triggered, this, &ARASPlayer::PressE);
}
void ARASPlayer::PressShift()
{
bIsPressShift = true;
}
void ARASPlayer::PressShiftEnd()
{
bIsPressShift = false;
}
void ARASPlayer::PressF()
{
bIsPressF = true;
}
void ARASPlayer::PressFEnd()
{
bIsPressF = false;
}
이제 좌클릭을 했을 때, Shift가 포함되어 있는지 F가 포함되어 있는지 판단하면 된다.
void ARASPlayer::PressComboAction()
{
if (ComboAttack)
{
if(bIsPressShift)
ComboAttack->PressComboAction(EAttackType::Shift);
else if(bIsPressF)
ComboAttack->PressComboAction(EAttackType::F);
else
ComboAttack->PressComboAction(EAttackType::LeftClick);
}
}
Combo Attack Component
콤보 어택 컴포넌트를 따로 두어 관리를 해보자.
- 현재 콤보가 없다면 시작한다.
- 만약 있다면 키 입력을 저장한다.
- 콤보가 시작되고 정해진 시간에 타이머를 둔다.
- 타이머가 완료되면 다음 상태로 전이할 수 있는지 확인한다.
- 가능하다면 3번으로 돌아가 타이머를 다시 설정한다.
- 불가능하다면 콤보를 종료한다.
크게 이러한 흐름으로 시작된다.
// Data Asset에서 불러온 콤보 상태 정보를 저장하는 맵
UPROPERTY(VisibleAnywhere, Category = "Combo")
TMap<FName, FComboState> ComboStateMap;
// 에디터에서 할당할 Data Asset
UPROPERTY(EditAnywhere, Category = "Combo")
TObjectPtr<class UComboAttackData> ComboDataAsset;
---------------------------------------------------------------
void UComboAttackComponent::BeginPlay()
{
Super::BeginPlay();
// Data Asset이 할당되어 있다면, 에디터에서 구성한 콤보 상태를 ComboStateMap에 복사
if (ComboDataAsset)
{
ComboStateMap.Empty();
for (const FComboState& State : ComboDataAsset->ComboStates)
{
ComboStateMap.Add(State.StateName, State);
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("ComboDataAsset이 할당되지 않았습니다!"));
}
}
먼저 데이터 에셋으로부터 값을 가져온다.
void UComboAttackComponent::PressComboAction(EAttackType InAttackType)
{
// 입력 제한이 활성화 중이면 입력 무시
if (!bCanAcceptInput)
{
return;
}
// 만약 콤보 진행 중이 아니라면 시작
if (CurrentState == NAME_None)
{
if (InAttackType == EAttackType::LeftClick)
{
CurrentState = FName("A");
StartCombo();
}
else if (InAttackType == EAttackType::Shift)
{
CurrentState = FName("F");
StartCombo();
}
return;
}
// 마지막 공격이 진행 중이면 새로운 입력은 무시
if (ComboStateMap.Contains(CurrentState) && ComboStateMap[CurrentState].bIsLast)
{
return;
}
// 콤보 진행 중이며 입력 윈도우 내라면, 입력을 저장
if (GetWorld()->GetTimerManager().IsTimerActive(ComboTimerHandle))
{
bHasPendingInput = true;
PendingAttackType = InAttackType;
}
}
플레이어가 위해서 좌클릭 공격등을 하면 위의 함수가 호출된다.
콤보가 존재하지 않다면 입력된 값에 따라 콤보가 시작된다.
void UComboAttackComponent::StartCombo()
{
// 플레이어 공격 상태 활성화
ARASPlayer* Player = Cast<ARASPlayer>(GetOwner());
if (Player)
{
Player->SetIsAttacking(true);
// 몽타주 재생
UAnimInstance* AnimInstance = Player->GetMesh()->GetAnimInstance();
if (AnimInstance && ComboAttackMontage)
{
AnimInstance->Montage_Play(ComboAttackMontage);
}
}
// 현재 상태의 입력 유효 시간에 맞춰 타이머 설정
SetComboTimer();
}
그리고 타이머를 설정한다.
void UComboAttackComponent::SetComboTimer()
{
if (ComboStateMap.Contains(CurrentState))
{
ARASPlayer* Player = Cast<ARASPlayer>(GetOwner());
if (Player) if (Player->bIsRolling == true) return;
float EffectiveTime = ComboStateMap[CurrentState].EffectiveTime;
if (ComboStateMap[CurrentState].bIsLast == false)
{
// 입력 가능한 상태라면 타이머를 설정
GetWorld()->GetTimerManager().SetTimer(ComboTimerHandle, this, &UComboAttackComponent::ComboTimerExpired, EffectiveTime, false);
}
else
{
GetWorld()->GetTimerManager().SetTimer(ComboTimerHandle, this, &UComboAttackComponent::EndCombo, EffectiveTime, false);
}
}
else
{
EndCombo();
}
}
정해진 시간 만큼이 지난후 키 입력이 있는지 체크한다.
void UComboAttackComponent::ComboTimerExpired()
{
// 입력이 있었다면 상태 전이 시도
if (bHasPendingInput)
{
ARASPlayer* Player = Cast<ARASPlayer>(GetOwner());
if (Player) if (Player->bIsRolling == true) return;
FName NextState;
if (GetNextState(PendingAttackType, NextState))
{
CurrentState = NextState;
bHasPendingInput = false; // 입력 초기화
// 몽타주에서 해당 섹션으로 점프
if (Player)
{
UAnimInstance* AnimInstance = Player->GetMesh()->GetAnimInstance();
if (AnimInstance && ComboAttackMontage)
{
// 다음 공격으로 전환할 때 Montage_JumpToSection을 호출
AnimInstance->Montage_Play(ComboAttackMontage);
AnimInstance->Montage_JumpToSection(CurrentState, ComboAttackMontage);
}
}
// 새 상태에 맞춰 타이머 재설정
SetComboTimer();
}
else
{
// 유효한 전이가 없으면 콤보 종료
EndCombo();
}
}
else
{
// 입력이 없으면 콤보 종료
EndCombo();
}
}
bool UComboAttackComponent::GetNextState(EAttackType InAttackType, FName& OutNextState)
{
if (ComboStateMap.Contains(CurrentState))
{
const FComboState& ComboState = ComboStateMap[CurrentState];
// 배열을 순회하여 해당 공격 타입과 일치하는 전이를 찾음
for (const FComboTransition& Transition : ComboState.Transitions)
{
if (Transition.AttackType == InAttackType)
{
OutNextState = Transition.NextState;
return true;
}
}
}
return false;
}
콤보가 끝나지 않았다면 다음 콤보를 예약한다.
그게 아니라면 콤보를 종료한다.
void UComboAttackComponent::EndCombo()
{
// 타이머 초기화 및 콤보 상태 리셋
GetWorld()->GetTimerManager().ClearTimer(ComboTimerHandle);
CurrentState = NAME_None;
bHasPendingInput = false;
// 플레이어 공격 상태 비활성화
ARASPlayer* Player = Cast<ARASPlayer>(GetOwner());
if (Player)
{
Player->SetIsAttacking(false);
}
bCanAcceptInput = false;
GetWorld()->GetTimerManager().SetTimer(
ComboResetHandle,
FTimerDelegate::CreateLambda([this]()
{
bCanAcceptInput = true;
}),
.7f,
false
);
}
이렇게 해서 콤보 시스템을 만들 수 있었다.
결과
Rolling
콤보 어택보다 애먹은 것이 하나 있는데 바로 구르기 이다.
구르기라는 것이 좀 애매했었는데
애니메이션의 루트 모션을 이용해 구르기를 하면 바로 이동이 되게끔 할 계획이었지만
생각해보면 공격중 뒤로 구르거나 옆으로 구르고 싶을 수도 있을 것이다.
그렇기 때문에 생각한 방법은 마지막으로 누른 방향을 저장해 놨다가
Roll 할 때, 그 방향과 현재 카메라의 방향에 차이를 계산해 각도에 따라 애니메이션을 재생하는 것이었다.
말로 하면 쉽지만 이 아이디어를 떠올리는데는 꽤나 오랜 시간이 지났다.
void ARASPlayer::Roll(const FInputActionValue& Value)
{
if (RollMontage == nullptr) return;
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance == nullptr) return;
if (bIsRolling) return;
bIsRolling = true;
AnimInstance->Montage_Stop(0.1f);
FName RollSection = TEXT("Roll_F"); // 기본 섹션
// 입력값이 없으면 기본값 (정면, (1,0)) 사용
FVector2D RawInput;
if (!LastMoveInput.IsNearlyZero())
{
RawInput = LastMoveInput.GetSafeNormal();
}
else
{
RawInput = FVector2D(1.f, 0.f);
}
float SignedAngle = 0.f; // 계산될 각도 (0~360)
if (bUseControllerRotationYaw)
{
// 입력을 카메라 기준으로 월드 방향으로 변환
FRotator CamRot = Controller->GetControlRotation();
FRotator CamYaw(0, CamRot.Yaw, 0);
FVector RollDirection = FRotationMatrix(CamYaw).TransformVector(FVector(RawInput.X, RawInput.Y, 0.f));
RollDirection.Normalize();
// 기준 정면은 카메라의 forward (CamYaw의 X축)
FVector ReferenceForward = FRotationMatrix(CamYaw).GetUnitAxis(EAxis::X);
float Dot = FVector::DotProduct(ReferenceForward, RollDirection);
float AngleDegrees = FMath::Acos(Dot) * (180.f / PI);
FVector Cross = FVector::CrossProduct(ReferenceForward, RollDirection);
float Sign = (Cross.Z >= 0.f) ? 1.f : -1.f;
SignedAngle = AngleDegrees * Sign;
if (SignedAngle < 0.f)
{
SignedAngle += 360.f;
}
}
else
{
// 캐릭터가 직접 회전한 상태.
// 입력은 카메라 기준으로 저장되어 있으므로, 의도한 이동 방향을
// 카메라의 회전(원래 입력 기준)으로부터 구함.
FRotator CamRot = Controller->GetControlRotation();
FRotator CamYaw(0, CamRot.Yaw, 0);
FVector IntendedDirection = FRotationMatrix(CamYaw).TransformVector(FVector(RawInput.X, RawInput.Y, 0.f));
IntendedDirection.Normalize();
// 캐릭터의 현재 정면 (Actor의 forward)
FVector ActorForward = GetActorForwardVector();
ActorForward = ActorForward.GetSafeNormal2D();
float Dot = FVector::DotProduct(ActorForward, IntendedDirection);
float AngleDegrees = FMath::Acos(Dot) * (180.f / PI);
FVector Cross = FVector::CrossProduct(ActorForward, IntendedDirection);
float Sign = (Cross.Z >= 0.f) ? 1.f : -1.f;
SignedAngle = AngleDegrees * Sign;
if (SignedAngle < 0.f)
{
SignedAngle += 360.f;
}
}
// 8방향(45도 단위) 매핑
if ((SignedAngle >= 337.5f && SignedAngle < 360.f) || (SignedAngle >= 0.f && SignedAngle < 22.5f))
{
RollSection = TEXT("Roll_F"); // 정면
}
else if (SignedAngle >= 22.5f && SignedAngle < 67.5f)
{
RollSection = TEXT("Roll_FR"); // 정면 우측 대각
}
else if (SignedAngle >= 67.5f && SignedAngle < 112.5f)
{
RollSection = TEXT("Roll_R"); // 우측
}
else if (SignedAngle >= 112.5f && SignedAngle < 157.5f)
{
RollSection = TEXT("Roll_BR"); // 후면 우측 대각
}
else if (SignedAngle >= 157.5f && SignedAngle < 202.5f)
{
RollSection = TEXT("Roll_B"); // 후면
}
else if (SignedAngle >= 202.5f && SignedAngle < 247.5f)
{
RollSection = TEXT("Roll_BL"); // 후면 좌측 대각
}
else if (SignedAngle >= 247.5f && SignedAngle < 292.5f)
{
RollSection = TEXT("Roll_L"); // 좌측
}
else if (SignedAngle >= 292.5f && SignedAngle < 337.5f)
{
RollSection = TEXT("Roll_FL"); // 정면 좌측 대각
}
AnimInstance->Montage_Play(RollMontage);
AnimInstance->Montage_JumpToSection(RollSection);
FOnMontageEnded MontageEndedDelegate;
MontageEndedDelegate.BindLambda([this](UAnimMontage* Montage, bool bInterrupted)
{
bIsRolling = false;
bIsAttacking = false;
if (ComboAttack)
{
ComboAttack->EndCombo();
}
});
AnimInstance->Montage_SetEndDelegate(MontageEndedDelegate, RollMontage);
}
결과
'Unreal Engine 5 > ProjectRAS' 카테고리의 다른 글
| [UE5] ProjectRAS - 전투 공격 판정 과 패링, 적 타겟팅 시스템(락온) (0) | 2025.03.12 |
|---|---|
| [UE5] ProjectRAS - 시점 고정과 일반 몬스터 (0) | 2025.03.06 |
| [UE5] ProjectRAS - 몬스터 AI 제작하기 (0) | 2025.02.06 |
| [UE5] ProjectRAS - 스탯 구성하기 (0) | 2025.01.21 |
| [UE5] ProjectRAS - 스킬 디자인 구성2 (0) | 2025.01.15 |