UE Projects/Sigh Of Jay 개발일지 (심화)

9 - 2. 캐릭터 회피(구르기) 기능 추가

권재현의 포트폴리오 2025. 6. 6. 05:34

< 개요 >

  • 캐릭터의 상태 머신도 구현했으니, 이어서 구르기 기능도 추가하도록 하자

< 회피 기능 추가 >

  • 회피 기능 동작 과정
    1. Space 누르면 회피
    2. 애니메이션 재생
    3. 이동
    4. 무적 처리
    5. 일정 시간 후, Idle로 복귀
회피 기능 동작 과정 1. Space 누르면 회피 기능 발동

2. 애니메이션 재생

3. 캐릭터가 회피 방향으로 이동

4. 회피 중, 무적 처리

5. 일정 시간 후, 다시 Idle로 복귀하고 무적 해제
구현 계획 1. InputAction 생성 및 InputMappingContext에 등록

2. PlayerCharacter 클래스에 IA 등록 및 함수 바인드

3. PlayerCharacter::Evade() 작성

4. AnimNotify(EvadeStart, EvadeEnd) 생성 후, 회피 애니메이션 몽타주를 생성하고 노티파이 추가

5. 캐릭터 블루프린트에서 회피 애니메이션 몽타주 등록

6. BaseCharacter 클래스의 CharacterState와 BA_Common의 AnimState를 동기화해주는 컴포넌트 작성(StateSyncComponent)

7. BaseCharacter 클래스에 StateSyncComponent 부착 후, SetState 함수 내에서 동기화되도록 해줌

[ 1. InputAction 생성 및 InputMappingContext에 등록 ]

IA_Evade / 눌림 Trigger 추가

 

InputMappingContext에 등록

 

조작 키 변경
이동 WASD
회전 마우스 XY축
점프 왼쪽 Ctrl
전력질주 왼쪽 Shift
회피 Space
락온
타겟 변경
락온 : Tab
타겟 변경 : T
줌인
줌 리셋
줌 인 : 마우스 휠
줌 리셋 : End
공격 마우스 좌클릭

[ 2. PlayerCharacter 클래스에 IA 등록 및 Evade 함수 바인드 ]

SOJCharacterBase / 헤더파일 / 적 캐릭터도 회피 기능을 사용하여 bCanEvade가 필요할 수도 있으므로, PlayerCharacter가 아닌 CharacterBase에 선언함. 다른 클래스(Evade 관련된 AnimNotify)에서도 가져가서 쓸 수 있도록 bCanEvade의 Getter, Setter 함수도 선언

 

SOJPlayerCharacter / 헤더파일 / Evade 관련 변수들 추가

 

SOJPlayerCharacter::생성자()

 

SOJPlayerCharacter::SetupPlayerInputComponent()


[ 3. PlayerCharacter::Evade() 작성 ]

SOJPlayerCharacter::Evade()

 

Evade() 구조
회피 가능 여부 체크 - 회피 쿨타임이 도는 중이면 회피 불가
- Evade나 Dead 상태에서는 회피 불가
입력 방향 가져오기 - 플레이어가 마지막으로 입력한 이동 방향을 가져옴(WASD)
- 월드 좌표계 기준의 값이 들어있다
가져온 입력 방향을
로컬 기준으로 변환
- 캐릭터의 현재 Transform을 기준으로 하여, 입력 방향을 로컬 기준으로 변환

- 변환 후에는 로컬 기준의므로 이렇게 된다
- X축 : 내가 바라보는 앞(+)/뒤(-) 방향
- Y축 : 내가 바라보는 좌(-)/우(+) 방향
변환한 입력 방향을
평면 방향으로 압축
- 평면에서의 회피만 다루고 있으므로, XY만 필요하고 Z값은 무시
방향 판별
(Enum 결정)
- 받아온 입력값의 X, Y축 절대값을 계산
- X축 입력값이 더 큼 : 캐릭터가 앞뒤로 움직임
- Y축 입력값이 더 큼 : 캐릭터가 좌우로 움직임
회피 몽타주 재생
및 방향 계산
- switch 문을 사용하여 방향에 따라 다른 회피 애니메이션 재생

