적 구현

적은 플레이어 오브젝트를 복사 + 붙여넣기 하여 기본 틀로 사용한다.

적 스크립트 또한 플레이어 스크립트와 비슷하다.

[field: Header("References")]
    [field: SerializeField] public EnemySO Data { get; private set; }

    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }

    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public ForceReceiver ForceReceiver { get; private set; }
    public CharacterController Controller { get; private set; }

    private EnemyStateMachine stateMachine;

    void Awake()
    {
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Controller = GetComponent<CharacterController>();
        ForceReceiver = GetComponent<ForceReceiver>();

        stateMachine = new EnemyStateMachine(this);
    }

    private void Start()
    {
        stateMachine.ChangeState(stateMachine.IdlingState);
    }

    private void Update()
    {
        stateMachine.HandleInput();

        stateMachine.Update();
    }

    private void FixedUpdate()
    {
        stateMachine.PhysicsUpdate();
    }

 

적의 Scriptable Object와 상태 머신 또한 이름만 바뀌고 비슷하다.

[CreateAssetMenu(fileName = "EnemySO", menuName = "Characters/Enemy")]
public class EnemySO : ScriptableObject
{
    [field: SerializeField] public float PlayerChasingRange { get; private set; } = 10f;
    [field: SerializeField] public float AttackRange { get; private set; } = 1.5f;
    [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; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_Start_TransitionTime { get; private set; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_End_TransitionTime { get; private set; }

    [field: SerializeField] public PlayerGroundData GroundedData { get; private set; }

}
public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Transform Target { get; private set; }

    public EnemyIdleState IdlingState { get; }
    public EnemyChasingState ChasingState { get; }
    public EnemyAttackState AttackState { get; }

    // 이동
    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public EnemyStateMachine(Enemy enemy)
    {
        Enemy = enemy;
        Target = GameObject.FindGameObjectWithTag("Player").transform;

        IdlingState = new EnemyIdleState(this);
        ChasingState = new EnemyChasingState(this);
        AttackState = new EnemyAttackState(this);

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

 

기본 상태는 아래 플레이어를 쫓아가는 코드 외에는 전부 똑같다.

sqrMagnitude의 sqr은 제곱근으로, 제곱근을 푸는 연산이 무겁기 때문에 벡터의 크기에 한 번 더 곱해버리는 계산이다.

private Vector3 GetMovementDirection()
{
    return (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).normalized;
}

protected bool IsInChaseRange()
{
    // if (stateMachine.Target.IsDead) { return false; } // 플레이어가 죽었을 때 쫓아가지 않는다

    float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

    return playerDistanceSqr <= stateMachine.Enemy.Data.PlayerChasingRange * stateMachine.Enemy.Data.PlayerChasingRange;
}
public class EnemyIdleState : EnemyBaseState
{
    public EnemyIdleState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    // 플레이어의 Idle 공유
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0f;

        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Update()
    {
        if (IsInChaseRange())
        {
            stateMachine.ChangeState(stateMachine.ChasingState); // 적과 플레이어가 일정 거리가 되면 쫓아간다
            return;
        }
    }
}

 

적이 플레이어를 쫓는 상태

public class EnemyChasingState : EnemyBaseState
{
    public EnemyChasingState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 1;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

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

        if (!IsInChaseRange()) // 플레이어가 사정거리에 들어오지 않았다면
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
        else if (IsInAttackRange())
        {
            stateMachine.ChangeState(stateMachine.AttackState);
            return;
        }
    }

    private bool IsInAttackRange()
    {
        // if (stateMachine.Target.IsDead) { return false; }

        float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

        return playerDistanceSqr <= stateMachine.Enemy.Data.AttackRange * stateMachine.Enemy.Data.AttackRange;
    }
}

 

적이 플레이어를 공격하는 상태

public class EnemyAttackState : EnemyBaseState
{

    private bool alreadyAppliedForce;

    public EnemyAttackState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);

    }

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

