Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(93일차) - [3D 게임] 플레이어 공격 구현 & 에너미 상태 머신 구현

by 독기품은토끼 2025. 10. 1.
✅ 오늘의 학습 목표
1. Player 공격 구현
2. Enemy 상태머신 구현

1. Attack

기존에 상태 패턴을 활용하여 스크립트를 잘 분리해놓았기 때문에

새로운 동작이 추가될 때에도 확장이 용이하다.

 

 

우선 Attack 애니메이션을 만들어주고

 

using UnityEngine;

public class PlayerSmbAttack : StateMachineBehaviour
{
    private PlayerController _playerController;

    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        if (_playerController == null) _playerController = animator.GetComponent<PlayerController>();
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        _playerController.SetState(Constants.EPlayerState.Idle);
    }
}

 

SMB도 연결해주었다.

 

[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
    private void Awake()
    {
        // ...
        // 상태 객체 초기화
        var playerStateIdle = new PlayerStateIdle(this, _animator, _playerInput);
        var playerStateMove = new PlayerStateMove(this, _animator, _playerInput);
        var playerStateJump = new PlayerStateJump(this, _animator, _playerInput);
        var playerStateAttack = new PlayerStateAttack(this, _animator, _playerInput);
        var playerStateHit = new PlayerStateHit(this, _animator, _playerInput);
        
        _states = new Dictionary<EPlayerState, ICharacterState>
        {
            { EPlayerState.Idle, playerStateIdle },
            { EPlayerState.Move, playerStateMove },
            { EPlayerState.Jump, playerStateJump },
            { EPlayerState.Attack, playerStateAttack },
            { EPlayerState.Hit, playerStateHit },
        };
        
        // ...
    }
}
using UnityEngine.InputSystem;
using UnityEngine;
using static Constants;

public class PlayerStateAttack : PlayerState, ICharacterState
{
    public PlayerStateAttack(PlayerController playerController, Animator animator, PlayerInput playerInput)
        : base(playerController, animator, playerInput) { }

    public void Enter()
    {
        _animator.SetTrigger(PlayerAniParamAttack);
        _playerInput.actions["Fire"].performed += AttackTrigger;
    }

    public void Exit()
    {
        _playerInput.actions["Fire"].performed -= AttackTrigger;
    }

    private void AttackTrigger(InputAction.CallbackContext context)
    {
        _animator.SetTrigger(PlayerAniParamAttack);
    }
}

 

idle, move 스크립트와 다른점은

애니메이션을 실행시킬 때 파라미터 타입이 트리거 타입이어서 SetTrigger로 선언해주었다.

 

그리고 attack은 4개의 콤보로 이루어진 공격이 있기 때문에

AttackTrigger을 구독하여 공격이 매끄럽게 이어지도록 구현해주었다.

 

protected void Attack(InputAction.CallbackContext context)
{
    _playerController.SetState(EPlayerState.Attack);
}

 

트리거는 한번만 발동되기 때문에

기존처럼 SetState()로 Attack을 갖고오게 되면 트리거 상태에서 빠져나오기 때문에 콤보 공격이 동작하지 않는다.

 

 

2. Enemy

 

Player 상태 머신을 만들어 주었던 것 처럼 Enemy 상태 머신도 만들어 주겠다.

우선 Enemy는 입력 값을 토대로 움직이는 것이 아니라 자동으로 움직이게 하기 위해서 NavMesh를 설치해준다.

 

public static class Constants
{
    // Enemy 상태
    public enum EEnemyState
    {
        None, Idle, Patrol, Chase, Attack, Hit, Dead
    }

    // Enemy 애니메이터 파라미터
    public static readonly int EnemyAniParamIdle = Animator.StringToHash("idle");
    public static readonly int EnemyAniParamPatrol = Animator.StringToHash("patrol");
    public static readonly int EnemyAniParamChase = Animator.StringToHash("chase");
    public static readonly int EnemyAniParamAttack = Animator.StringToHash("attack");
    public static readonly int EnemyAniParamHit = Animator.StringToHash("hit");
    public static readonly int EnemyAniParamDead = Animator.StringToHash("dead");
    public static readonly int EnemyAniParamMoveSpeed = Animator.StringToHash("move_speed");
}
using UnityEngine;
using UnityEngine.AI;

public class EnemyState
{
    protected EnemyController _enemyController;
    protected Animator _animator;
    protected NavMeshAgent _navMeshAgent;