- 캐릭터를 이동시킬 수 있게 EvadeDirection 변수를 선언
- 캐릭터가 현재 바라보는 방향 기준으로 EvadeDirection 설정
물리 이동 처리 - LaunchCharacter()를 사용하여 EvadeDirection으로 물리 이동을 발생시킴

 

  • FVector APawn::GetLastMovementInputVector() const
    • 이 함수는 AddMovementInput() 으로 넣었던 입력 벡터들을 합산해서 기억해둔 다음, "월드 기준 방향"으로 반환함
    • 그러므로 이를 로컬 기준으로 사용하기 위해서는 반드시 로컬 기준 좌표로 변환하는 작업이 필요함

    • 예시)
      • 플레이어가 앞을 누름 → "월드 Forward 방향" 기준의 값이 들어있음 (캐릭터가 보고 있는 방향이 아님)

    • GetLastMovementInputVector() : 마지막 이동 입력(월드 기준) ~ 일반적인 이동 방향 판별에 사용됨
    • GetPendingMovementInputVector() : 아직 처리되지 않은 이동 입력
    • ConsumeMovementInputVector() : 입력 벡터를 꺼내고 초기화

 

  • FVector FTransform::InverseTransformVectorNoScale(const FVector &V) const
    • 월드 좌표계의 벡터를 내 로컬 좌표계 벡터로 변환해주는 함수
    • 이 벡터가 내 위치/회전을 기준으로 봤을 때 어떤 방향인지를 알려줌

    • 예시)
      • 캐릭터가 북쪽(월드 상 +Z)을 보고 있음
      • 플레이어가 "앞" 입력
      • 월드 기준으로는 +Z / 로컬 기준으로는 +X

 

SOJPlayerCharacter::ApplyDamage_Implementation() / Evade 상태를 추가했으므로 이제 여기서 회피 중 무적 상태 로직을 넣었다. 그리고 SetCharacterState로 현재 상태를 Hit으로 바꿔준 후, PlayerStatComponent::ApplyDamage() 를 이용하여 받은 데미지 처리까지 해주자


[ 4. 애님 노티파이 및 회피 애니메이션 몽타주 생성 후, 노티파이 추가 ]

  • 굳이 Notify로 bCanEvade와 CharacterState를 관리하는 이유는 뭘까?
    • SOJPlayerCharacter::Evade()에서 코드로 관리해도 되긴 하는데, 이렇게 하면 문제점이 몇 가지 존재한다
      • 애니메이션 길이와 EvadeCooltime이 다르면 타이밍이 꼬임
      • 회피 애니메이션이 길어지거나 짧아지더라도 쿨타임은 고정되어 있음
      • 경직 처리나 Blend 시간을 고려할 수 없음

    • AnimNotify 기반으로 관리하게 되면 갖는 장점
      • 정확하게 Evade 애니메이션이 시작하는 시점에 무적 상태가 시작됨
      • 정확하게 Evade 애니메이션이 종료되는 시점에 무적 상태가 종료되고 원상태(Idle)로 복귀 가능 

 

  • 그러면 bCanEvade와 CharacterState를 AnimNotify와 PlayerCharacter::Evade() 둘 다에서 관리해도 되는거 아닌가?
    • 이렇게 되면 물론 몽타주가 재생되지 않거나 노티파이가 누락되더라도 최소한 상태 전이는 가능해져서 더 안전하긴 하다
    • 그러나 이렇게 되면 상태를 누가 관리하는지 모호해지고 나중에 수정 시 실수할 위험이 있다
      • ex) PlayerCharacter::Evade()에서 회피 도중에 다른 상태로 바꾸고 싶어서 코드를 수정했는데, 노티파이에서도 관리하고 있었던 것을 깜빡 잊어서 상태 변경이 정상적으로 안되는 실수

 

SOJAN_EvadeRollingStart / 헤더파일 / 당연한 말이지만 AnimNotify를 부모 클래스로 상속받아 생성하였다

 

SOJAN_EvadeRollingStart / 소스파일

 

SOJAN_EvadeRollingEnd / 헤더파일

 

SOJAN_EvadeRollingEnd / 소스파일

 

AM_EvadeRoll_Forward, Backward, Left, Right 총 4개의 회피 애니메이션 몽타주 생성 / 애니메이션 재생 속도는 0.8로 조절(1.0은 너무 빨라서 조절함) / 양 끝에 노티파이 추가


[ 5. 캐릭터 블루프린트에서 회피 애니메이션 몽타주 등록 ]

 

  • 애니메이션 몽타주를 PlayerCharacter 클래스에 레퍼런스로 직접 등록하지 않고 굳이 블루프린트에서 등록하도록 한 이유
    • 혹시나 적 캐릭터에서도 Evade 기능을 사용할 수도 있으므로
    • 만약에 PlayerCharacter 클래스에 레퍼런스로 직접 등록하게 되면 캐릭터가 달라지는 경우(다른 플레이어 클래스 / 적 클래스가 사용하는 경우)에 동기화가 힘들어진다
    • 블루프린트에서 애니메이션 몽타주를 등록하게 함으로써, 블루프린트는 단순한 데이터 주입기 역할만 하게 되고 전체 회피 로직은 C++ 코드로 작동하게 된다