        ForceMove();

        float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
        if (normalizedTime < 1f)
        {
            if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
                TryApplyForce();

        }
        else
        {
            if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
            {
                stateMachine.ChangeState(stateMachine.ChasingState);
                return;
            }
            else
            {
                stateMachine.ChangeState(stateMachine.IdlingState);
                return;
            }
        }

    }

    private void TryApplyForce()
    {
        if (alreadyAppliedForce) return;
        alreadyAppliedForce = true;

        stateMachine.Enemy.ForceReceiver.Reset();

        stateMachine.Enemy.ForceReceiver.AddForce(stateMachine.Enemy.transform.forward * stateMachine.Enemy.Data.Force);

    }
}

 

플레이어의 애니메이션 스크립트를 기반으로 동작하기 때문에 플레이어 애니메이션 스크립트에 적의 기본 공격을 추가한다.

[SerializeField] private string baseAttackParameterName = "BaseAttack";

public int BaseAttackParameterHash { get; private set; }

public void Initialize()
{
    BaseAttackParameterHash = Animator.StringToHash(baseAttackParameterName);
}

 

유니티로 돌아와서 적의 Scriptable Object를 생성하고 적 스크립트에 연결한다.

 

기본 공격에 대한 애니메이션을 연결하면 적이 플레이어를 쫓아와서 때린다.

 

 

피격 및 처치

피격을 위해 HP와 무기 스크립트를 만든다.

public class Health : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    private int health;
    public event Action OnDie;

    public bool IsDead => health == 0;

    private void Start()
    {
        health = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        if (health == 0) return;
        health = Mathf.Max(health - damage, 0);

        if (health == 0)
            OnDie?.Invoke();

        Debug.Log(health);
    }
}
public class Weapon : MonoBehaviour
{
    [SerializeField] private Collider myCollider;

    private int damage;
    private float knockback;

    private List<Collider> alreadyColliderWith = new List<Collider>();

    private void OnEnable()
    {
        alreadyColliderWith.Clear();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other == myCollider) return;
        if (alreadyColliderWith.Contains(other)) return;

        alreadyColliderWith.Add(other);

        if(other.TryGetComponent(out CharacterHealth health))
        {
            health.TakeDamage(damage);
        }

        if(other.TryGetComponent(out ForceReceiver forceReceiver))
        {
            Vector3 direction = (other.transform.position - myCollider.transform.position).normalized;
            forceReceiver.AddForce(direction * knockback);
        }
    }

    public void SetAttack(int damage, float knockback)
    {
        this.damage = damage;
        this.knockback = knockback;
    }
}

 

공격 애니메이션이 처리 될 때, 데미지를 줄 것인지 처리하기 위해 적 공격 상태 스크립트에 코드를 추가한다.

private bool alreadyAppliedDealing;

public override void Enter()
{
    alreadyAppliedForce = false;
    alreadyAppliedDealing = false;

    stateMachine.MovementSpeedModifier = 0;
    base.Enter();
    StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
    StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}

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

    ForceMove();

    float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
    if (normalizedTime < 1f)
    {
        if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
            TryApplyForce();

        if (!alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_Start_TransitionTime) // 딜 안했을 때
        {
            stateMachine.Enemy.Weapon.SetAttack(stateMachine.Enemy.Data.Damage, stateMachine.Enemy.Data.Force);
            stateMachine.Enemy.Weapon.gameObject.SetActive(true);
            alreadyAppliedDealing = true;
        }

        if (alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_End_TransitionTime) // 딜 했을 때
        {
            stateMachine.Enemy.Weapon.gameObject.SetActive(false);
        }

    }
    else
    {
        if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
        {
            stateMachine.ChangeState(stateMachine.ChasingState);
            return;
        }
        else
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
    }
}

 

적 스크립트에 무기와 플레이어가 죽었는지에 대한 코드를 추가한다.

[field: SerializeField] public Weapon Weapon { get; private set; }
public Health Health { get; private set; }

