Cinemachine 카메라 적용

Cinemachine(시네머신)은 Unity에서 제공해주는 카메라 시스템으로, 카메라 스무딩, 블렌딩, 추적, 회전, 레일 카메라 등 여러가지 기능들을 제공하여 쉽게 구현할 수 있게 해준다.

 

하이어라키에 우클릭하여 Virture Camera를 추가한다.

 

생성하면 Main Camera에 CinemachineBrain이라는 것이 추가된다. Virture Camera는 실제 카메라가 아니기 때문에 메인에서 Virture Camera의 정보를 가지고 실행된다.

 

Player는 시야가 발에 붙어있기 때문에 높이를 올린 오브젝트를 따로 만들어서 Follow와 Look에 넣어준다.

Body는 Framing Transposer, Aim은 POV로 설정한 뒤, 세부 사항을 조절한다.

 

실행해보면 움직일 때 오브젝트가 있으면 Player가 가려진다.

 

Virtual Camera에서 Add Extension에서 CinemachineCollider를 추가하면 카메라에 충돌체가 달려서 일정 거리가 되면 Player에 가까워지며 오브젝트에 시야가 막히지 않는다.

 

 

플레이어 점프

플레이어가 현재 Rigid Body를 사용하고 있지 않기 때문에 중력에 영향을 받고 있지 않으며 점프 처리를 하기 위해서는 힘을 받아야한다. 플레이어에 스크립트를 추가한다.

[SerializeField] private CharacterController controller;
[SerializeField] private float drag = 0.3f;

private Vector3 dampingVelocity;
private Vector3 impact;
private float verticalVelocity;

public Vector3 Movement => impact + Vector3.up * verticalVelocity;

void Update()
{
    if (verticalVelocity < 0f && controller.isGrounded) // 땅에 붙어있는가
    {
        verticalVelocity = Physics.gravity.y * Time.deltaTime;
    }
    else
    {
        verticalVelocity += Physics.gravity.y * Time.deltaTime;
    }

    // 저항값을 가지고 천천히 떨어진다
    impact = Vector3.SmoothDamp(impact, Vector3.zero, ref dampingVelocity, drag);
}

public void Reset()
{
    impact = Vector3.zero;
    verticalVelocity = 0f;
}

public void AddForce(Vector3 force)
{
    impact += force;
}

public void Jump(float jumpForce)
{
    verticalVelocity += jumpForce;
}

 

이동 처리할 때 Force Receive 값을 더하기 위해 플레이어 스크립트에 추가하여 컴포넌트 받는다.

public ForceReceiver ForceReceiver { get; private set; }

private void Awake()
{
    ForceReceiver = GetComponent<ForceReceiver>();
}

 

플레이어 기본 상태에서 이동을 처리할 때 더해준다. 이제 중력의 영향을 받아 높은 곳에서 낮은 곳으로 이동할 때 낮은 곳으로 떨어진다. (높은 곳에서 떨어질 때는 중력의 영향만 받을 뿐 아직 떨어지는 코드가 동작하는 것은 아니다.)

private void Move(Vector3 movementDirection)
{
    float movementSpeed = GetMovemenetSpeed();
    stateMachine.Player.Controller.Move(
        ((movementDirection * movementSpeed)
        + stateMachine.Player.ForceReceiver.Movement)
        * Time.deltaTime
        );
}

 

이동 구현할 때와 마찬가지로 공중에 있을 때와 점프, 떨어지는 상태의 스크립트를 각각 만든다.

플레이어가 공중에 있을 때

public class PlayerAirState : PlayerBaseState
{
    public PlayerAirState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.AirParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.AirParameterHash);
    }
}

 

플레이어의 점프 상태

public class PlayerJumpState : PlayerAirState
{
    public PlayerJumpState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.JumpForce = stateMachine.Player.Data.AirData.JumpForce;
        stateMachine.Player.ForceReceiver.Jump(stateMachine.JumpForce);

        base.Enter();

        StartAnimation(stateMachine.Player.AnimationData.JumpParameterHash);
    }

    public override void Exit()
    {
        base.Exit();

        StopAnimation(stateMachine.Player.AnimationData.JumpParameterHash);
    }

    public override void PhysicsUpdate()
    {
        base.PhysicsUpdate();

        if (stateMachine.Player.Controller.velocity.y <= 0) // 공중에서 y가 0 이하로 될 때
        {
            stateMachine.ChangeState(stateMachine.FallState);
            return;
        }
    }
}

 

플레이어의 떨어지는 상태

public class PlayerFallState : PlayerAirState
{
    public PlayerFallState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();