[ 6. StateSyncComponent 생성 ]

  • StateSyncComponent를 생성하는 이유
    • 만약에 StateSyncComponent를 사용하여 CharacterState와 AnimState를 동기화시켜주지 않는다면, FSM 상태 전환만 되고 AnimState에서의 상태 전환은 되지 않아서 애니메이션 재생이 정상적으로 되지 않는다
    • 이를 해결하기 위해 AnimNotify에서 CharacterState뿐만이 아니라 AnimState도 명시적으로 설정해줘야 함
    • StateSyncComponent를 사용하여 이러한 과정을 줄여줄 수 있다
      • 이전에는 Evade 상태로 바꾸고 싶으면 FSM과 애니메이션에서 각각 SetState를 1번씩 써야 했다
      • 지금 구조에서는 FSM에서만 바꾸면 끝
      • 즉, 책임은 그대로 분리되어 있는데 "전달 책임"만 컴포넌트가 맡은 것이다

 

둘 다 상태를 관리하는건데 왜 인터페이스나 상태 통합 클래스 같은 걸로 묶어서 한 곳에서 관리하지 않는 것인가?
CharacterState - 게임 로직용 상태
- FSM 기반으로 전투, 회피, 피격, 죽음 등 "로직 흐름"을 제어
AnimState - 애니메이션 상태
- BlendSpace, Blend Logic, AnimGraph에서의 전이를 제어
통합하지 않고
분리하여 사용하는 이유
1. 업데이트 주기가 다름
- CharacterState는 입력/이벤트 기반(즉시 반응)
- AnimState는 Tick마다 애니메이션 상태 업데이트(Delay가 존재)



2. 책임이 다름
- FSM은 게임 로직, AnimState는 시각을 제어함
- 서로 변경 사유가 다름(SRP 위배)



3. 전이 조건 기준이 다름
- FSM은 키 입력/스킬이 조건이다
- AnimState는 속도, 가속도, 낙하 여부 등이 조건이다



4. 유연성 제한 방지
- ex) 피격 FSM 상태는 Hit이지만 AnimState는 Landing일 수 있다
- 그러므로 분리가 필요


5. 서로 다른 캐릭터에게 재활용 가능
- AnimState만 다른 캐릭터에게도 FSM은 공유가 가능함(반대의 경우도 마찬가지)
그런데 상태 연동 컴포넌트를 만들면 결국 위의 내용들과 SRP를 위배하는 것 아닌가?
- StateSyncComponent는 "통합"이 아니라 "동기화(연결 고리)"임
- 그러므로 CharacterState와 AnimState를 하나로 합쳤다고 볼 수 없음

- 동기화를 했다는 것은 서로 독립적으로 존재하지만 의도된 시점에 상태만 맞춰준다는 것이다
- 그러므로 상호 침범이 아니라 중간 메신저 역할을 한다고 보면 됨

 

  • BaseCharacter 클래스의 CharacterState와 BA_Common의 AnimState를 동기화해주는 컴포넌트 작성

 

SOJStateSyncComponent / 헤더파일

 

SOJStateSyncComponent / 소스파일 / Switch 문으로 CharacterState와 AnimState의 동기화가 필요한 상태들을 각각 짝지어서 동기화시켜줌


[ 7. BaseCharacter 클래스에 StateSyncComponent 부착 및 상태 동기화 코드 추가 ]

그런데 StateSyncComponent를 왜 BaseCharacter 클래스에 부착하는건지?
- FSM 상태는 CharacterBase가 관리하고 있음 : SetCharacterState(), GetCharacterState()의 원본 위치임

- 애님 인스턴스는 로직을 알 필요가 없음 : 애니메이션은 데이터 소비자일 뿐이다

- CharacterBase는 FSM과 AnimInstance를 둘 다 참조 가능 : GetMesh()->GetAnimInstance()

- Enemy, Player 모두 FSM을 사용하므로 재사용이 쉬움 : CharacterBase에 붙이면 모든 파생 클래스에 자동 적용
PlayerCharacter에서 사용한다면 - FSM이 PlayerCharacter에만 있다고 가정하는 구조가 되어 재사용이 불가능해짐
- EnemyCharacter가 회피나 피격 상태를 가지게 되면 또 중복 구현을 해야됨
AnimInstance에서 사용한다면 - 애님 인스턴스는 로직 계층을 참조하면 안됨 : SRP 위반
- CharacterState는 애니메이션이 직접 결정할 일이 아님
- 유지보수 시에 애니메이션 쪽 코드까지 뒤져야 함

 

  • BaseCharacter 클래스에 StateSyncComponent 부착 후, SetState 함수 내에서 CharacterState를 설정할 때, 동일한 AnimState가 있다면 동기화시킴

 

SOJCharacterBase / 헤더파일

 

SOJCharacterBase / 소스파일