void Awake()
{
    AnimationData.Initialize();

    Rigidbody = GetComponent<Rigidbody>();
    Animator = GetComponentInChildren<Animator>();
    Controller = GetComponent<CharacterController>();
    ForceReceiver = GetComponent<ForceReceiver>();

    Health = GetComponent<Health>();

    stateMachine = new EnemyStateMachine(this);
}

private void Start()
{
    stateMachine.ChangeState(stateMachine.IdlingState);
    Health.OnDie += OnDie;
}

void OnDie()
{
    Animator.SetTrigger("Die");
    enabled = false;
}

 

유니티로 돌아와서 적 오른쪽 손에 무기 Collider와 Rigidbody를 걸어주고 무기 스크립트를 붙여준다.

 

적 오브젝트에 붙은 스크립트에 무기를 넣어준다.

 

플레이어에도 동일하게 스크립트와 무기에 Collider, Rigidbody를 추가한다.

주석 처리했던적 기본 상태 스크립트에서 주석 처리했던 죽음 처리를 풀고 적 상태 머신에서 임시로 처리한 Transform을 Health로 바꾼다.

public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Health Target { get; private set; }
    
    public EnemyStateMachine(Enemy enemy)
	{
    Enemy = enemy;
    Target = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();

    IdlingState = new EnemyIdleState(this);
    ChasingState = new EnemyChasingState(this);
    AttackState = new EnemyAttackState(this);

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

 

 

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하면 된다.

Player State Machine(플레이어 상태 머신)

플레이어의 Input을 받아 스크립트로 Action과 이벤트를 처리하고 추가한다.

public class PlayerInput : MonoBehaviour
{
    // 플레이어의 InputAction
    public PlayerInputActions InputActions { get; private set; }
    public PlayerInputActions.PlayerActions PlayerActions { get; private set; }

    private void Awake()
    {
        InputActions = new PlayerInputActions();

        PlayerActions = InputActions.Player;
    }

    // InputAction 키기
    private void OnEnable()
    {
        InputActions.Enable();
    }

    // InputAction 끄기
    private void OnDisable()
    {
        InputActions.Disable();
    }
}

 

애니메이션에서 사용할 파라미터 값들을 설정한다.

[Serializable]
public class PlayerAnimationData
{
    [SerializeField] private string groundParameterName = "@Ground";
    [SerializeField] private string idleParameterName = "Idle";
    [SerializeField] private string walkParameterName = "Walk";
    [SerializeField] private string runParameterName = "Run";

    [SerializeField] private string airParameterName = "@Air";
    [SerializeField] private string jumpParameterName = "Jump";
    [SerializeField] private string fallParameterName = "Fall";

    [SerializeField] private string attackParameterName = "@Attack";
    [SerializeField] private string comboAttackParameterName = "ComboAttack";


    public int GroundParameterHash { get; private set; }
    public int IdleParameterHash { get; private set; }
    public int WalkParameterHash { get; private set; }
    public int RunParameterHash { get; private set; }

    public int AirParameterHash { get; private set; }
    public int JumpParameterHash { get; private set; }
    public int fallParameterHash { get; private set; }

    public int AttackParameterHash { get; private set; }
    public int ComboAttackParameterHash { get; private set; }

    public void Initialize()
    {
        GroundParameterHash = Animator.StringToHash(groundParameterName);
        IdleParameterHash = Animator.StringToHash(idleParameterName);
        WalkParameterHash = Animator.StringToHash(walkParameterName);
        RunParameterHash = Animator.StringToHash(runParameterName);

        AirParameterHash = Animator.StringToHash(airParameterName);
        JumpParameterHash = Animator.StringToHash(jumpParameterName);
        fallParameterHash = Animator.StringToHash(fallParameterName);

        AttackParameterHash = Animator.StringToHash(attackParameterName);
        ComboAttackParameterHash = Animator.StringToHash(comboAttackParameterName);
    }
}

 

플레이어의 필요한 데이터를 총집합한다.

public class Player : MonoBehaviour
{
    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }

    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public PlayerInput Input { get; private set; }
    public CharacterController Controller { get; private set; }

    // 초기화
    private void Awake()
    {
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Input = GetComponent<PlayerInput>();
        Controller = GetComponent<CharacterController>();
    }

    // 커서 처리
    private void Start()
    {
        Cursor.lockState = CursorLockMode.Locked;
    }

}

 

