✅ 오늘의 학습 목표
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 |
