본문 바로가기

TIL (since 2023.08.07 ~ )

2023-10-27 TIL(Unity 최종 프로젝트 5일차)

오늘은 플레이어 공격 구현과 체력, 스태미나 시스템을 구현해 볼 계획이다.

 

먼저 어제 점프 구현까지는 완료가 되었는데 직접 실행을 해보니 이상한 오류가 하나 발생하게 되었다.

 

※ 버그리포트

점프를 한 상태에서 캐릭터가 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타에 인덱스값이 할당이 안 되어 있었다...

 

 

분명 위에서는 인덱스값까지 넣어서 적용시켜 놨는데 왜 이런 상황이 된 건지 잘 모르겠지만... 어쨌든 다시 또 인덱스 값을

할당해 주고 실행해 보니 정말 문제없이 잘 돌아가는 것을 확인했다!

직접 실행시켜 보니 생각보다 매끄럽게 흘러가진 않아서 추후에 다시 자연스럽게 이어지도록 애니메이션을 수정해야겠다.

 

생각보다 큰 버그들은 없었고 사실상 버그라고 말하기도 애매할 정도로 가벼운 실수들이지만 좀 더 꼼꼼하게 살펴보아야겠다. 코드를 직접 작성하면서 복사+붙여 넣기를 하는 과정에서 수정을 빼먹는 부분도 많았지만 이제는 어떤 부분이 작동이 잘 안 되면 그 부분을 찾는 게 좀 더 익숙해진 것 같다.