State는 인터페이스이기 때문에 내부를 구현하지 않아도 된다.

IState에 들어왔을 때, 나갈 때, State 중 입력처리 할 때, 업데이트, 물리적 업데이트의 상태를 만들어준다.

public interface IState
{
    public void Enter();
    public void Exit();
    public void HandleInput();
    public void Update();
    public void PhysicsUpdate();

}

 

State Machine 스크립트에서 IState를 받아온다. 자체적으로 객체화를 할 수 없도록 추상클래스를 사용한다.

public abstract class StateMachine
{
    protected IState currentState;

    public void ChangeState(IState newState)
    {
        currentState?.Exit(); // 전의 상황에 대한 마무리

        currentState = newState; // 새로운 상황

        currentState?.Enter(); // 새로운 상황 시작
    }

    public void HandleInput()
    {
        currentState?.HandleInput();
    }

    public void Update()
    {
        currentState?.Update();
    }

    public void PhysicsUpdate()
    {
        currentState?.PhysicsUpdate();
    }
}

 

플레이어의 Scriptable Object를 사용하기 위해 스크립트만든다.

[CreateAssetMenu(fileName ="Player",menuName = "Characters/Player")]

public class PlayerSO : ScriptableObject
{
    [field: SerializeField] public PlayerGroundData GroundedData { get; private set; }
    [field: SerializeField] public PlayerAirData AirData { get; private set; }
}

 

그리고 Player 스크립트에서 스크립터블 오브젝트의 데이터를 가져올 수 있도록 한다.

[field: Header("References")]
[field: SerializeField] public PlayerSO Data { get; private set; }

private PlayerStateMachine stateMachine; // 플레이어 상태 머신

private void Awake()
{
    AnimationData.Initialize();

    Rigidbody = GetComponent<Rigidbody>();
    Animator = GetComponentInChildren<Animator>();
    Input = GetComponent<PlayerInput>();
    Controller = GetComponent<CharacterController>();

    stateMachine = new PlayerStateMachine(this); // 플레이어가 플레이어 자신을 넘겨줄 수 있도록 한다
}

 

스크립터블 오브젝트에서 사용하기 위한 데이터 스크립트들을 만든다.

플레이어가 땅에 있을 때

[Serializable]
public class PlayerGroundData
{
	// 플레이어의 기본 이동속도와 회전
    [field: SerializeField][field: Range(0f, 25f)] public float BaseSpeed { get; private set; } = 5f;
    [field: SerializeField][field: Range(0f, 25f)] public float BaseRotationDamping { get; private set; } = 1f;

	// 플레이어 이동처리
    [field: Header("IdleData")]

    [field: Header("WalkData")]
    [field: SerializeField][field: Range(0f, 2f)] public float WalkSpeedModifier { get; private set; } = 0.225f;

    [field: Header("RunData")]
    [field: SerializeField][field: Range(0f, 2f)] public float RunSpeedModifier { get; private set; } = 1f;
}

 

플레이어가 공중에 있을 때

[Serializable]
public class PlayerAirData
{
    // 플레이어의 점프에 대한 힘
    [field: Header("JumpData")]
    [field: SerializeField][field: Range(0f, 25f)] public float JumpForce { get; private set; } = 4f;
}

 

플레이어 상태 머신 스크립트에서 플레이어의 상태와 이동에 대한 처리들을 받아온다.

public class PlayerStateMachine : StateMachine
{
    public Player Player { get; }

    // States
    public PlayerIdleState IdleState { get; }

