2022. 10. 6. 04:40ㆍProjects/Charon
저번 글에서 확장성을 고려하여 무기 시스템의 기반을 만들었었습니다. 이제는 진짜 무기의 공격 구현을 해볼 차례네요.
공격 키를 입력 받아야 하니 새로운 입력 키 바인딩이 필요할 것이고, 단순히 눌렀을 때냐 아니면 누르고 유지하고 있을 때냐 등등의 상호작용도 추가해줘야 할 겁니다. 우선 입력 처리부터 먼저 해보도록 하겠습니다.
1. 공격 키 바인딩 및 입력 분기 처리하기
New Input System의 "상호 작용(Interaction)"
공격은 마우스 좌 클릭으로 입력을 받습니다. Player Input Actions에 들어가서 해당 키 바인딩을 해주도록 하죠.
왼쪽 마우스 버튼을 바인딩 해주었습니다. 그런데 Interactions에 Hold와 Press가 추가한 걸 볼 수 있는데요.
상호작용(Interaction)은 해당 키 입력에 대해서 어떤 방식으로 처리할 것인지에 대해 설정해줄 수 있는 옵션입니다. 상호 작용은 추가한 순서대로 우선 순위를 가지게 됩니다. 위의 사진을 예로 든다면, Hold → Press 순서대로 우선권을 가질 겁니다.
저희 게임의 무기 공격 종류에는 차징 공격(Charging Attack)이 있습니다. 그렇기 때문에 마우스 버튼을 일정 시간 이상 누르고 있는지를 먼저 검사해야 하죠. 따라서 Hold 상호 작용을 먼저 검사하는 것이며, 만약 Hold가 실패하게 되면 다음 우선순위 상호작용인 Press가 발동될 권한을 얻게 될 겁니다.
일반 공격의 경우에는 마우스 클릭하고 뗐을 때 이벤트가 발생하면 되니, Press를 추가하고 Trigger Behavior를 Release Only로 해주었습니다. 설명이 길어졌지만, 내용을 요약하면 다음과 같습니다.
- 바인딩한 키에 대하여 입력을 어떤 방식으로 처리할 지에 대한 옵션을 줄 수 있으며, 이걸 상호작용(Interaction)이라고 한다.
- 상호작용은 추가한 순서대로 우선 순위를 가지게 된다.
- 현재 상호작용이 성공이 되었다면, 그 다음 우선 순위 상호작용은 권한을 얻지 못한다.
- ex) 차징 공격을 하려고 마우스 버튼을 누르고 있어, Hold 상호 작용이 성공했다면 그 다음인 Press 상호 작용은 실행되지 않는다.
- 현재 상호작용이 실패했다면, 그 다음 우선 순위의 상호 작용이 권한을 얻어 실행된다.
- ex) 일반 공격을 위해 마우스 버튼을 클릭하고 뗐다면, Hold 상호 작용을 실패한 것이므로 그 다음인 Press 상호 작용이 실행된다.
이러한 내용을 숙지한 채로, 이제 마우스 왼쪽 버튼에 대한 이벤트 콜백 함수를 정의하여 등록해주도록 합시다.
// PlayerController
public void OnClickLeftMouse(InputAction.CallbackContext context)
{
...
}
이제 마우스 왼쪽 입력을 받을 수 있게 되었습니다. 이제 함수 내에서 어떤 상호작용이 들어왔는지를 검사하여 분기 처리 하면 되겠네요.
상호작용 검사하여 분기 처리하기
상호 작용 입력 콜백에는 대표적으로 다음과 같이 네 가지가 있습니다.
- Waiting : 상호 작용이 입력을 기다리고 있는 상태
- Started : 상호 작용이 시작되었지만(예상 입력 중 일부를 수신받음), 아직 완료는 안 된 상태
- Performed : 상호 작용이 완전히 다 이루어진 상태
- Canceled : 상호 작용이 이루어 지는 과정 중 조건을 충족하지 못해 중단된 상태
- 예를 들어, 버튼을 눌렀다가 놓는데 필요한 최소 시간이 있는데 그 시간 전에 사용자가 버튼에 손을 뗀 경우가 있습니다.
이 콜백 정보를 이용하여 다음과 같이 코드를 짜서, 차징 공격일 때와 일반 공격일 때를 분리하였습니다.
public void OnClickLeftMouse(InputAction.CallbackContext context)
{
if (context.performed)
{
if (context.interaction is HoldInteraction) // 차지 공격
{
}
else if (context.interaction is PressInteraction) // 일반 공격
{
}
}
}
마우스 왼쪽 버튼을 눌렀다면 가장 먼저 Hold Interaction이 실행되겠지요. 플레이어가 차징 공격을 하기 위해 마우스 왼쪽 버튼을 계속 누르고 있는다면, 해당 상호 작용은 성공으로 간주되어 Performed 콜백을 호출해줄 겁니다.
플레이어가 일반 공격을 하기 위해 마우스 왼쪽 버튼을 눌렀다가 뗀다면, Hold Interaction의 콜백은 Canceled가 호출 될 겁니다. 그리고 나서 다음 상호 작용인 Press Interaction이 실행되겠지요. 눌렀다 뗐으니 성공했으므로 Performed가 호출될 겁니다.
이런 콜백 과정을 생각하여 위와 같이 코드를 작성하여 분리해주었습니다. 이제 입력 받는 것은 처리해놨으니, 마우스 월드 좌표를 얻어오는 걸 해보도록 합시다.
2. 마우스 월드 좌표 얻어오기
마우스의 좌표는 다음 코드를 통해 얻어올 수 있습니다.
Mouse.current.position.ReadValue(); // 스크린 좌표
하지만 위의 코드를 통해 얻어오는 것은 스크린 좌표계의 마우스 좌표입니다. 모니터 스크린은 2차원으로 되어 있기 때문에 x와 y 값만 존재하고 z값은 항상 0입니다.
하지만 우리 캐릭터는 스크린 좌표계에 존재하는 것이 아니라 게임 월드 좌표계에 존재합니다. 그렇다면 스크린 좌표계의 마우스 좌표를 통해 어떻게 월드 좌표를 얻을 수 있을까요?
마우스를 클릭한 지점으로부터 Raycast를 쏘면 됩니다.
즉, 게임 월드 내에 카메라로부터 Raycast가 뻗어 나와 실제 게임 월드 내의 오브젝트에 충돌하는 것이지요.
우리가 마우스로 클릭하여 공격을 한다면, 캐릭터는 해당 클릭한 방향을 바라봐야 합니다. Raycast와 충돌한 지점으로부터 현재 캐릭터의 좌표를 빼서, 해당 방향으로의 벡터를 얻어내도록 합시다.
// PlayerController
protected Vector3 GetMouseWorldPosition()
{
Vector3 mousePosition = Mouse.current.position.ReadValue();
Ray ray = Camera.main.ScreenPointToRay(mousePosition);
Debug.DrawRay(ray.origin, ray.direction * 1000f, Color.red, 5f); // Raycast 시각화
if (Physics.Raycast(ray, out RaycastHit HitInfo, Mathf.Infinity))
{
Vector3 target = HitInfo.point;
Vector3 myPosition = new Vector3(transform.position.x, 0f, transform.position.z);
target.Set(target.x, 0f, target.z);
return (target - myPosition).normalized;
}
return Vector3.zero;
}
이제 이 메소드를 통해 마우스 월드 좌표를 계산해 저장해두도록 합시다.
// PlayerController
public Vector3 MouseDirection { get; private set; }
public void OnClickLeftMouse(InputAction.CallbackContext context)
{
if (context.performed)
{
MouseDirection = GetMouseWorldPosition();
LookAt(MouseDirection);
if (context.interaction is HoldInteraction) // 차지 공격
{
}
else if (context.interaction is PressInteraction) // 일반 공격
{
}
}
}
클릭한 방향으로 캐릭터가 잘 보는지 테스트 해봅시다.
이제 공격 상태(Attack State)를 정의하여, 상태 머신(StateMachine)에 등록해봅시다.
3. 공격 상태(Attack State) 추가하여 등록하기
우선 일반 공격 상태를 구현해보도록 하죠. 다음과 같이 AttackState 클래스를 정의해 주었습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace CharacterController
{
public class AttackState : BaseState
{
public static bool IsAttack = false;
public const float CanReInputTime = 1f;
public AttackState(PlayerController controller) : base(controller) { }
public override void OnEnterState()
{
IsAttack = true;
Player.Instance.weaponManager.Weapon?.Attack(this);
}
public override void OnExitState()
{
}
public override void OnFixedUpdateState()
{
}
public override void OnUpdateState()
{
}
}
}
WeaponManager가 현재 들고 있는 무기의 Attack() 메소드를 실행하는 것을 볼 수 있습니다. 추상 메소드로 정의했으니, 들고 있는 무기에 따라 제 각각 다른 공격이 나가겠지요. 그런데, 무기 시스템을 만들 때 생각해야 하는 게 한 두가지가 아니더군요.
- 무기마다 공격 애니메이션이 다를텐데, 이걸 어떻게 무기마다 다른 애니메이션이 발동되도록 관리할 수 있을까?
- 공격 애니메이션마다 길이가 다를 것이고, 또한 공격 속도에 따라 더 빠르거나 더 느리게 재생될 수도 있을텐데 해당 공격 모션이 끝났다는 것을 어떻게 알 수 있을까?
- 공격 키 입력을 받고 일정 시간 내에 입력이 들어왔는지 체크를 해야 하는데, 어떤 방법을 써서 할 것인가?
하나씩 한 번 알아보도록 합시다.
4. 공격 애니메이션
Weapon Animation Layer 생성하고, 기본 공격 애니메이션 연결하기
손돌의 애니메이터에서 새로운 애니메이션 레이어를 생성하고, Weapon Layer라고 이름 지어 주었습니다.
그리고 가중치(Weight) 값을 1로 주었죠. 이렇게 되면 Weapon Layer 또한 Base Layer와 함께 같이 재생된다는 걸 말합니다. 그리고 각 공격 애니메이션들을 배치해 주었습니다.
그냥 Base Layer에서 하면 되지, 왜 이렇게 Layer를 나누어서 하냐고 생각이 들 수도 있는데, 이것은 나중에 무기별로 다른 애니메이션을 재생해야 하는 부분을 해결해 줄 겁니다.
Empty 상태에는 할당된 애니메이션이 없기 때문에 말 그대로 아무 행동도 하지 않습니다. 그러다가 특정 조건에 따라 트랜지션이 일어나면 해당 애니메이션을 재생하겠지요. 그리고 애니메이션 매개변수로 다음과 같이 세 개를 선언해 주었습니다.
AttackCombo 변수는 현재 몇 번째 콤보 공격인지를 나타냅니다.
AttackSpeed는 현재 내 무기의 공격 속도에 따라 애니메이션 재생 속도를 다르게 해줄 변수입니다.
IsAttack 변수는 좀 더 매끄럽고 정확한 트랜지션을 위해 만들어 준 변수입니다.
3타 애니메이션 각각은 AttackSpeed 값을 재생 속도에 곱하도록 만들어 주었습니다.
이제 무기 공격 속도 값을 읽어와서 AttackSpeed 변수에 저장한다면, 무기의 공격 속도 값에 따라 애니메이션 재생 속도가 유동적으로 달라질 겁니다. 아무튼 이런 식으로 세팅해주고, 각 애니메이션 간 트랜지션 조건도 세팅해주도록 합니다.
*세팅 값은 더보기에 적어 두었습니다.
애니메이션 이벤트 함수 등록하기
애니메이션에서 특정 키 프레임에 이벤트 함수를 등록할 수 있습니다. 이걸 이용하여 공격 모션이 끝났다고 초기화 해주는 이벤트 함수를 호출해줄 겁니다. 모션이 거의 끝나갈 때 쯤에 등록해줍시다.
그리고 SondolAnimationEvents라는 스크립트를 만들어 손돌에게 달아주었습니다. 정적 변수들로만 접근을 하기 때문에, 사실상 손돌이 아니라 다른 빈 오브젝트에 달고 애니메이션 이벤트만 관리하는 애를 만들어도 괜찮겠네요.
using CharacterController;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SondolAnimationEvents : MonoBehaviour
{
public void OnFinishedAttack()
{
AttackState.IsAttack = false;
Player.Instance.animator.SetBool("IsAttack", false);
Player.Instance.stateMachine.ChangeState(StateName.MOVE);
}
}
그리고 이전에 등록해 둔 애니메이션 이벤트에 위 함수를 등록해주면 됩니다.
이렇게 하면 애니메이션 재생 속도가 달라도, 모션이 끝난 지점을 정확하게 파악할 수 있을 겁니다.
공격 모션이 끝났을 때, 공격과 관련된 변수들을 초기화해주는 것이지요.
이제 마지막으로 기본 무기인 카론의 노(CharonPaddle) 클래스를 만들어서 공격 로직을 구현해봅시다.
5. 카론의 노(Charon's Paddle) 무기 클래스 만들기
이전에 만들었던 BaseWeapon 추상 클래스를 상속 받아 추상 메소드들을 구현해주면 됩니다.
using CharacterController;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CharonPaddle : BaseWeapon
{
public readonly int hashIsAttackAnimation = Animator.StringToHash("IsAttack");
public readonly int hashAttackAnimation = Animator.StringToHash("AttackCombo");
public readonly int hashAttackSpeedAnimation = Animator.StringToHash("AttackSpeed");
private Coroutine checkAttackReInputCor;
public override void Attack(BaseState state)
{
ComboCount++;
Player.Instance.animator.SetFloat(hashAttackSpeedAnimation, AttackSpeed);
Player.Instance.animator.SetBool(hashIsAttackAnimation, true);
Player.Instance.animator.SetInteger(hashAttackAnimation, ComboCount);
CheckAttackReInput(AttackState.CanReInputTime);
}
public override void ChargingAttack(BaseState state)
{
}
public override void DashAttack(BaseState state)
{
}
public override void Skill(BaseState state)
{
}
public override void UltimateSkill(BaseState state)
{
}
public void CheckAttackReInput(float reInputTime)
{
if (checkAttackReInputCor != null)
StopCoroutine(checkAttackReInputCor);
checkAttackReInputCor = StartCoroutine(CheckAttackReInputCoroutine(reInputTime));
}
private IEnumerator CheckAttackReInputCoroutine(float reInputTime)
{
float currentTime = 0f;
while (true)
{
currentTime += Time.deltaTime;
if (currentTime >= reInputTime)
break;
yield return null;
}
ComboCount = 0;
Player.Instance.animator.SetInteger(hashAttackAnimation, 0);
}
}
기본 공격은 콤보 카운트 수를 늘리면서 해당 애니메이션 매개변수를 업데이트하면서 재생해주는 것이구요. 또 하나의 핵심은 기본 공격 후, 일정 시간 내에 다시 공격을 하지 않으면 콤보로 이어지지 않고 처음 공격으로 초기화 된다는 점입니다.
그래서 그 시간을 체크하는 용도로 코루틴(Coroutine)을 사용하였습니다. 만약 일정 시간 내에 입력이 다시 들어왔다면, 현재 코루틴을 중단하고 새로 시작하는 것이죠. 일정 시간이 지나버렸다면, 콤보 카운트와 애니메이션 변수를 초기화 해줍니다.
여담으로, 코루틴이 중지가 되지 않길래 왜 안 되나 하다가 기존 코루틴은 냅두고, 새로 할당하여 그걸 중지하고 있었습니다... 즉, 다음과 같이 하고 있었단 소리지요.
public void CheckAttackReInput(float reInputTime)
{
if (checkAttackReInputCor != null)
StopCoroutine(CheckAttackReInputCoroutine(reInputTime));
StartCoroutine(CheckAttackReInputCoroutine(reInputTime));
}
여러분들은 이런 실수 하지 마세요... 이것 때문에 30분 날렸습니다. 이제 마지막으로 두 가지 작업만 남았습니다.
6. Animator Override Controller 만들기
위에서 애니메이션 세팅하고 매개변수 만들어 주고 했던 우리 손돌의 Animator를 우클릭하여, Animator Override Controller를 만들어 주도록 합시다.
그리고 카론의 노 무기에 해당하는 애니메이션들을 할당하여 사용할 것이기 때문에 "CharonPaddleController"라고 이름 짓겠습니다. 손돌의 애니메이터 환경을 그대로 가져올 것이므로 Controller에 손돌 애니메이터를 연결해줍니다.
Animator Override Controller란 말 그대로 선택한 Animator의 Layer, 매개변수, 상태(State)등의 환경을 그대로 가져오지만, Override란 말 답게 다형성을 적용하여 사용할 수 있는 것을 말합니다.
무기가 3가지 종류가 있다고 가정하고, 그 무기들 모두 3타 공격이 있는 것은 똑같지만 제 각각 모션은 다르다고 해보면, 이런 방식을 통해 애니메이션만 변경하여 효율적으로 해당 무기 애니메이션들을 관리할 수 있는 것이죠. 그리고 무기를 교체할 때 애니메이터도 같이 교체해주면, 별 다른 세팅없이 무기마다 서로 다른 애니메이션 재생을 구현할 수 있게 됩니다.
BaseWeapon에 RuntimeAnimatorController라는 변수가 있던 걸 기억하시나요? 바로 이 용도를 위해 넣어놨습니다.
using CharacterController;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class BaseWeapon : MonoBehaviour
{
public int ComboCount { get; set; }
public WeaponHandleData HandleData { get { return weaponhandleData; } }
// 이 무기를 사용할 때의 애니메이터
public RuntimeAnimatorController WeaponAnimator { get { return weaponAnimator; } }
...
}
무기를 교체할 때마다, 손돌 Animator에다가 해당 무기의 RuntimeAnimatorController를 넣어주면 됩니다.
이 부분은 WeaponManager의 SetWeapon()에 이미 구현해 놨었습니다.
// 무기 변경
public void SetWeapon(GameObject weapon)
{
if (Weapon == null)
{
weaponObject = weapon;
Weapon = weapon.GetComponent<BaseWeapon>();
weaponObject.SetActive(true);
/// 애니메이터 변경
Player.Instance.animator.runtimeAnimatorController = Weapon.WeaponAnimator;
return;
}
for(int i = 0; i < weapons.Count; i++)
{
if (weapons[i].Equals(Weapon))
{
weaponObject = weapon;
weaponObject.SetActive(true);
Weapon = weapon.GetComponent<BaseWeapon>();
/// 애니메이터 변경
Player.Instance.animator.runtimeAnimatorController = Weapon.WeaponAnimator;
continue;
}
weapons[i].SetActive(false);
}
}
이제 카론의 노 무기에다가 위에서 생성했던 "CharonPaddleController" 애니메이터를 넣어두도록 합시다.
현재는 무기가 카론의 노 밖에 없기 때문에 Override Controller의 애니메이션들을 다른 걸로 교체할 필요가 없습니다.
나중에 무기가 추가로 생기게 되면, 애니메이션 클립들만 교체해주고 해당 애니메이터를 연결해주면 되겠지요.
이제 진짜 마지막으로, 입력 처리 부분에서 공격 상태로 전환하는 코드만 넣어주면 됩니다.
7. 공격 상태 전환 코드 입력하기
현재 공격 중이 아니고, 콤보 카운트가 3 미만일 때만 공격 상태로 전환이 일어나도록 해주면 됩니다.
// PlayerController
public void OnClickLeftMouse(InputAction.CallbackContext context)
{
if (context.performed)
{
MouseDirection = GetMouseWorldPosition();
if (context.interaction is HoldInteraction) // 차지 공격
{
/// 차지 공격 상태 전환
}
else if (context.interaction is PressInteraction) // 일반 공격
{
if (DashState.IsDash) // 일단은 막아놨음
{
/// 대시 공격 상태전환
return;
}
bool isAvailableAttack = !AttackState.IsAttack &&
(player.weaponManager.Weapon.ComboCount < 3);
if (isAvailableAttack)
{
LookAt(MouseDirection);
player.stateMachine.ChangeState(StateName.ATTACK);
}
}
}
}
이제 진짜로 모든 게 끝났습니다. 정말 길었네요. 무기 시스템 하나 구현하는데, 이렇게 많은 것들을 생각해야 할 줄은 몰랐습니다. 추후 확장을 편하게 하기 위해, 기반을 잘 다지는 것을 중요한 일이니 고민을 많이 했었습니다.
테스트 한 번 해보고 마치면 되겠네요.
나중에 선 입력을 받을 수 있도록 적용하면 좋을 것 같습니다. 왜냐하면 애니메이션 재생 시간이 콤보 재입력 기준 시간보다 길게 되면, 다음 콤보로 갈 수가 없거든요. 이 부분은 나중에 한 번 고민해서 적용해 보겠습니다.
오늘 글이 굉장히 길었는데, 읽어주셔서 감사합니다!
'Projects > Charon' 카테고리의 다른 글
[Charon] #9. 대시(Dash) 선입력(Input Buffer) 기능 추가하기 (0) | 2022.11.16 |
---|---|
[Charon] #8. 플레이어와 카메라 사이의 오브젝트 투명화하기 (6) | 2022.11.16 |
[Charon] #6. 휴머노이드 애니메이션 수정과 무기 탈장착 시스템 기반 만들기 (1) | 2022.10.05 |
[Charon] #5. 상태 패턴(State Pattern) 도입하기 (4) | 2022.10.04 |
[Charon] #4. RigidBody를 이용한 3D 캐릭터 N단 대시(Dash) 구현하기 (4) | 2022.09.21 |