        StartAnimation(stateMachine.Player.AnimationData.fallParameterHash);
    }

    public override void Exit()
    {
        base.Exit();

        StopAnimation(stateMachine.Player.AnimationData.fallParameterHash);
    }

    public override void Update()
    {
        base.Update();

        if (stateMachine.Player.Controller.isGrounded) // 땅을 밟았을 때
        {
            stateMachine.ChangeState(stateMachine.IdleState);
            return;
        }
    }
}

 

플레이어 State Machine에 점프 상태와 떨어질 때 상태를 각각 추가한다.

public class PlayerStateMachine : StateMachine
{
    // States
    public PlayerIdleState IdleState { get; }
    public PlayerWalkState WalkState { get; }
    public PlayerRunState RunState { get; }
    
    public PlayerJumpState JumpState { get; }
    public PlayerFallState FallState { get; }


    public PlayerStateMachine(Player player)
    {
        this.Player = player;

        IdleState = new PlayerIdleState(this);
        WalkState = new PlayerWalkState(this);
        RunState = new PlayerRunState(this);
        
        JumpState = new PlayerJumpState(this);
        FallState = new PlayerFallState(this);

        MainCameraTransform = Camera.main.transform;

        MovementSpeed = player.Data.GroundedData.BaseSpeed;
        RotationDamping = player.Data.GroundedData.BaseRotationDamping;
    }
}

 

플레이어의 기본 상태 스크립트에 입력값에 대한 코드를 추가한다.

protected virtual void AddInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled += OnMovementCanceled;
    input.PlayerActions.Run.started += OnRunStarted;

    stateMachine.Player.Input.PlayerActions.Jump.started += OnJumpStarted;
}

protected virtual void RemoveInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled -= OnMovementCanceled;
    input.PlayerActions.Run.started -= OnRunStarted;

    stateMachine.Player.Input.PlayerActions.Jump.started -= OnJumpStarted;
}

// 틀만 구현
protected virtual void OnRunStarted(InputAction.CallbackContext context)
{

}

protected virtual void OnMovementCanceled(InputAction.CallbackContext context)
{

}

protected virtual void OnJumpStarted(InputAction.CallbackContext context)
{

}

 

플레이어가 땅에 있을 때 입력값을 처리한다.

protected override void OnJumpStarted(InputAction.CallbackContext context)
{
    stateMachine.ChangeState(stateMachine.JumpState);
}

 

유니티로 돌아와서 공중에 있을 때와 땅에 있을 때의 애니메이션을 이어준다.

 

 

플레이어 추락

위에서 언급했다시피 높은 곳에서 떨어질 때, 떨어지는 구현이 되어있지 않기 때문에 걷는 상태로 중력의 영향만 받는다.

 

땅에 있는 상태에 대한 스크립트를 수정한다.

public override void PhysicsUpdate()
{
    base.PhysicsUpdate();

    if (!stateMachine.Player.Controller.isGrounded
    && stateMachine.Player.Controller.velocity.y < Physics.gravity.y * Time.fixedDeltaTime)
    {
        stateMachine.ChangeState(stateMachine.FallState);
        return;
    }
}

 

추락 처리가 적용되었다.

 

 

플레이어 공격

플레이어의 공격을 처리하는 스크립트와 콤보 스크립트, 그리고 공격 데이터를 처리하는 스크립트를 만든다.

플레이어 공격 데이터

[Serializable]
public class AttackInfoData
{
    [field: SerializeField] public string AttackName { get; private set; }
    [field: SerializeField] public int ComboStateIndex { get; private set; }
    [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 int Damage { get; private set; }
}


[Serializable]
public class PlayerAttackData 
{
    [field: SerializeField] public List<AttackInfoData> AttackInfoDatas { get; private set; }
    public int GetAttackInfoCount() { return AttackInfoDatas.Count; }
    public AttackInfoData GetAttackInfo(int index) { return AttackInfoDatas[index]; }

}

 

공격 데이터를 플레이어의 Scriptable Object에 가져온 뒤, 유니티에서 공격 종류를 추가한다.

[field: SerializeField] public PlayerAttackData AttakData { get; private set; }

 

 

 

공격 애니메이션을 연결한다. 공격 콤보에는 Tag를 붙여준다.

 

플레이어 공격 상태

public class PlayerAttackState : PlayerBaseState
{
    public PlayerAttackState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0;
        base.Enter();

        StartAnimation(stateMachine.Player.AnimationData.AttackParameterHash);
    }

    public override void Exit()
    {
        base.Exit();

        StopAnimation(stateMachine.Player.AnimationData.AttackParameterHash);
    }
}

 

플레이어 State Machine에 공격 상태를 추가한다.

public class PlayerStateMachine : StateMachine
{
    // States
    public PlayerIdleState IdleState { get; }
    public PlayerWalkState WalkState { get; }
    public PlayerRunState RunState { get; }
    
    public PlayerJumpState JumpState { get; }
    public PlayerFallState FallState { get; }
    