    // 이동
    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public float JumpForce { get; set; }

    public Transform MainCameraTransform { get; set; }

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

        IdleState = new PlayerIdleState(this);

        MainCameraTransform = Camera.main.transform;

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

 

플레이어의 기본 상태를 만들기 위해 IState를 상속받는다.

public class PlayerIdleState : IState
{
    public void Enter()
    {
        throw new System.NotImplementedException();
    }

    public void Exit()
    {
        throw new System.NotImplementedException();
    }

    public void Update()
    {
        throw new System.NotImplementedException();
    }
}

 

그런데 Enter, Exit, Update 등의 처리를 위해 Input이나 Animation 등 여러 State를 컨트롤 하는 기능들을 구현하면 중복이 발생할 것이다. 그것을 방지하기 위해 새로운 BaseState를 상속받게 한다.

 

플레이어의 기본 상태

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerBaseState : IState
{
    // 역참조
    protected PlayerStateMachine stateMachine;
    protected readonly PlayerGroundData groundData;

    public PlayerBaseState(PlayerStateMachine playerStateMachine)
    {
        stateMachine = playerStateMachine;
        groundData = stateMachine.Player.Data.GroundedData;
    }

    public virtual void Enter()
    {
        AddInputActionsCallbacks();
    }

    public virtual void Exit()
    {
        RemoveInputActionsCallbacks();
    }

    public virtual void HandleInput()
    {
        ReadMovementInput();
    }

    public virtual void PhysicsUpdate()
    {

    }

    public virtual void Update()
    {
        Move();
    }

    private void AddInputActionsCallbacks()
    {
        throw new NotImplementedException();
    }

    private void RemoveInputActionsCallbacks()
    {
        throw new NotImplementedException();
    }

    // 이동에 대한 입력
    private void ReadMovementInput()
    {
        stateMachine.MovementInput = stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();
    }

    // 실제 이동처리
    private void Move()
    {
        Vector3 movementDirection = GetMovementDirection();

        Rotate(movementDirection);

        Move(movementDirection);
    }

    // 카메라가 바라보는 방향으로 이동
    private Vector3 GetMovementDirection()
    {
        Vector3 forward = stateMachine.MainCameraTransform.forward;
        Vector3 right = stateMachine.MainCameraTransform.right;

        // 땅바닥 보지 않게 하기 위해 y값 제거
        forward.y = 0;
        right.y = 0;

        forward.Normalize();
        right.Normalize();

        return forward * stateMachine.MovementInput.y + right * stateMachine.MovementInput.x;
    }

    // 이동
    private void Move(Vector3 movementDirection)
    {
        float movementSpeed = GetMovemenetSpeed();
        stateMachine.Player.Controller.Move(
            (movementDirection * movementSpeed) * Time.deltaTime
            );
    }

    // 회전
    private void Rotate(Vector3 movementDirection)
    {
        if (movementDirection != Vector3.zero)
        {
            Transform playerTransform = stateMachine.Player.transform;
            Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
            playerTransform.rotation = Quaternion.Slerp(playerTransform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
        }
    }

    // 이동 속도
    private float GetMovemenetSpeed()
    {
        float movementSpeed = stateMachine.MovementSpeed * stateMachine.MovementSpeedModifier;
        return movementSpeed;
    }

    // 플레이어 상태에 대한 애니메이션
    protected void StartAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, true);
    }

    protected void StopAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, false);
    }
}

 

 

플레이어가 땅에 있을 때

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

    // 입력에 따라 애니메이션의 bool값을 키고 끈다
    public override void Enter()
    {
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.GroundParameterHash);
    }

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

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

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

 

다시 플레이어의 기본 상태 스크립트인 PlayerIdleState로 돌아와서 생성자 처리를 해주고 오버라이드 한다.

public class PlayerIdleState : PlayerGroundedState
{
    public PlayerIdleState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0f; // 기본 상태이기 때문에 움직이지 않는다.
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
    }

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

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

 

