오늘은 플레이어 공격 구현과 체력, 스태미나 시스템을 구현해 볼 계획이다.
먼저 어제 점프 구현까지는 완료가 되었는데 직접 실행을 해보니 이상한 오류가 하나 발생하게 되었다.
※ 버그리포트
점프를 한 상태에서 캐릭터가 idle 애니메이션으로 돌아오지 않는다.
아마 상태는 잘 출력이 되지만 애니메이션 문제인 것 같았다. 그래서 점프상태를 담당하는 PlayerJumpState와
PlayerFallState 스크립트를 살펴보았는데 실수로 잘못 작성한 곳이 두 군데가 발견되었다.
Enter에서 StartAnimation이 작동이 되고 Exit에서는 StopAnimation이 작동되어야 하는데 코드를 복사해서 붙여 넣다 보니 실수로 스크립트가 잘못 작성된 것을 확인할 수 있었다. 그래도 플레이어의 상태를 보고 어느 부분이 잘못돼서 이러한 오류가 발생했는지 스스로 찾아낼 수 있는 것을 보니 어느 정도 스테이트 머신 구조에 대해 파악한 것 같다.
그리고 공중상태에서 떨어지면 idle상태 그대로 추락하는 부분도 어색하기 때문에
FallState로 바꿔주기 위해서 GroundState를 수정한다.
PlayerGroundState 수정
public override void PhysicsUpdate()
{
base.PhysicsUpdate();
// Player가 땅에 있지않고 떨어지고 있는 상태라면 FallState를 적용
// 중력을 한번에 받는 양이 더 크다(떨어지는 상태를 의미)
if(!stateMachine.Player.Controller.isGrounded
&& stateMachine.Player.Controller.velocity.y < Physics.gravity.y * Time.fixedDeltaTime)
{
stateMachine.ChangeState(stateMachine.FallState);
return;
}
}
해당 조건문을 조금 살펴보자면 player가 ground상태가 아니거나, 한 번에 받는 중력값(gravity.y * Time.fixedDeltaTime)이 크다면, FallState로 전환하는 로직을 만든 것이다.
※ 플레이어 공격 구현
이어서 오늘은 드디어 플레이어 공격을 구현해 볼 날이다. 저번 프로젝트에서는 공격을 할 필요가 없었기 때문에
공격구현은 이번이 처음인지라 새로웠다. 우선 필요한 스크립트를 생성한다.
ScriptablObject : PlayerAttackData
PlayerStateMachine : PlayerAttackState , PlayerComboAttackState
PlayerSO 수정
public class PlayerSO : ScriptableObject
{
[field: SerializeField] public PlayerGroundData GroundedData { get; private set; }
[field: SerializeField] public PlayerAirData AirData { get; private set; }
[field: SerializeField] public PlayerAttackData AttackData { get; private set; }
}
<PlayerAttackData>
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class AttackInfoData
{
[field: SerializeField] public string AttackName { get; private set; }
[field: SerializeField] public int ComboStateIndex { get; private set; }
// 콤보가 유지되려면 언제까지 때려야 하는지 = ComboTransitionTime
// 힘은 언제 적용을 하는지 = ForceTransitionTime
[field: SerializeField][field: Range(0f, 1f)] public float ComboTransitionTime { get; private set; }
[field: SerializeField][field: Range(0f, 3f)] public float ForceTransitionTime { get; private set; }
[field: SerializeField][field: Range(-10f, 10f)] public float Force { get; private set; }
[field: SerializeField] public string Damage { get; private set; }
}
[Serializable]
public class PlayerAttackData
{
// 콤보에 대한 정보를 가지고 옴
[field: SerializeField] public List<AttackInfoData> AttackInfoDatas { get; private set;}
// AttackInfo에서 카운트를 가지고 옴
public int GetAttackInfoCount() { return AttackInfoDatas.Count; }
// 현재 사용하고 있는 AttackInfoData의 인덱스값을 가지고 옴
public AttackInfoData GetAttackInfo(int index) { return AttackInfoDatas[index]; }
}
AttackState에서 AttackData를 지정해주고 나면 Unity에서 hierarchy창에서 ScriptableObject에 추가된 것을 볼 수 있다.
PlayerAttackState
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerAttackState : PlayerBaseState
{
public PlayerAttackState(PlayerStateMachine playerstateMachine) : base(playerstateMachine)
{
}
public override void Enter()
{
stateMachine.MovementSpeedModifier = 0f;
base.Enter();
StartAnimation(stateMachine.Player.AnimationData.AttackParameterHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Player.AnimationData.AttackParameterHash);
}
}
PlayerStateMachine 수정
public class PlayerStateMachine : StateMachine
{
// 생략
public PlayerComboAttackState ComboAttackState { get; }
// 생략
public bool IsAttacking { get; set; }
public int ComboIndex { get; set; }
}
public PlayerStateMachine(Player player)
{
// 생략
ComboAttackState = new PlayerComboAttackState(this);
}
PlayerBaseState 수정
protected virtual void AddInputActionsCallbacks()
{
//생략
stateMachine.Player.Input.PlayerActions.Attack.performed += OnAttackPerformed;
stateMachine.Player.Input.PlayerActions.Attack.canceled += OnAttackCanceled;
}
protected virtual void RemoveInputActionsCallbacks()
{
//생략
stateMachine.Player.Input.PlayerActions.Attack.performed -= OnAttackPerformed;
stateMachine.Player.Input.PlayerActions.Attack.canceled -= OnAttackCanceled;
}
protected virtual void OnAttackPerformed(InputAction.CallbackContext context)
{
stateMachine.IsAttacking = true;
}
protected virtual void OnAttackCanceled(InputAction.CallbackContext context)
{
stateMachine.IsAttacking = false;
}
protected float GetNormalizedTime(Animator animator, string tag)
{
AnimatorStateInfo currentInfo = animator.GetCurrentAnimatorStateInfo(0);
AnimatorStateInfo nextInfo = animator.GetNextAnimatorStateInfo(0);
if(animator.IsInTransition(0) && nextInfo.IsTag(tag))
{
return nextInfo.normalizedTime;
}
else if(!animator.IsInTransition(0) && currentInfo.IsTag(tag))
{
return currentInfo.normalizedTime;
}
else
{
return 0f;
}
걷기나 뛰기, 점프 같은 경우에는 공용으로 사용하는 키라서 이렇게 구현되었지만 나중에 특정 상태에서만 사용될 키를 구현하게 된다면 큰 상태(Ground, Air 등)에서 해당 부분을 오버라이드 시켜서 구현하는 것이 더 깔끔하다고 한다.
이렇게 구현하면 점프 중에서도 공격을 할 수 있게 구현이 된다.
<PlayerComboAttackState>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerComboAttackState : PlayerAttackState
{
private bool alreadyAppliedForce;
private bool alreadyApplyCombo;
AttackInfoData attackInfoData;
public PlayerComboAttackState(PlayerStateMachine playerstateMachine) : base(playerstateMachine)
{
}
public override void Enter()
{
base.Enter();
StartAnimation(stateMachine.Player.AnimationData.ComboAttackParameterHash);
// 초기화
alreadyApplyCombo = false;
alreadyAppliedForce = false;
// 스테이트머신에서 콤보인덱스를 가져오고
// 현재 사용해야 하는 콤보인덱스의 정보를 가져오고
// 콤보인덱스 적용(해시값 가져오지 않고)
// 공격에 대한 정보를 가져오고 그에 대한 인덱스를 적용시키는 로직
int comboIndex = stateMachine.ComboIndex;
attackInfoData = stateMachine.Player.Data.AttackData.GetAttackInfo(comboIndex);
stateMachine.Player.Animator.SetInteger("Combo", comboIndex);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Player.AnimationData.ComboAttackParameterHash);
// 콤보가 끊겼을 때 다시 인덱스값을 0으로 돌림
if (!alreadyApplyCombo)
stateMachine.ComboIndex = 0;
}
private void TryComboAttack()
{
// 콤보어택을 이미 했다면 리턴
if (alreadyApplyCombo) return;
// -1은 마지막3타이므로 마지막 공격을 했다면 리턴
if (attackInfoData.ComboStateIndex == -1) return;
// 공격이 끊겼다면 리턴
if(!stateMachine.IsAttacking) return;
// 위의 모든 조건이 아니라면 true로 적용
alreadyApplyCombo = true;
}
private void TryApplyForce()
{
// 이미 적용한 적이 있으면 리턴 그렇지 않다면 true
if(alreadyAppliedForce) return;
alreadyAppliedForce = true;
// 받고있던 힘을(Force) 리셋하고
stateMachine.Player.ForceReceiver.Reset();
// ForceReceiver에 AddForce를 적용. 바라보고있는 정면에서 밀려나도록.
stateMachine.Player.ForceReceiver.AddForce(stateMachine.Player.transform.forward * attackInfoData.Force);
}
public override void Update()
{
base.Update();
ForceMove();
// Animator를 전달하고 "Attack"이라는 태그를 주면 가져올 수 있다
float normalizedTime = GetNormalizedTime(stateMachine.Player.Animator, "Attack");
if (normalizedTime < 1f)
{
// 애니메이션이 처리가 되고 있는 중
if(normalizedTime >= attackInfoData.ForceTransitionTime)
TryApplyForce();
if(normalizedTime >= attackInfoData.ComboTransitionTime)
TryComboAttack();
}
else
{
// 애니메이션 플레이가 완료된 시점
// 다음 콤보 인덱스를 가져와서 콤보어택을 시전하는 로직
if (alreadyApplyCombo)
{
stateMachine.ComboIndex = attackInfoData.ComboStateIndex;
stateMachine.ChangeState(stateMachine.ComboAttackState);
}
else
{
stateMachine.ChangeState(stateMachine.IdleState);
}
}
}
}
ComboAttackState에서 사용을 선언한 ForceMove는 PlayerBaseState에서 Move밑에 따로 선언을 해준다.
PlayerBaseState 수정(ForceMove 추가)
private void Move(Vector3 movementDirection)
{
// 플레이어 이동처리
//생략
}
protected void ForceMove()
{
stateMachine.Player.Controller.Move(stateMachine.Player.ForceReceiver.Movement * Time.deltaTime);
}
PlayerGroundState 수정
public override void Update()
{
base.Update();
if(stateMachine.IsAttacking)
{
OnAttack();
return;
}
}
protected virtual void OnAttack()
{
stateMachine.ChangeState(stateMachine.ComboAttackState);
}
그리고 공격기능 구현에 따라 애니메이션도 설정을 해야 한다.
Attack Animator 구성
1타 → 2타 : Combo equals 1
2타 → 3타 : Combo equals 2
entry → 2타 : ComboAttack (true) , Combo equals 1
entry → 3타 : ComboAttack (true) , Combo equals 2
1타, 2타, 3타 → Exit : ComboAttack(false)
공격기능 구현은 캐릭터 동작기능 애니메이션과는 다르게 한 가지 더 추가해야 하는데,
1타, 2타, 3타 애니메이션에 Attack Tag를 걸어주어야 한다. 그래야 Animator에서 "Attack"이라는 태그를 달고 있는 액션을 자동으로 호출할 수 있게 만들었던 코드가 작동이 된다.
※ 버그리포트
여기까지 진행이 됐다면 캐릭터가 공격기능을 갖추게 되기 때문에 직접 실행을 해보았으나 또 다른 버그가 생겼다...
공격 키를 한 번만 눌러도 계속 공격이 나가는 상황인데, 이것 또한 초반에 작성했던 애니메이션에서 빠져나오지 못하고
무한루프를 도는 형식인 것 같아서 문제점을 찾고자 애니메이션을 관리하는 AttackState나 ComboAttackState를 찾아보았으나 문제는 없었는데, 이번에는 BaseState에서의 문제였다. 역시나 코드적인 오류였는데,
OnAttackPerformed로만 죄다 도배해놓아서 액션에서 빠져나오질 못하고 계속 반복하고만 있었다.
해당 부분을 BaseState에서 만들어 놨었던 OnAttackCanceled로 바꿔주어야 한다. (만들어놓고 안 쓴 건 뭐야...)
이렇게 수정을 하고 나서 다시 실행을 하면 키 입력을 한 번 했을 때 한 번만 작동이 잘 되어서 무한루프에 빠지는 것은 해결했으나, 콤보어택이 이어지지 않았다... 그래서 다시 문제점을 찾아보고 있었는데
코드상의 오류는 없었으나 실행했을 때 애니메이션 인덱스를 수정하고 공격을 해도 해당 인덱스의 공격이 나가지 않는 문제가 있었다. 인덱스에 따른 공격 애니메이션이 할당이 안되어 있는 것인가 싶어서 확인해 봤더니,
PlayerSO의 AttackData에서 1타, 2타, 3타에 인덱스값이 할당이 안 되어 있었다...
분명 위에서는 인덱스값까지 넣어서 적용시켜 놨는데 왜 이런 상황이 된 건지 잘 모르겠지만... 어쨌든 다시 또 인덱스 값을
할당해 주고 실행해 보니 정말 문제없이 잘 돌아가는 것을 확인했다!
직접 실행시켜 보니 생각보다 매끄럽게 흘러가진 않아서 추후에 다시 자연스럽게 이어지도록 애니메이션을 수정해야겠다.
생각보다 큰 버그들은 없었고 사실상 버그라고 말하기도 애매할 정도로 가벼운 실수들이지만 좀 더 꼼꼼하게 살펴보아야겠다. 코드를 직접 작성하면서 복사+붙여 넣기를 하는 과정에서 수정을 빼먹는 부분도 많았지만 이제는 어떤 부분이 작동이 잘 안 되면 그 부분을 찾는 게 좀 더 익숙해진 것 같다.
'TIL (since 2023.08.07 ~ )' 카테고리의 다른 글
2023-10-31 TIL(Unity 최종 프로젝트 7일차) (1) | 2023.10.31 |
---|---|
2023-10-30 TIL(Unity 최종 프로젝트 6일차) (0) | 2023.10.30 |
2023-10-26 TIL(Unity 최종 프로젝트 4일차) (0) | 2023.10.26 |
2023-10-25 TIL(Unity 최종 프로젝트 3일차) (0) | 2023.10.25 |
2023-10-24 TIL(Unity 최종 프로젝트 2일차) (1) | 2023.10.24 |