카타나 공격 구현
코드 (깃허브)
카타나 (칼)
일단 이것은 프로젝트 이쩜오 기획에 맞춰 구현한 것입니다. 같은 칼 공격이더라도 기획에 따라 구현이 많이 바뀔 것입니다.
그래도 구현하며 공부한 내용과 생각한 내용들을 기록해, 다른 기획에 맞춰 구현할 때도 도움이 될 것입니다.
구현
- 타격 판정
- NotifyState 사용하여 판정 구간 설정
- 최대 판정 사이 간격 설정을 통해 정확도 향상
- 본 트랜스폼 추출 및 활용을 통해 궤적 보정
- 콤보
- 플레이어 경험 고려한 콤보 방식 설계
- NotifyState, Notify 사용
- 멀티 플레이어
- 호스트에서 판정 처리
- 클라이언트에서 콤보 선 진행 후 서버 검증
- 패킷 지연 및 손실 상황 고려
타격 판정 구현
생각한 칼 판정 구현 방법 세 가지입니다.
- 무기의 콜리전을 공격 애니메이션 시작할 때 켜고, 끝날 때 끄는 방식. 적과 부딪히면 hit 또는 overlap 이벤트 발생. (무기 자체를 or 무기에 소켓을 붙여서)
- 칼 경로 따라서 Shape Sweep (틈이 적어서 정밀. 쉽게 두께 반영 가능)
- 칼 경로 따라서 Line Trace 연결 (Shape Sweep보다 비용이 쌈)
기획에 따라 고려해 볼 내용입니다.
- 판정 기대 수준 (정확한 궤적, 프레임 드랍 시 정밀도 등)
- 공격 속도
- 칼 두께
저 같은 경우엔 라인 트레이스를 사용하여 타격 판정을 했습니다.
- 오버랩으로 판정하면, 빠른 공격 시 프레임과 프레임 사이 지나치는 공간 발생 가능
- Shape Sweep 방식은 Sweep 중 회전이 불가능하다는 단점
- 카타나는 얇은 두께의 칼로, 두께를 고려하지 않아도 되기에 적은 수의 Line Trace로 판정 가능
애님 노티파이 스테이트를 사용해서, 칼을 휘두르는 동안에만 타격 판정을 할 수 있게 했습니다. 다른 캐릭터나 무기에서도 재사용할 수 있게, 프로젝트 기본 캐릭터의 HitCheck 함수를 호출하게 설계했습니다.
- NotifyBegin : Character->PreAttackHitCheck()
- NotifyTick : Character->AttackHitCheck()
- NotifyEnd : Character->PostAttackHitCheck()
칼에 소켓을 세 개 붙여서, 그 소켓 위치를 사용해 Line Trace 했습니다.
판정을 촘촘하게 하기 위해서 지그재그 모양으로 쐈습니다.
지그재그 트레이스
그러나, 공격 속도가 빠르거나 초당 프레임 수가 떨어질 때 프레임 사이 칼 간격이 멀어져 판정이 부정확해지는 문제가 있었습니다.
공격 속도 빠를 때
옵션 플래그(bStepTrace)와 최소 간격 프로퍼티를 넣어, 원한다면 칼 판정 최소 간격을 설정할 수 있게 해 문제를 해결했습니다.
(아래 동영상과 차이가 있는 이유는 개발 후반에 Line을 하나 추가했기 때문입니다.)
스텝 트레이스
StepTrace로 판정을 촘촘하게 만들었지만, 그저 판정 사이 간격이 넓을 시 일정 간격으로 나눠 판정을 진행하는 방식이라 궤적이 둥글지 않고 각지는 문제가 발생했습니다.
아직 해결하지 못했습니다.
해결했습니다.
일명 MetaTrace
MetaTrace (메타데이터 사용)
Tick과 Tick 사이의 소켓의 월드 위치를 알아낼 수 있다면, 판정 궤적을 보완할 수 있을 것이라 생각했습니다.
StepTrace처럼 LineTrace의 시작점과 끝점을 추가하는 것은 같은데, StepTrace와 다르게 칼의 궤적 위에 있는 점으로 추가를 해야 궤적을 살리면서 판정을 촘촘하게 만들 수 있습니다.
그래서 칼의 궤적, 즉 타격 판정을 위해 칼에 붙인 Socket의 위치를 원하는 어떤 시점이든 알아내야 했습니다. (StepTrace는 틱마다 엔진이 계산해서 주는 값만 사용)
칼 위 Socket의 위치를 구하는 과정을 나열해 봤습니다.
캐릭터의 Transform(1번)과 애니메이션에 의해 바뀌는 Bone Transform(3번)만 알면, 소켓의 월드 위치를 구할 수 있었습니다. (나머지는 고정)
캐릭터의 Transform은 완전히 런타임에 결정되기 때문에 보간해서 사용하고, Bone Transform은 애니메이션에 의해 바뀌는 것이기에, 미리 애니메이션에서 추출해 두고 사용할 수 있습니다.
그래서 칼이 부착되는 Bone Space에서 Skeletal Component으로의 변환을 메타데이터로 추출하기로 했습니다.
그리고 그때의 시간을 알기 위해 시간 배열도 넣기로 했습니다.
그리고 추출하기 쉽게 에디터 툴을 만들기로 했습니다.
메타데이터 추출
생각한 추출 방법은
- 에디터 환경에서 레벨에 Skeletal Mesh Component를 스폰하고,
- Skeletal Mesh를 입히고,
- Animation을 설정하고,
- 설정한 주기씩 애니메이션을 움직이면서,
- 메타데이터를 추출하는 것입니다.
( 쉽게 생각한 것만큼 쉽게 구현되진 않았습니다 )
처음에 Anim Sequence와 Anim Montage 중 어떤 단위로 메타데이터를 추출하는 것이 좋을지 고민했습니다.
Anim Sequence별로 메타데이터를 두는 것이 더 분할되어 좋다고 생각했습니다.
성능 측면에서는,
시퀀스 단위의 경우 : 몽타주에서 현재 재생 시간을 사용해서 현재 재생중인 시퀀스를 찾는 것은 고작 몽타주 속 시퀀스 개수만큼만 for문을 돌리면 됩니다.
몽타주 단위의 경우 : 메타데이터 인덱스가 배로 늘어나지만, 시간 데이터가 정렬되어 있으니 이진탐색을 사용하면 최악의 경우에도 시퀀스 단위보다 계산이 횟수가 적습니다.
시퀀스 개수가 많지 않기 때문에 둘 사이에 성능 차이는 크게 없다고 판단했습니다.
그래서 Anim Sequence 단위로 메타데이터를 추출하는 것을 구현했고, 성공했습니다.
(이후 몽타주 단위로도 추출하고 추가로 깨달은 것은, 몽타주 단위로 하면 메타데이터 크기가 커져서 에디터에서 다룰 때 렉이 걸려 불편합니다.)
몽타주 단위로 메타데이터를 추출하는 것도 해 보고 싶어서 해 봤습니다.
몽타주는 애님 블루프린트의 슬롯이 없으면 재생이 안 된다고 알고 있었기에 시퀀스랑 다르게 캐릭터도 스폰하고 애님 블루프린트도 적용해 줬습니다.
그럼에도 Anim Sequence로 추출했을 때의 로직을 사용했는데, 모든 변환 값이 똑같이 나오는 문제가 발생했습니다.
TickAnimation()을 호출했는데도 불구하고, Transform 계산 로직이 진행이 안 된 것 같아서
TickAnimation() 내부 함수들 외에, 애님 인스턴스나 몽타주만의 Bone Transform 값을 업데이트하는 다른 로직이 있는지 찾아봤지만 찾지 못 했습니다.
이 문제의 원인과 최종 해결법은…
TickAnimation()에 DeltaTime으로 0.f를 넣어준 것이 문제였습니다. 어쩌다 혹시몰라 다른 값을 넣어줬더니 해결됐습니다.
이미 애니메이션 시간은 직접 설정했으니, 내부 로직만 돌리면 된다고 생각해서 0.f를 넣었는데… 엔진 코드를 뜯어보지는 않았지만 0이면 스킵하는 것 같습니다.
Tick에서 관련 로직들이 다 실행이 될 것이기 때문에, 그냥 몽타주를 Pause하거나 재생 속도를 0으로 하고 Tick을 돌리면 되는 것이었습니다.
TickAnimation() 함수에 DeltaTime을 넣어도, 몽타주는 Pause 혹은 재생 속도가 0이라서 문제 없기 때문에 Tick 로직들을 호출하는 용도로 사용할 수 있는 것입니다.
TickAnimation()을 호출하면서 생긴 추가적인 문제가 있었는데, 노티파이가 호출되면서 크래시가 나는 것입니다.
이를 해결하기 위해,
애니메이션 사이에 메타데이터를 Copy 할 수 있는 기능을 만들어서
노티파이가 있는 경우에는, 노티파이 없는 버전으로 추출해서 노티파이 있는 버전으로 복사할 수 있게 만들었습니다.
에디터 툴
코드를 모르는 팀원도 쉽게 추출하고, 빠르게 추출해서 실험할 수 있게 구현한 추출 함수를 호출하는 에디터 툴을 만들기로 했습니다.
에디터 유틸리티 위젯을 사용해서 쉽게 만들 수 있었습니다.
시퀀스나 몽타주를 선택하고, Skeletal Mesh를 선택하고, Bone 이름을 입력하고 추출하면 됩니다.
추가로, 위에 적어둔 메타데이터 복사 기능도 넣어뒀습니다.
추출 결과물입니다.
밑에는 설명 없는 시연 영상입니다.
최종 결과
추출한 메타데이터를 사용해서 간단하게 궤적 보정을 해 보니,
(파란 디버그 점이 메타데이터를 사용해서 얻은 점입니다.)
일반적인 공격 속도에서는 아무런 문제가 없는데,
빠른 공격에서는 첫 부분이 궤적이 뭔가 이상해 졌습니다.
더 편하게 문제를 찾기 위해서 Tick 때 계산되는 소켓 위치들도 찍어보니, 메타데이터 문제가 아님을 쉽게 알 수 있었습니다.
이건 Blend 때문이었습니다. 실제 게임에서는 애니메이션이 Blend 되면서 칼의 궤적이 변하는 것이었습니다.
블렌드를 아예 없애거나, 혹은 이건 부자연스러울 수 있으니 블렌드 시간을 좀 줄이거나 Exponential Out과 같이 초반에 빠르게 Blend하는 Curve를 사용하는 것으로 해결이 가능했습니다.
최최종 결과
일반 속도
재생 속도 2배
죄종적으로, Tick 사이에 칼이 얼마나 이동하든지 상관없이 설정한 값보다 많이 이동하면 그 사이에 칼의 궤적 위의 점을 추가해서
궤적을 유지하면서 판정을 촘촘하게 할 수 있게 됐습니다.
성능 프로파일링
Unreal Insights로 각 Trace 방식들의 성능을 비교해 봤습니다.
한 번의 ZigzagTrace에 소요되는 시간과 하나의 Trace를 두 개의 Trace로 나누는 경우 얼마나 더 소요되는지 궁금했습니다.
한 번의 지그재그 트레이스는 8~12us가 걸렸습니다.
Step Trace는 지그재그 시간 * Step 횟수가 걸렸습니다.
Meta Trace도 Step Trace와 크게 다르지 않았습니다. (아주 살짝 더 걸리는 정도)
결론적으로, 성능에 큰 문제가 없었습니다.
콤보 구현
애님 몽타주와 애니메이션 노티파이를 사용해서 구현했습니다.
플레이어 경험을 위해 클라이언트에서 애니메이션 선 재생 후 서버가 검증해 주는 방식을 썼습니다.
처음엔, 특정 타이밍에 키를 누르고 있어야 콤보가 진행되어야 한다는 기획에 맞춰서, 키를 누른 상태를 bool 값으로 저장하고 노티파이 시 이를 사용해 콤보 진행 유무를 결정했습니다.
그러나 이 방식은 멀티플레이어 구현 측면에서 문제가 있었습니다.
- 마우스를 눌렀다 뗄 때마다 마우스 클릭 유무를 변경하는 서버 RPC를 보내야한다.
- 특정 타이밍에 검사하기 때문에 네트워크 지연이 조금만 발생해도 클라이언트와 서버 사이 콤보 진행 판단이 달라져서 클라이언트 애니메이션을 강제로 변경(동기화)해 줘야 하는 경우가 많이 발생한다. 이는 플레이어 경험에 좋지 않다.
콤보 공격하는 재미가 없다.
해당 단점을 해결하기 위해 기획자와 의논하여 콤보 판정 방식을 일정 구간에 한 번이라도 누르면 콤보가 진행되는 것으로 변경했습니다.
생각한 방법들
- 클라이언트에서 콤보 판정 결과만 서버로 보낸다. (1번은 해결, 그러나 2, 3번은 미해결)
- NotifyState를 사용해서, Notify Begin에 커맨드 입력을 활성화하고 Notify End에 커맨드 입력이 있었으면 다음 콤보를 진행하게 구현했습니다. (1, 3번 해결, 2번 미해결)
- 콤보 커맨드 인풋을 받는 구간을 뜻하는 NotifyState와 콤보 진행을 유무를 체크하는 Notify를 따로 둔다. 그리고 이 둘 사이에 간격을 둔다. (1, 2, 3번 모두 해결)
[최종 결과물]
네트워크 지연 상황 에뮬레이션 (멀티플레이어 구현 과정)
이게 제 첫 멀티플레이어 게임 개발이었습니다. 개발 기간에 롤 하다가 핑이 튀는 순간에 하나의 문제를 깨달았습니다.
PIE 환경에서는 네트워크 지연이 없어서 아무런 문제가 없었지만, 문득 네트워크 지연이 생겼을 때를 생각해 보니 예상되는 문제들이 많았습니다. (위에 적은 콤보 관련 문제들도 이때 깨달았습니다.)
이를 직접 확인해 보기 위해 네트워크 에뮬레이션을 사용해 트래픽 지연, 패킷 손실 상황을 에뮬레이션 했습니다.
클라이언트에서 ServerRPC를 보내서, 서버에서 이를 검증하고 다시 ClientRPC (or Multicast)로 애니메이션을 재생하게 하면 딜레이가 생겨 플레이어 경험에 좋지 않습니다.
그래서 타격 판정은 서버에서 하고,
애니메이션 재생과 콤보 체크는 클라이언트와 서버 둘 다 진행하기로 했습니다.
클라이언트에서 애니메이션을 먼저 재생하고, ServerRPC, MulticastRPC를 사용해 서버와 나머지 클라이언트에도 애니메이션을 재생해 줬습니다. (MulticastRPC 말고 커스텀해서 SimulatedProxy한테만 RPC 보내게 하는 게 좋을 듯합니다.)
콤보 커맨드 입력은 클라이언트에서 받아서, 서버로 전달해 줬습니다.
클라이언트에서는 바로 처리가 되니 bool 값으로 관리했지만, 서버에서는 int 값으로 관리했습니다.(전송받은 커맨드 횟수)
만약 짧은 간격으로 콤보 판정을 하는 경우, 서버에서 이전 콤보 커맨드를 판정하기 전에 콤보 커맨드가 하나 더 들어올 수도 있기 때문입니다.
검증 및 동기화
클라이언트에서도 직접 콤보 커맨드를 판정해서 다음 콤보 애니메이션을 재생했습니다.
만약 서버에서 콤보 진행이 끊겼는데 클라이언트에서 계속 진행 중이라면 클라이언트 애니메이션 시간을 서버 애니메이션 시간으로 맞춰주기로 했습니다.
하지만, 애니메이션 시간을 강제로 맞춰주는 것은 플레이어 경험상 좋지 않을 것입니다.
NotifyState를 사용해서 Notify Begin에 커맨드 입력을 활성화하고 Notify End까지 커맨드 입력이 있었으면 다음 콤보를 진행하는 현재 방식은,
네트워크 지연이 처음 애니메이션을 재생할 때보다 조금이라도 늘어나면,
Notify End가 거의 다 됐을 때 커맨드를 입력하게 되면, 클라이언트에서는 커맨드 입력을 해서 콤보가 진행되지만, 서버에서는 Notify End가 끝나고 커맨드 입력 RPC를 받게 되어 콤보가 중단될 것입니다.
이러한 상황을 줄이기 위해, 콤보 커맨드 인풋을 받는 구간을 뜻하는 NotifyState와 콤보 진행을 유무를 체크하는 Notify를 따로 두고 그 사이 간격을 줬습니다.
정리
만약 플레이어에서 콤보 진행했지만, 서버에 패킷이 늦게 도착해서 서버에서는 콤보가 멈췄을 경우에
- 플레이어의 콤보 진행이 끝나고, 애니메이션이 끝나는 중이라면 그대로 둔다.
- 콤보가 아직 진행 중이면, 더 이상 콤보를 진행하지 못하게 하고 서버와 애니메이션 시간을 동기화한다.
- 이런 상황이 발생하는 것 자체를 줄이기 위해 콤보 입력 기간과 콤보 판정 시간을 띄어놓는다.
리슨 서버 노티파이 무시되는 문제
네트워크 에뮬레이션을 했더니, 서버에서 Notify가 처리되지 않는 문제가 있었습니다.
이는 RootMotion을 하는 중에 AnimEvents를 무시하기 때문이었습니다.
void UDSCharacterMovementComponent::ServerMove_PerformMovement(const FCharacterNetworkMoveData& MoveData)
{
Super::ServerMove_PerformMovement(MoveData);
if (CharacterOwner && !CharacterOwner->bClientUpdating && CharacterOwner->IsPlayingRootMotion() && CharacterOwner->GetMesh())
{
CharacterOwner->GetMesh()->ConditionallyDispatchQueuedAnimEvents();
}
}
MovementComponent의 ServerMove_PerformMovement()를 오버라이드해서 RootMotion 중에 AnimEvents를 처리하게 하는 방법으로 해결됐습니다.
Leave a comment