    public EnemyState(EnemyController enemyController, Animator animator, NavMeshAgent navMeshAgent)
    {
        _enemyController = enemyController;
        _animator = animator;
        _navMeshAgent = navMeshAgent;
    }

}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using static Constants;

[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyController : MonoBehaviour
{
    private Animator animator;
    private NavMeshAgent agent;

    // 상태 관리
    public EEnemyState State { get; private set; }
    private Dictionary<EEnemyState, IState> states;

    private void Awake()
    {
        animator = GetComponent<Animator>();
        agent = GetComponent<NavMeshAgent>();

        // NavMeshAgent 설정
        agent.updatePosition = false;
        agent.updateRotation = true;

        // 상태 초기화
        var enemyStateIdle = new EnemyStateIdle(this, animator, agent);
        var enemyStatePatrol = new EnemyStatePatrol(this, animator, agent);
        var enemyStateChase = new EnemyStateChase(this, animator, agent);
        var enemyStateAttack = new EnemyStateAttack(this, animator, agent);

        states = new Dictionary<EEnemyState, IState>
        {
            { EEnemyState.Idle, enemyStateIdle },
            { EEnemyState.Patrol, enemyStatePatrol },
            { EEnemyState.Chase, enemyStateChase },
            { EEnemyState.Attack, enemyStateAttack },
        };

        SetState(EEnemyState.Idle);
    }

    public void SetState(EEnemyState state)
    {
        if (State == state) return;
        if (State != EEnemyState.None) states[State].Exit();
        State = state;
        if (State != EEnemyState.None) states[State].Enter();
    }
}

 

1. Idle 상태

Enemy는 처음에 가만히 서 있다가

플레이어가 탐지 범위 안에 들어오면 Chase 상태로 전환하고

일정시간마다 Patrol 상태로 전환되도록 구현해주려고 한다.

public class EnemyController : MonoBehaviour
{
    [Header("AI")]
    [SerializeField] private float patrolWaitTime = 1f;
    [SerializeField] private float patrolChance = 30f;
    [SerializeField] private float patrolDetectionDistance = 10f;

    // AI 관련
    public float PatrolWaitTime => patrolWaitTime;
    public float PatrolChance => patrolChance;
    public float PatrolDetectionDistance => patrolDetectionDistance;
}
using UnityEngine;
using UnityEngine.AI;
using static Constants;

public class EnemyStateIdle : EnemyState, ICharacterState
{
    private float _waitTime;

    public EnemyStateIdle(EnemyController enemyController, Animator animator, NavMeshAgent navMeshAgent)
        : base(enemyController, animator, navMeshAgent) { }

    public void Enter()
    {
        _waitTime = 0f;
        _navMeshAgent.isStopped = true; // 이거 해주는 이유? 이게 무슨 역할?
        _animator.SetBool(EnemyAniParamIdle, true);
    }

    public void Update()
    {
        // Idle -> Patrol 전환 조건
        if (_waitTime > _enemyController.PatrolWaitTime)
        {
            var randomValue = Random.Range(0, 100);
            if (randomValue < _enemyController.PatrolChance)
            {
                // 정찰 시작
                var patrolPosition = FindRandomPatrolPosition();

                // 정찰 위치가 현 위치에서 2Unit 이상 벗어날 경우 정찰 시작
                var realDistance = Vector3.Magnitude(patrolPosition - _enemyController.transform.position);
                var minimumDistance = _navMeshAgent.stoppingDistance + 2;
                if (realDistance > minimumDistance)
                {
                    _navMeshAgent.SetDestination(patrolPosition);
                    _enemyController.SetState(EEnemyState.Patrol);
                }
            }
            _waitTime = 0f;
        }
        _waitTime += Time.deltaTime;
    }

    public void Exit()
    {
        _animator.SetBool(EnemyAniParamIdle, false);
    }

    // 정찰 목적지를 반환하는 함수
    private Vector3 FindRandomPatrolPosition()
    {
        Vector3 randomDirection = Random.insideUnitSphere * _enemyController.PatrolDetectionDistance;
        randomDirection += _enemyController.transform.position;

        NavMeshHit hit;
        if (NavMesh.SamplePosition(randomDirection, out hit, _enemyController.PatrolDetectionDistance, NavMesh.AllAreas))
        {
            return hit.position;
        }
        else
        {
            return _enemyController.transform.position;
        }
    }
}

 

_waitTiem

Idle 상태가 시작되었을 때의 경과 시간을 0으로 초기화

나중에 Patrol이나 Chase 상태로 전환할 때 Idle 상태에서 얼마나 있었는지 판단하기 위해 사용한다.

 

_navMeshAgent.isStopped = true;

NavMashAgent는 적이 NavMesh 위를 따라 움직이게 해주는 Unity 내비게이션 컴포넌트이다.

isStopped = true를 하면 NavMeshAgent의 경로 탐색과 이동이 일시정지 된다.

즉, idle 상태에서는 적이 이동하지 않도록 멈추는 역할을 한다.

 

FindRandomPatrolPosition() : NavMesh 상의 무작위 점 찾기 메서드

 

  • Random.insideUnitSphere로 원형(3D 구) 안의 임의의 방향 벡터 생성
  • 현재 위치에 더해서 “현재 위치 주변의 랜덤 지점” 생성
  • NavMesh.SamplePosition()으로 NavMesh 상의 유효한 지점을 찾음
  • 성공하면 그 지점을 Patrol 목적지로 사용, 실패하면 제자리 반환
// EnemyController 스크립트
private void Update()
{
    if (State != EEnemyState.None)
    {
        states[State].Update();
    }
}

 

 

상태가 흐름에 맞게 변동될 수 있도록 Enemy Controller 스크립트의 Update()문에서 Enemy의 상태를 매 프레임마다 호출한다.

 

2. Patrol 상태

using UnityEngine;
using UnityEngine.AI;
using static Constants;

public class EnemyStatePatrol : EnemyState, ICharacterState
{
    private float _waitTime;

    public EnemyStatePatrol(EnemyController enemyController, Animator animator, NavMeshAgent navMeshAgent)
        : base(enemyController, animator, navMeshAgent) { }

    public void Enter()
    {
        _waitTime = 0f;
        _navMeshAgent.isStopped = false;
        _animator.SetBool(EnemyAniParamPatrol, true);
    }

    public void Update()
    {
        // Patrol -> Idle 전환 조건
        if (!_navMeshAgent.pathPending && _navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance)
        {
            _enemyController.SetState(EEnemyState.Idle);
        }

        _waitTime += Time.deltaTime;
    }

    public void Exit()
    {
        _animator.SetBool(EnemyAniParamPatrol, false);
    }
}

 

navMeshAgent.pathPending

현재 경로를 계산 중인지 체크

길을 찾는 중이 아니면서 남은 거리가 stoppingDistance보다 작으면 idle로 전환되도록 해주었다.

 

3. Chase

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using static Constants;

[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyController : MonoBehaviour
{
    [Header("AI")]
    [SerializeField] private float patrolWaitTime = 1f;
    [SerializeField] private float patrolChance = 30f;
    [SerializeField] private float patrolDetectionDistance = 10f;
    [SerializeField] private LayerMask detactionTargetLayerMask;
    [SerializeField] private float chaseWaitTime = 1f;
    [SerializeField] private float detectionSightAngle = 30f;
    [SerializeField] private float minimumRunDistance = 1f;

    // AI 관련
    public float PatrolWaitTime => patrolWaitTime;
    public float PatrolChance => patrolChance;
    public float PatrolDetectionDistance => patrolDetectionDistance;
    public float ChaseWaitTime => chaseWaitTime;
    public float DetectionSightAngle => detectionSightAngle;
    public float MinimumRunDistance => minimumRunDistance;
    
    private Collider[] _detectionResults = new Collider[1]; // 배열로 선언한 이유 : OverlapSphereNonAlloc 해당 함수가 배열로 선언된 함수여서

    // 일정 거리 안에 Player가 있는지 확인 후 있으면 반환
    // 있을 경우, 이미 찾은 상태면 기존 Player 반환
    // 없으면 null 반환
    public Transform DetectionTargetInCircle()
    {
        if (!_targetTransform)
        {
            Physics.OverlapSphereNonAlloc(transform.position,
                PatrolDetectionDistance, _detectionResults, detactionTargetLayerMask);
            _targetTransform = _detectionResults[0]?.transform;
        }
        else
        {
            float playerDistance = Vector3.Distance(transform.position, _targetTransform.position);
            if (playerDistance > PatrolDetectionDistance)
            {
                _targetTransform = null;
                _detectionResults[0] = null;
            }
        }
        return _targetTransform;
    }
}

 

우선 EnemyController 스크립트부터 수정해주었다.

 

DetectionTargetInCircle()

1. 현재 감지 대상이 없는 상태라면
→ OverlapSphereNonAlloc으로 주위에 플레이어가 있는지 감지
→ 감지된 첫 번째 Transform을 _targetTransform에 저장

2. 이미 대상이 있다면
→ 플레이어와 적의 거리를 재측정해서 범위 밖으로 나가면 null로 초기화

3. 결과적으로 현재 탐지 범위 안에 있는 플레이어의 Transform을 반환하거나, 없으면 null 반환

 

OverlapSphere vs OverlapSphereNonAlloc 차이

함수 이름 반환 방식 특징
Physics.OverlapSphere() Collider 배열 새로 생성해서 반환 편하지만 GC(가비지) 많이 발생 ❌
Physics.OverlapSphereNonAlloc() 미리 만들어둔 배열에 결과를 채움 할당 없이 빠름, GC 발생 없음 ✅
private Collider[] _detectionResults = new Collider[1];

 

우리 코드에서는 크기가 1인 배열을 미리 만들어두고 나중에 OverlapSphereNonAlloc()을 호출해주었다.

즉, 이 코드는 가장 가까운 플레이어 1명만 감지하겠다는 의미이다. (여러명이 감지되어도 맨 처음 감지된 녀석만 쫓아가겠다)

 


 

using UnityEngine;
using UnityEngine.AI;
using static Constants;

public class EnemyStateChase : EnemyState, ICharacterState
{
    private float _waitTime;

    public EnemyStateChase(EnemyController enemyController, Animator animator, NavMeshAgent navMeshAgent)
        : base(enemyController, animator, navMeshAgent) { }

    public void Enter()
    {
        _navMeshAgent.isStopped = false;
        _animator.SetBool(EnemyAniParamChase, true);
    }

    public void Update()
    {
        var detectionTargetTransform = _enemyController.DetectionTargetInCircle();
        if (detectionTargetTransform)
        {
            // 달리기 구현
            if (DetectionTargetInSight(detectionTargetTransform.position)
                && _navMeshAgent.remainingDistance > _enemyController.MinimumRunDistance)
            {
                _animator.SetFloat(EnemyAniParamMoveSpeed, 1);
            }
            else
            {
                _animator.SetFloat(EnemyAniParamMoveSpeed, 0);
            }

            _navMeshAgent.SetDestination(detectionTargetTransform.position);
        }
        else
        {
            _enemyController.SetState(EEnemyState.Idle);
        }

        _waitTime += Time.deltaTime;
    }

    public void Exit()
    {
        _animator.SetBool(EnemyAniParamChase, false);
    }

    //
    private bool DetectionTargetInSight(Vector3 position)
    {
        var cosTheta = Vector3.Dot(_enemyController.transform.forward,
            (position - _enemyController.transform.position).normalized);
        var angle = Mathf.Acos(cosTheta) * Mathf.Rad2Deg;

        if (angle < _enemyController.DetectionSightAngle)
        {
            return true;
        }
        else
        {
            return false;
        }
    }
}

 

이제 정찰이 제대로 동작될 수 있도록

플레이어의 위치를 계속 생신하는 부분을 구현하고

플레이어를 쫓아가는 로직을 구현해주었다.

 

 

[달리는 애니메이션]

 

  • DetectionTargetInSight() → 적의 시야각 안에 플레이어가 있는지 확인
  • remainingDistance → NavMeshAgent가 목표까지 남은 거리
    • MinimumRunDistance보다 크면 “달리는 중”으로 판단
    • 너무 가까우면 달리지 않고 정지 애니메이션을 재생

 

[NavMeshAgent 목적지 갱신]

플레이어의 현재 위치를 지속적으로 NavMeshAgent의 목적지로 설정

_navMeshAgent.SetDestination(detectionTargetTransform.position);

 

 

[DetectionTargetInSight()]

1. 적의 정면 방향 벡터(transform.forward)와
2. 적 → 플레이어 방향 벡터를 내적(Dot Product)해서 두 벡터 사이의 각도를 구함

  • 내적 값 (Dot Product)
    • 두 단위 벡터의 내적 = cos(θ)
    • 1에 가까울수록 같은 방향, 0이면 직각, -1이면 정반대 방향

3. Mathf.Acos()으로 각도 θ를 역으로 구함 (라디안 → 도 단위 변환)

4. 그 각도가 EnemyController에 설정된 DetectionSightAngle보다 작으면 시야 안에 있음으로 판단

 

 

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(32일차) - 2D 플랫포머 게임(7)

✅ 오늘의 학습 목표1. 몬스터 구현- 정찰 모드- 추적 모드- 공격 모드 1. Sound들어가기에 앞서 타운 씬에서 어드벤처 씬으로 이동될 때 사운드가 그대로 유지되도록 설정해 주겠다.public class SoundCo

toxicbunny.tistory.com

 

내적을 활용하지 않았을 때의 문제점은 위 포스팅에 적어두었다.

 


 

public class EnemyStateIdle : EnemyState, ICharacterState
{
    public void Update()
    {
        // Idle -> Chase 전환 조건
        var detectionTargetTransform = _enemyController.DetectionTargetInCircle();
        if (detectionTargetTransform && _waitTime > _enemyController.ChaseWaitTime)
        {
            _navMeshAgent.SetDestination(detectionTargetTransform.position);
            _enemyController.SetState(EEnemyState.Chase);
        }
        // Idle -> Patrol 전환 조건
        else if (_waitTime > _enemyController.PatrolWaitTime)
        {
            var randomValue = Random.Range(0, 100);
            if (randomValue < _enemyController.PatrolChance)
            {
                // 정찰 시작
                var patrolPosition = FindRandomPatrolPosition();

                // 정찰 위치가 현 위치에서 2Unit 이상 벗어날 경우 정찰 시작
                var realDistance = Vector3.Magnitude(patrolPosition - _enemyController.transform.position);
                var minimumDistance = _navMeshAgent.stoppingDistance + 2;
                if (realDistance > minimumDistance)
                {
                    _navMeshAgent.SetDestination(patrolPosition);
                    _enemyController.SetState(EEnemyState.Patrol);
                }
            }
            _waitTime = 0f;
        }
        _waitTime += Time.deltaTime;
    }
}
public class EnemyStatePatrol : EnemyState, ICharacterState
{
    public void Update()
    {
        // Patrol -> Chase 전환 조건
        var detectionTargetTransform = _enemyController.DetectionTargetInCircle();
        if (detectionTargetTransform && _waitTime > _enemyController.ChaseWaitTime)
        {
            _navMeshAgent.SetDestination(detectionTargetTransform.position);
            _enemyController.SetState(EEnemyState.Chase);
        }
        // Patrol -> Idle 전환 조건
        else if (!_navMeshAgent.pathPending && _navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance)
        {
            _enemyController.SetState(EEnemyState.Idle);
        }

        _waitTime += Time.deltaTime;
    }
}

 

Idle과 Patrol 상태에 Chase 상태로 전환되는 로직 추가

 

4. 기즈모 활용

// EnemyController 스크립트
// 기즈모를 이용해서 목적지가 어디에 만들어졌는지 체크
private void OnDrawGizmos()
{
    // 감지 범위
    Gizmos.color = Color.yellow;
    Gizmos.DrawWireSphere(transform.position, PatrolDetectionDistance);

    // 시야각
    Gizmos.color = Color.red;
    Vector3 rightDirection = Quaternion.Euler(0, detectionSightAngle, 0) * transform.forward;
    Vector3 leftDirection = Quaternion.Euler(0, -detectionSightAngle, 0) * transform.forward;
    Gizmos.DrawRay(transform.position, rightDirection * PatrolDetectionDistance);
    Gizmos.DrawRay(transform.position, leftDirection * PatrolDetectionDistance);
    Gizmos.DrawRay(transform.position, transform.forward * PatrolDetectionDistance);

    // Agent 목적지 표시
    if (_navMeshAgent != null && _navMeshAgent.hasPath)
    {
        Gizmos.color = Color.green;
        Gizmos.DrawSphere(_navMeshAgent.destination, 0.5f);
        Gizmos.DrawLine(transform.position, _navMeshAgent.destination);
    }
}

 

Enemy의 감지 범위, 시야각, NavMeshAgent의 목적지 위치를 기즈모로 그려주는 스크립트이다.

 

[기즈모 함수]

함수명 기능 파라미터
Gizmos.color 이후에 그릴 기즈모의 색상을 설정 Color color (ex: Color.red, Color.green)
Gizmos.DrawWireSphere 중심점과 반지름을 기준으로 **테두리만 있는 구(원)**을 그림 Vector3 center, float radius
Gizmos.DrawRay 특정 시작점에서 방향으로 뻗는 직선을 그림 Vector3 from, Vector3 direction
Gizmos.DrawSphere 특정 위치에 꽉 찬 구를 그림 (목적지 표시용) Vector3 center, float radius
Gizmos.DrawLine 두 점 사이에 직선을 그림 Vector3 from, Vector3 to