Unity 플레이어와 몬스터 #002

주환서·2026년 2월 22일
post-thumbnail

Player 상태 구현

Animator Controller

코드 구현

// 변수 설정
public class CharacterMovement : MonoBehaviour
{
    Transform _transform;
    Animator _anim;
    float _h, _v, _r;

    [SerializeField] private float _moveSpeed = 5f;  // 초당 이동 거리
    [SerializeField] private float _turnSpeed = 60f; // 초당 회전 속도
// 이동 로직
        _r = Input.GetAxis("Mouse X");
        _transform.Rotate(Vector3.up * _r * _turnSpeed * Time.deltaTime);

        _h = Input.GetAxis("Horizontal");
        _v = Input.GetAxis("Vertical");

        if (_v != 0)
        {
            _transform.Translate(Vector3.forward * _v * _moveSpeed * Time.deltaTime);
        }
        if (_h != 0)
        {
            _transform.Translate(Vector3.right * _h * _moveSpeed * Time.deltaTime);
        }

        _anim.SetFloat("Forward", _v);
        _anim.SetFloat("Side", _h);

Monster 상태 구현

Animator Controller

코드 구현

public enum MonsterState
{
    IDLE,
    PATROL,
    CHASE,
    COMBAT,
    DAMAGED,
    DEAD
}

public class MonsterAI : MonoBehaviour
{
    [SerializeField] private MonsterState _state = MonsterState.IDLE;

    private Animator _anim;
    private Collider _collider;
    private NavMeshAgent _agent;

    // 현재 실행 중인 코루틴
    private Coroutine _currentCoroutine;

    [Header("[Idle State]")]
    [SerializeField] private float _idleTime = 0f;

    [Header("[Patrol State]")]
    [SerializeField] private GameObject _destObj; // 목적지
    [SerializeField] private Vector3 _patrolDest; // 목적지 좌표
    [SerializeField] private float _rotateSpeed = 120f; // 회전 속도
    [SerializeField] private float _patrolAccel = 8f;
    [SerializeField] private float _patrolRange = 5f; // 걷는 범위
    [SerializeField] private float _patrolMoveSpeed = 3f; // 이동 속도
    [SerializeField] private float _patrolAngularSpeed = 350f; // 회전 속도

    [Header("[Chase State]")]
    [SerializeField] private float _chaseSpeed = 8f; // 추격 속도
    [SerializeField] private float _chaseAccel = 8f; // 추격 속도
    [SerializeField] private float _chaseStoppingDistance = 2f; // 추격 속도
    [SerializeField] private float _chaseRange = 8f; // 추격 범위
    [SerializeField] private float _chaseAngularSpeed = 3f;
    private Vector3 _prevPosition; // 추격 시작 전 원래 위치
    private GameObject _target; // 플레이어 타겟

    [Header("[Combat State]")]
    [SerializeField] private float _attackRange = 3.5f; // 공격 사거리
    [SerializeField] private float _damage = 5f; // 공격력
    [SerializeField] private float _attackInterval = 2f; // 공격 쿨타임
    [SerializeField] private float _hp = 20f; // 체력

    // 데미지 처리
    private float _pendingDamage = 0f;
    private bool _isAlive = true; // 생존 여부
    public bool IsAlive { get { return _isAlive; } }

    [Header("Damaged State")]
    [SerializeField] private float _damageStunTime = 0.5f; // 불렛 피격 시 경직 시간

    [Header("Dead State")]
    [SerializeField] private float _deadTime = 2f; // 몬스터 사망 후 없어지는 시간