유니티로 돌아와서 저번에 만들어둔 스크립터블 오브젝트인 Player를 생성한다.

 

Rotation Damping 값만 조절하고 나머지는 그대로 둘 것이다.

 

이 데이터를 플레이어 오브젝트에 넣고 캐릭터의 이동을 위해 Input을 처리하는 스크립트를 넣는다.

 

플레이어에 Character Controller를 추가한다.

Character Controller는 움직이는 것을 Rigid Body나 Collider를 사용하지 않고 간단하게 사용할 수 있다. 그러나 현실적인 움직임을 구현할 때는 적합하지 않다.

 

플레이어 모델에 Animator를 걸고 Player Animator Controller를 연결한다.

 

Create Sub-State Machine을 생성하여 Ground를 관리할 Sub-State Machine을 만든다.

 

Animation Data 값에 들어있던 것들을 Parameters에 Bool값으로 만들어준다.

 

각각 언제 true가 되고 false가 되는지 연결해준다.

 

StateMachine의 기능들을 사용하기 위해 Player 스크립트에 코드를 추가한다.

private void Start()
{
    Cursor.lockState = CursorLockMode.Locked;
    stateMachine.ChangeState(stateMachine.IdleState);
}

private void Update()
{
    stateMachine.HandleInput();
    stateMachine.Update();
}

private void FixedUpdate()
{
    stateMachine.PhysicsUpdate();
}

State를 걸어주면 curState가 저장되고 HandleInput(), Update(), PhysicsUpdate()가 동작하며 알맞은 동작을 한다.

Idle에 대한 State만 구상했기 때문에 아직은 기본 상태만 동작한다.

 

 

플레이어의 이동

이번에는 이동을 처리하는 스크립트를 만들어준다.

플레이어 State Machine에 걷는 상태와 뛰는 상태를 추가한다.

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

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

        IdleState = new PlayerIdleState(this);
        WalkState = new PlayerWalkState(this);
        RunState = new PlayerRunState(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;    }

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

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

}

protected virtual void OnMovementCanceled(InputAction.CallbackContext context)
{

}

 

땅 위의 상태에서 이동 입력이 들어오지 않았을 때를 처리한다.

// 땅 위에서 실행되지 않았을 때 처리
protected override void OnMovementCanceled(InputAction.CallbackContext context)
{
    // 입력이 안들어왔다면
    if (stateMachine.MovementInput == Vector2.zero)
    {
        return;
    }
    // 기본 상태로
    stateMachine.ChangeState(stateMachine.IdleState);

    base.OnMovementCanceled(context);
}

// 이동이 된다면 걷기 상태로
protected virtual void OnMove()
{
    stateMachine.ChangeState(stateMachine.WalkState);
}

 

플레이어의 걷기 상태

public class PlayerWalkState : PlayerGroundedState
{
    public PlayerWalkState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = groundData.WalkSpeedModifier;
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.WalkParameterHash);
    }

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

    protected override void OnRunStarted(InputAction.CallbackContext context)
    {
        base.OnRunStarted(context);
        stateMachine.ChangeState(stateMachine.RunState);
    }
}

 

플레이어의 뛰기 상태

public class PlayerRunState : PlayerGroundedState
{
    public PlayerRunState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = groundData.RunSpeedModifier;
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.RunParameterHash);
    }

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

 

기본 상태 스크립트에서 기본 상태에서 걷거나 뛰는 상태로 전환될 때의 처리를 하는 코드를 추가한다.

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

    if (stateMachine.MovementInput != Vector2.zero)
    {
        OnMove();
        return;
    }
}

 

키보드 입력에 따라 걷거나 뛴다.

 

 

이번 주차에서는 URP 3D RPG FSM게임을 만드는 것을 공부한다.

 

URP

URP란 Universal Render Pipeline의 약자로, 유니티에서 제작한 사전 빌드된 스크립트 가능한 렌더 파이프라인(Scriptable Render Pipeline)라고 한다.