    public PlayerComboAttackState ComboAttackState { get; }
    
    public bool IsAttacking { get; set; }
    public int ComboIndex { get; set; }

    public PlayerStateMachine(Player player)
    {
        this.Player = player;

        IdleState = new PlayerIdleState(this);
        WalkState = new PlayerWalkState(this);
        RunState = new PlayerRunState(this);
        
        JumpState = new PlayerJumpState(this);
        FallState = new PlayerFallState(this);
        
        ComboAttackState = new PlayerComboAttackState(this);

        MainCameraTransform = Camera.main.transform;

        MovementSpeed = player.Data.GroundedData.BaseSpeed;
        RotationDamping = player.Data.GroundedData.BaseRotationDamping;
    }
}

 

마찬가지로 플레이어의 기본 상태 스크립트에 공격에 대한 입력값에 대한 코드를 추가한다.

protected virtual void AddInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled += OnMovementCanceled;
    input.PlayerActions.Run.started += OnRunStarted;

    stateMachine.Player.Input.PlayerActions.Jump.started += OnJumpStarted;

    stateMachine.Player.Input.PlayerActions.Attack.performed += OnAttackPerformed;
    stateMachine.Player.Input.PlayerActions.Attack.canceled += OnAttackCanceled;
}

protected virtual void RemoveInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled -= OnMovementCanceled;
    input.PlayerActions.Run.started -= OnRunStarted;

    stateMachine.Player.Input.PlayerActions.Jump.started -= OnJumpStarted;

    stateMachine.Player.Input.PlayerActions.Attack.performed -= OnAttackPerformed;
    stateMachine.Player.Input.PlayerActions.Attack.canceled -= OnAttackCanceled;
}

// 틀만 구현
protected virtual void OnRunStarted(InputAction.CallbackContext context)
{

}

protected virtual void OnMovementCanceled(InputAction.CallbackContext context)
{

}

protected virtual void OnJumpStarted(InputAction.CallbackContext context)
{

}

protected virtual void OnAttackPerformed(InputAction.CallbackContext obj)
{
    stateMachine.IsAttacking = true;
}

protected virtual void OnAttackCanceled(InputAction.CallbackContext obj)
{
    stateMachine.IsAttacking = false;
}

protected void ForceMove()
{
    stateMachine.Player.Controller.Move(stateMachine.Player.ForceReceiver.Movement * Time.deltaTime);
}

protected float GetNormalizedTime(Animator animator, string tag)
{
    AnimatorStateInfo currentInfo = animator.GetCurrentAnimatorStateInfo(0);
    AnimatorStateInfo nextInfo = animator.GetNextAnimatorStateInfo(0);

    if (animator.IsInTransition(0) && nextInfo.IsTag(tag)) // transition을 탔거나 다음 애니메이션이 있다면
    {
        return nextInfo.normalizedTime;
    }
    else if (!animator.IsInTransition(0) && currentInfo.IsTag(tag)) // transition이 아니고 현재 태그가 Attack이라면
    {
        return currentInfo.normalizedTime;
    }
    else
    {
        return 0f;
    }
}

 

플레이어 콤보 공격

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.AttakData.GetAttackInfo(comboIndex);
        stateMachine.Player.Animator.SetInteger("Combo", comboIndex);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.ComboAttackParameterHash);

        if (!alreadyApplyCombo) // 콤보에 성공하지 못했을 경우
            stateMachine.ComboIndex = 0;
    }

    private void TryComboAttack() // 콤보 중
    {
        if (alreadyApplyCombo) return;

        if (attackInfoData.ComboStateIndex == -1) return;

        if (!stateMachine.IsAttacking) return;

        alreadyApplyCombo = true;
    }

    private void TryApplyForce() // 밀어내는 힘
    {
        if (alreadyAppliedForce) return;
        alreadyAppliedForce = true;

        stateMachine.Player.ForceReceiver.Reset();

        stateMachine.Player.ForceReceiver.AddForce(stateMachine.Player.transform.forward * attackInfoData.Force);
    }

    public override void Update()
    {
        base.Update();

        ForceMove();

        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);
            }
        }
    }
}

 

플레이어가 땅에 있을 때 입력값을 처리한다. 공중에서도 공격하고 싶다면 Air 스크립트에 추가해도 된다.

public override void Update()
{
    base.Update();

    if (stateMachine.IsAttacking)
    {
        OnAttack();
        return;
    }
}

protected virtual void OnAttack() 
{
    stateMachine.ChangeState(stateMachine.ComboAttackState);
}

 

만약 기본 상태나 걸을 때는 공격이 되지만 뛰는 상태에서 공격이 불가능하게 하고 싶다면 OnAttack을 override하면 된다.

+ Recent posts