    // 애니메이터 컨트롤러 파라미터 들고오기
    private int _hashPatrol = Animator.StringToHash("Patrol");
    private int _hashChase = Animator.StringToHash("Chase");
    private int _hashCombat = Animator.StringToHash("Combat");
    private int _hashDamaged = Animator.StringToHash("Damaged");
    private int _hashDead = Animator.StringToHash("Dead");
    private void Start()
    {
        _anim = GetComponent<Animator>();
        _collider = GetComponent<Collider>();
        _agent = GetComponent<NavMeshAgent>();

        // 게임 시작 시 초기 상태
        if (_agent != null)
        {
            _agent.updateRotation = true;
            _agent.angularSpeed = _rotateSpeed;
        }
        ChangeState(_state);
    }
    private void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.CompareTag("Bullet"))
        {
            BulletController bullet = collision.gameObject.GetComponent<BulletController>();
            TakeDamage(bullet.Damage);
        }
    }
    // 상태를 변경하고, 상태에 맞는 코루틴을 시작하는 함수
    private void ChangeState(MonsterState monState)
    {
        // 상태 전환 하고 이전에 실행 중이던 코루틴이 있으면 멈춤(다른 상태랑 꼬이게 하지 않기 위해)
        if (_currentCoroutine != null)
        {
            StopCoroutine(_currentCoroutine);   // 다른 상태랑 중복 되지 않게 일단 한번 멈춤
        }

        // 기본 몬스터 상태 = monState;
        _state = monState;
        ApplyAgentSetting(_state);

        // 새로운 상태에 맞는 코루틴을 시작, _ingCoroutine에 저장
        switch (_state)
        {
            case MonsterState.IDLE: _currentCoroutine = StartCoroutine(Co_Idle()); break;
            case MonsterState.PATROL: _currentCoroutine = StartCoroutine(Co_Patrol()); break;
            case MonsterState.CHASE: _currentCoroutine = StartCoroutine(Co_Chase()); break;
            case MonsterState.COMBAT: _currentCoroutine = StartCoroutine(Co_Combat()); break;
            case MonsterState.DAMAGED: _currentCoroutine = StartCoroutine(Co_Damaged()); break;
            case MonsterState.DEAD: _currentCoroutine = StartCoroutine(Co_Dead()); break;
        }
    }

    private IEnumerator Co_Idle()
    {
        SetAnimation(); // 모든 애니메이션 초기화

        // 대기 상태에 이동 멈춤
        if (_agent) _agent.isStopped = true;

        // 대기할 시간
        float waitTime = Random.Range(2f, 4f);
        _target = GameObject.FindGameObjectWithTag("Player");
        float elapsed = 0f; // 경과 시간

        //if (_agent)
        //{
        //    _agent.isStopped = true;
        //}

        // Update 역할 while로 매 프레임 체크
        while (true)
        {
            // 플레이어가 chase 범위 안이면
            if (IsFindTarget(_chaseRange))
            {
                // chase 상태로 전환
                ChangeState(MonsterState.CHASE);
                yield break; // 코루틴 즉시 종료 다음 내용은 진행 하지 않음
            }

            // 대기 시간이 지났으면
            elapsed += Time.deltaTime;
            if (elapsed >= waitTime)
            {
                // patrol 상태로 전환
                ChangeState(MonsterState.PATROL);
                yield break; // 코루틴 즉시 종료
            }
            // 다음 프레임까지 대기. 대기 끝난 후 뭘할지 정함
            yield return null;
        }
    }

    private IEnumerator Co_Patrol()
    {
        SetAnimation(hashPatrol: true); // 걷기 파라미터 트루

        // 현재 위치 기준으로 랜덤한 목적지 설정
        _patrolDest = transform.position + new Vector3(
            Random.Range(-_patrolRange, _patrolRange),
            0f,
            Random.Range(-_patrolRange, _patrolRange)
        );

        //if (_agent)
        //{
        //    _agent.isStopped = false;
        //}

        // 따라갈 큐브 위치 이동
        if (_destObj) _destObj.transform.position = _patrolDest;
        _target = GameObject.FindGameObjectWithTag("Player");

        // 목적지 설정, 이동
        if (_agent)
        {
            _agent.isStopped = false;
            _agent.speed = _patrolMoveSpeed;
            _agent.SetDestination(_patrolDest);
        }

        // update역할 while로 매 프레임 확인
        while (true)
        {
            // chase 범위안에서 타겟을 찾았으면 상태 변경
            if (IsFindTarget(_chaseRange))
            {
                ChangeState(MonsterState.CHASE);
                yield break;
            }

            // 목적지 도착 체크
            if (_agent && !_agent.pathPending && _agent.remainingDistance <= 0.2f)
            {
                ChangeState(MonsterState.IDLE);
                yield break;
            }
            yield return null; // 대기 후 선택
        }
    }

    private IEnumerator Co_Chase()
    {
        SetAnimation(hashChase: true); // chase 파라미터 트루

        _prevPosition = transform.position; // 추격 시작전 위치 
        _target = GameObject.FindGameObjectWithTag("Player");

        // 추격 시작
        if (_agent)
        {
            _agent.isStopped = false;
            _agent.speed = _chaseSpeed;
        }

        // while 매프레임확인
        while (true)
        {
            // 타겟이 없으면(범위안에 없거나 죽으면) IDLE
            if (_target == null)
            {
                ChangeState(MonsterState.IDLE);
                yield break;
            }

            float dist = Vector3.Distance(transform.position, _target.transform.position);

            // 목적지 갱신
            if (_agent) _agent.SetDestination(_target.transform.position);

            // 공격 범위 췤
            if (dist <= _attackRange)
            {
                ChangeState(MonsterState.COMBAT); // 컴뱃 전환
                yield break;
            }

            // 범위 밖이면 
            if (dist > _chaseRange)
            {
                ChangeState(MonsterState.IDLE); // 대기 상태로
                yield break;
            }
            yield return null; // 대기 후 상태 전환
        }
    }

    private IEnumerator Co_Combat()
    {
        SetAnimation(hashCombat: true); // combat 파라미터 트루면
        _target = GameObject.FindGameObjectWithTag("Player");

        // 공격 중에는 이동을 하지 않음
        if (_agent)
        {
            _agent.isStopped = true;
            _agent.velocity = Vector3.zero;
        }

        // 공격 쿨타임 초기화
        float currentCooldown = _attackInterval;

        // update
        while (true)
        {
            // 플레이어가 없거나 죽었으면 IDLE
            if (_target == null || _target.GetComponent<Combat>().IsAlive == false)
            {
                ChangeState(MonsterState.IDLE);
                yield break;
            }

            // 공격 중 플레이어 바라보게 하려면 수동 회전 해야함
            Vector3 dirToTarget = (_target.transform.position - transform.position).normalized;
            RotatetoDirection(dirToTarget);

            // 쿨타임 감소
            currentCooldown -= Time.deltaTime;

            // 공격 가능 시간이 되었을 때
            if (currentCooldown <= 0)
            {
                float distTarget = Vector3.Distance(transform.position, _target.transform.position);

                // 공격 범위 밖이면
                if (distTarget > _attackRange)
                {
                    ChangeState(MonsterState.CHASE); // 다시 쫓아감
                    yield break;
                }

                // else
                // 실제 공격 수행
                _target.GetComponent<Combat>().TakeDamage(_damage);
                currentCooldown = _attackInterval; // 쿨타임 리셋
            }

            yield return null; // 대기 후 상태 설정
        }
    }

    private IEnumerator Co_Damaged()
    {
        _anim.SetTrigger(_hashDamaged); // 데미지 파라미터 트리거 발동
        _hp -= _pendingDamage; // 데미지 적용

        // 피격 시 정지
        if (_agent)
        {
            _agent.isStopped = true;
            _agent.velocity = Vector3.zero;
        }

        // 체력이 0 이하라면 사망 후 상태 전환
        if (_hp <= 0)
        {
            _isAlive = false;
            ChangeState(MonsterState.DEAD);
            yield break;
        }

        // 스턴 시간동안 대기 - 0.5초
        yield return new WaitForSeconds(_damageStunTime);

        // 조건에 따라 다음 행동 결정
        if (IsFindTarget(_chaseRange)) // 체이스 범위
        {
            if (IsFindTarget(_attackRange)) // 공격 범위
            {
                ChangeState(MonsterState.COMBAT); // 바로 컴뱃 상태
            }
            else
            {
                ChangeState(MonsterState.CHASE); // 아니면 추격
            }
        }
        else
        {
            ChangeState(MonsterState.IDLE); // 주변에 없으면 idle
        }
    }

    private IEnumerator Co_Dead()
    {
        _anim.SetTrigger(_hashDead); // dead 트리거 발동
        _collider.enabled = false; // 시체랑 충돌하지 않도록 콜라이더 끔

        // 죽으면 navmeshagent 끄기
        if (_agent) _agent.enabled = false;

        // 사망 모션이 끝날 때까지 대기 - 2초
        yield return new WaitForSeconds(_deadTime);

        // 오브젝트 없어짐
        Destroy(gameObject);
    }
    private void RotatetoDirection(Vector3 dir)
    {
        if (dir == Vector3.zero) return; // 방향이 없으면 회전 안 함
        Quaternion targetRot = Quaternion.LookRotation(dir);
        // 회전
        transform.rotation = Quaternion.Slerp(
            transform.rotation, targetRot, _rotateSpeed * Time.deltaTime);
    }
    

    // 범위 안에 타겟 있나 없나
    private bool IsFindTarget(float range)
    {
        if (_target)
        {
            float distTarget = Vector3.Distance(transform.position, _target.transform.position);
            if (distTarget < range)
            {
                return true;
            }
        }
        return false;
    }

    // 데미지 처리 함수
    public void TakeDamage(float damage)
    {
        _pendingDamage = damage;
        // 데미지를 입으면 현재 어떤 상태이든 DAMAGED 상태로 전환(에니메이터 컨트롤러에 ANY STATE)
        ChangeState(MonsterState.DAMAGED);
    }

    // 파라미터 한번에 세팅
    public void SetAnimation(bool hashPatrol = false, bool hashChase = false, bool hashCombat = false)
    {
        _anim.SetBool(_hashPatrol, hashPatrol);
        _anim.SetBool(_hashChase, hashChase);
        _anim.SetBool(_hashCombat, hashCombat);
    }

문제 해결 방법

  1. Monster가 Player에게 Chase 할 때 Idle 상태로 쭉 끌려 오는 문제가 있었습니다. Has Exit Time 체크 해제를 하지 않아서 애니메이션이 바뀌지 않는 문제 였습니다.
  2. Monster가 걸어 다닐 때 시선을 회전 하지 않고 걸어다니는 문제가 있었습니다. Rotate Speed의 값을 설정 하지 않아서 생긴 문제였습니다. Rotate Speed 값을 늘려주어서 해결 하였습니다.
  3. Monster의 상태 구현에서 Coroutine과 여러 함수들이 있는데 이것은 다음 포스팅들에서...

0개의 댓글