솔직히 이 말만 보면 무슨 말인지 잘 모르겠어서 찾아보았다. Unity가 지원하는 모든 플랫폼에서 동작하도록 설계, 확장성이나 성능에 대한 그래픽 설정, 랜더링 기술, 높은 품질의 그래픽 등...

고사양 콘솔 뿐만 아니라 모바일, PC 등 다양한 플랫폼에 최적화 시키는데 특화된 것 같다.

 

FSM

FSM유한 상태 기계(Finite State Machine, FSM)를 나타내는 디자인 패턴으로, 상태와 상태 간의 전환을 기반으로 동작하는 동작 기반 시스템이라고 한다. 유한한 상태, 즉 정해진 상태를 어떤 특정한 조건에 맞춰 동작을 하도록 구성하는 것이 유한 상태 기계이다.

 

구성 요소

  • 상태 - 정지 상태, 이동 상태, 점프 상태 등
  • 전환 조건 - 이동 입력, 점프 입력, 충돌 등
  • 동작 - 이동 애니메이션 재생, 점프 처리, 이동 속도 조정 등

초기 상태 -> 입력, 시간, 조건 등 전환 조건에 따라 다른 동작으로 전환 -> 전환이 되면 조건에 맞는 상태의 동작 수행

 

장점

  • 일관된 행동 관리
  • 코드 재사용성과 유지 보수
  • 다양한 동작의 유기적인 조합으로 동작 구현

 


 

<사용하는 패키지>

  • Input System - 캐릭터 이동
  • Cinemachine - 카메라 이동
  • ProBuilder - 3D 모델링 수정 및 편집

다른건 사용해봤는데 프로빌더(ProBuilder)는 처음이라 어떤 것인지 찾아보았다.

프로빌더는 3D 모델을 유니티 내에서도 만들 수 있도록 제공하며 수정과 편집이 가능한 기능이다. 아직은 편하다고 할 수는 없지만 간단한 모델링을 만드는 것에는 좋다고 한다. 이번 프로젝트에서는 지형을 만들어볼 것이다.

 


ProBuilder 사용법

Project Manager에서 위의 패키지들을 다운 받은 뒤, Tools - ProBuilder - ProBuilder Window를 클릭하여 창을 연다.

 

New Shape로 3D 모델을 생성하고 재질과 색을 선택한다.

모델링의 상단에 모서리를 조절할 수 있는 버튼으로 경사면을 조절할 수 있다.

 

면을 조절할 수 있는 버튼을 누른 뒤, Extrude Faces를 누르면 새로운 면이 추가된다.

 

오브젝트를 전체 선택하고 Center Pivot를 누르면 오브젝트의 중심점을 선택할 수 있다.

 

3D 모델이 아래처럼 핑크색으로 나온다면 URP 관련된 Material 처리가 안된 것이다.

 

Window - Rendering - Render Pipeline Converter에서 항목을 체크하고 Initialize and Convert를 누른다.

URP나 HDRP에서 사용할 수 있도록 만든 에셋이 아닌 일반적인 에셋을 사용할 때, URP에서 사용할 수 있는 Material Texture로 바꿔주어야 사용할 수 있다.

 

 

추가로 리듬 게임을 꼭 한 번 쯤은 만들어보고 싶었는데 이번 공부를 통해 구현이 가능 할 것 같아서 개인 과제 때 만들어볼 예정이다.

 

 

'부트캠프 > Study' 카테고리의 다른 글

<Unity> 3D 게임 개발 심화 - RPG FSM(3)  (0) 2023.12.24
<Unity> 3D 게임 개발 심화 - RPG FSM(2)  (1) 2023.12.23
<Unity> UI  (0) 2023.12.14
<Unity> 숙련 - 2D 게임 개발(8)  (0) 2023.12.13
<Unity> 숙련 - 2D 게임 개발(7)  (1) 2023.12.12

+ Recent posts