목차
게임에 필요한 상식
25.04.09
늦잠을 자는 바람에 앞 부분 수업을 조금 놓쳤다.. 따라서 PlayerController 구현 부분은 Github를 참고하여 작성했다.
PlayerController 구현
>> Attack 구현
: 연속으로 공격하지 않던 문제 해결 --> PlayerStateAttack.cs 코드 수정
>> Attack → Idle 구현
: Behaviour State를 활용하여 구현
>> Move → Idle 조건 변경
※ Camera와 Player의 거리를 3으로 조정
※ CustomEditor가 상태를 느리게 반영하는 문제 수정
: PlayerControllerEditor.cs 수정
Enemy 구현
└ EnemyController
: PlayerController와 같이 Old버전과 상태 패턴을 활용한 버전으로 나눠서 제작할 예정
- Old버전 : 하나의 Script에서 모든 상태를 관리
- 상태 패턴 : 상태 패턴을 활용하여 Class를 나눠서 관리
※ Enemy가 죽는 모션은 rag doll을 통해 만들 예정
>> 상태 변경 조건
- Idle → Patrol : 일정 주기로 랜덤하게 수를 뽑아서 Patrol을 할지 말지 결정 (수업에서는 1초 주기) --> Patrol하기로 결정되면 Patrol하고자 하는 목적지를 'NavMeshSurface'가 지정되어있는 영역 내에서 랜덤으로 설정하여 SetDestination()으로 이동함
- Patrol → Idle : 목적지에 도착
- Idle → Trace : 적을 발견하면 적 위치로 이동
- Patrol → Trace : 적을 발견하면 적 위치로 이동
- Trace → Attack : 적이 공격 범위 안에 들어오면 공격
- Attack → Trace : 공격 후, 다시 공격 범위를 체크하여 범위 밖이라면 적 위치로 이동
- Trace → Idle : 적이 근처에 없다면 그 자리에서 다시 Idle 상태로 변경
>> Enemy가 적을 찾아서 추적하는 방법
: 길찾기 알고리즘이 필요한데, 대표적으로 A*(A Star) Algorithm, Dijkstra(다익스트라) Algorithm이 있다.
--> 이번 코드에서는 Unity에서 A* Algorithm을 기반으로 미리 구현해놓은 'NavMesh'를 사용할 예정이다.
▶ NavMesh에 추가되어 있는 유용한 기능들
- 중간에 장애물 추가되어도 처리
- 있던 장애물이 삭제되어도 처리
- 점프를 해서 건너가야할 공간도 처리
- 오르막이라던지 계단과 같이 경사가 있어도 처리
>> EnemyControllerOld.cs 생성 및 코드 작성
※ 캐릭터의 체력이나 공격력 같은 데이터들은 'ScriptableObject'로 관리하기도 한다.
>> Chomper에 EnemyControllerOld 추가
--> EnemyControllerOld.cs에서 OnAnimatorMove()로 관리하기 때문에 Apply Root Motion이 Handled by Script로 바뀐 모습
└ Enemy Animation
>> Chomper의 Animator Controller 생성
: 이름은 'Chomper' --> 생성한 후, Chomper에 Animator Controller 바인딩
>> Animation 추가
- ChomperIdle : 이름을 'Idle'로 수정
- ChomperWalkForward : 이름을 'Patrol'로 수정
- ChomperAttack : 이름을 'Attack'으로 수정
- ChomperHit1 : 이름을 'Hit'으로 수정
- Blend Tree를 만들어서 이름을 'Trace'로 수정
--> 다음과 같이 Make Transition
>> Parameter를 추가하고, Transition 조건 설정
: Bool 타입으로 'Idle', 'Patrol', 'Trace' 추가 --> Idle, Patrol, Trace 간의 Transition만 우선 설정
>> 코드로 Animation 구현
: EnemyControllerOld.cs
└ NavMesh 사용하기
- NavMesh를 사용하기 위해 필요한 정보
- 길찾기를 할 지형에 대한 정보
- 길찾기 정보를 기반으로 움직이게 될 Object
※ Animation이 Transform 정보를 갖고 있느냐에 따라 구현이 조금 달라진다
: Animation을 실행하면 Object가 이동하는지, 제자리에 있는지에 따라 다르다 (Apply Root Motion의 체크 유무와도 관련)
1. Package Manager에서 'AI Navigation' 설치
2. 길찾기를 할 지형에 Component 추가
: 'Plane'에 'NavMeshSurface' 추가
- Agent : 움직이는 Object를 지칭하는 용어
- Radius : 움직일 Object가 이동할 수 있는 길을 결정해주는 범위 --> 만약 문이 설정된 Radius보다 좁다면 갈 수 없게 된다.
- Height : 움직일 Object가 이동할 수 있는 길을 결정해주는 높이 --> 만약 천장이 설정된 Height보다 낮다면 갈 수 없게 된다.
- Step Height : 움직일 Object가 이동할 수 있는 높이 --> 계단이나 턱이 있을 때 넘어갈 수 있는 높이
- Max Slope : 움직일 Obejct가 이동할 수 있는 경사각
--> 설정 완료 후 Bake (수업에서는 따로 수치를 건들지 않음)
--> Agent가 갈 수 있는 공간은 Scene에 파란색으로 표시된다.
3. 움직일 Agent에 Component 추가
: 'Chomper'에 'Nav Mesh Agent' 추가
- Speed : 이동 속도 --> Nav Mesh가 Agent를 설정한 목적지까지 직접 이동시키는데, 그때의 속도
- Angular Speed : 회전 속도
- Acceleration : 가속도
- Stopping Distance : Agent가 설정한 목적지에서 얼만큼 떨어진 위치에서 멈출지 --> 보통 Enemy는 Player를 찾으면 Player의 위치까지 가는 것이 아니라 Player의 위치 근처까지 가서 공격 범위를 확보한 상태에서 공격해야하기 때문 (0으로 설정하면 정확히 설정한 목적지까지 간다.)
- Auto Braking : Agent가 멈춰하는 상황에서 자동으로 처리해주는 기능 (수업에선 코드로 구현할 예정이라 체크 해제)
- Radius : Agent의 범위
- Height : Agent의 높이
4. 코드 작성
: EnemyControllerOld.cs
※ NavMesh 테스트
: 빈 게임 오브젝트로 'Target Position'을 생성하고 잘 보이도록 Icon을 설정한 후, Position을 옮겨준다.
--> 이후 Chomper에 Target Position 바인딩
--> 테스트 이후 작성한 테스트 코드와 만든 'Target Position'은 삭제
--> 코드의 OnAnimatorMove() 를 주석처리하고 Apply Root Motion을 체크 해제
- 테스트 코드
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public enum EnemyState { Idle, Patrol, Trace, Attack, Hit, Dead }
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyControllerOld : MonoBehaviour
{
[Header("Enemy")]
[SerializeField] private int attackPower = 1;
[SerializeField] private int maxHealth = 100;
[SerializeField] private Transform targetPosition; // Test
public Animator EnemyAnimator { get; private set; }
private int _currentHealth;
private EnemyState _currentState;
private NavMeshAgent _navMeshAgent;
private void Awake()
{
EnemyAnimator = GetComponent<Animator>();
_navMeshAgent = GetComponent<NavMeshAgent>();
}
private void Start()
{
_currentHealth = maxHealth;
_navMeshAgent.SetDestination(targetPosition.position); // Test
}
private void Update()
{
switch (_currentState)
{
case EnemyState.Idle:
break;
case EnemyState.Patrol:
break;
case EnemyState.Trace:
break;
case EnemyState.Attack:
break;
case EnemyState.Hit:
break;
case EnemyState.Dead:
break;
}
}
public void SetState(EnemyState newState)
{
switch (newState) // 새로운 State에 대한 처리
{
case EnemyState.Idle:
break;
case EnemyState.Patrol:
break;
case EnemyState.Trace:
break;
case EnemyState.Attack:
break;
case EnemyState.Hit:
break;
case EnemyState.Dead:
break;
}
switch (_currentState) // 기존의 State에 대한 처리
{
case EnemyState.Idle:
break;
case EnemyState.Patrol:
break;
case EnemyState.Trace:
break;
case EnemyState.Attack:
break;
case EnemyState.Hit:
break;
case EnemyState.Dead:
break;
}
_currentState = newState;
}
#region 동작 처리
// private void OnAnimatorMove()
// {
//
// }
#endregion
#region 디버깅
private void OnDrawGizmos()
{
}
#endregion
}
5. Layer 추가
: 이후, Ellen의 Layer를 Player로 지정하고 EnemyControllerOld의 Target Layer를 Player로 설정
└ 상태 패턴을 활용한 EnemyController 초기 세팅
>> EnemyController.cs 생성
: 기존에 구현한 EnemyControllerOld.cs는 전부 주석처리 --> EnemyState 등 겹치기 때문
※ EnemyControllerEditor.cs 에서 오류가 발생하는데, 이 상태로 수업이 종료됨
>> Interface 및 각 상태별 Script 생성
- EnemyStateIdle.cs
- EnemyStatePatrol.cs
- EnemyStateTrace.cs
- EnemyStateAttack.cs
- EnemyStateHit.cs
- EnemyStateDead.cs
최종 코드
>> EnemyControllerOld.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum EnemyState { Idle, Patrol, Trace, Attack, Hit, Dead }
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyControllerOld : MonoBehaviour
{
[Header("Enemy")]
[SerializeField] private int attackPower = 1;
[SerializeField] private int maxHealth = 100;
[Header("AI")]
[SerializeField] private LayerMask targetLayer;
[SerializeField] private float detectCircleRadius = 10f; // 감지 범위
[SerializeField] private float maxPatrolWaitTime = 3f; // Idle -> Patrol의 촤대 대기 시간
[SerializeField] private float detectSightAngle = 30f; // 감지 시야각
private Animator _enemyAnimator;
private NavMeshAgent _navMeshAgent;
private int _currentHealth; // 적의 체력
// 적의 현재 상태
private EnemyState _currentState;
public EnemyState CurrentState => _currentState;
// Player의 추적 위치를 업데이트 하는 Coroutine
private Coroutine _updateDestinationCoroutine;
private float _detectCircleRadiusSqr; // Player와의 거리를 비교하기 위한 변수
private float _patrolWaitTime; // 누적 Patrol 대기 시간
// ㅡㅡㅡ AI ㅡㅡㅡ
private Transform _playerTransform; // 감지된 Player의 Transform
private void Awake()
{
_enemyAnimator = GetComponent<Animator>();
_navMeshAgent = GetComponent<NavMeshAgent>();
_navMeshAgent.updatePosition = false; // navMesh가 직접 Agent의 Position을 옮기기 때문에 이를 비활성화
_navMeshAgent.updateRotation = true;
}
private void Start()
{
_currentHealth = maxHealth;
// 제곱 정도는 Mathf.Pow() 함수보다 직접 곱하는 것이 성능에 이점이 있다.
_detectCircleRadiusSqr = detectCircleRadius * detectCircleRadius;
_patrolWaitTime = 0f;
SetState(EnemyState.Idle);
}
private void Update()
{
switch (_currentState)
{
case EnemyState.Idle:
{
// 플레이어 감지
var detectPlayer = DetectPlayerInCircle();
if (detectPlayer)
{
_playerTransform = detectPlayer;
SetState(EnemyState.Trace);
break;
}
// 정찰 여부 판단
if (_patrolWaitTime > maxPatrolWaitTime && Random.Range(0, 100) < 30)
{
// 정찰하기로 결정
SetState(EnemyState.Patrol);
break;
}
_patrolWaitTime += Time.deltaTime;
break;
}
case EnemyState.Patrol:
{
// 플레이어 감지
var detectPlayer = DetectPlayerInCircle();
if (detectPlayer)
{
_playerTransform = detectPlayer;
SetState(EnemyState.Trace);
break;
}
// Patrol 위치에 도착하면 Idle 상태로 전환
// pathPending : 길찾기 연산 중 / ramainingDistance : 목적지와의 남은 거리
if (!_navMeshAgent.pathPending && _navMeshAgent.remainingDistance < 0.1f)
{
SetState(EnemyState.Idle);
break;
}
break;
}
case EnemyState.Trace:
{
// Player와 Enemy의 거리 계산
// 거리를 계산할 때, Vector3.Distance(transform.position, _playerTransform.position); 를 사용해도 된다.
// Magnitude는 루트 연산이 들어가기 때문에 Update()에서 계속 처리하기에는 무겁다.
// 따라서 sqrMagnitude를 사용했고 대신 거리를 비교할 때, 마찬가지로 제곱된 값으로 비교해야한다.
var playerDistanceSqr = (_playerTransform.position - transform.position).sqrMagnitude;
// Trace 중 시야에 플레이어가 들어오면 속도 증가
if (DetectPlayerInSight(_playerTransform))
{
_enemyAnimator.SetFloat("Speed", 1f);
}
else
{
_enemyAnimator.SetFloat("Speed", 0f);
}
// 일정 거리 이상으로 Player가 멀어지면 Idle로 전환
if (playerDistanceSqr > _detectCircleRadiusSqr)
{
SetState(EnemyState.Idle);
}
break;
}
case EnemyState.Attack:
{
break;
}
case EnemyState.Hit:
{
break;
}
case EnemyState.Dead:
{
break;
}
}
}
public void SetState(EnemyState newState)
{
switch (newState) // 새로운 State에 대한 처리 (Enter)
{
case EnemyState.Idle:
{
// 찾아야 할 Player 정보를 초기화
_playerTransform = null;
// Patrol 대기 시간 초기화
_patrolWaitTime = 0f;
// Idle 상태에서는 Agent의 이동을 중지
_navMeshAgent.isStopped = true;
// Idle Animation 재생
_enemyAnimator.SetBool("Idle", true);
break;
}
case EnemyState.Patrol:
{
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
var patrolPoint = FindRandomPatrolPoint();
if (patrolPoint == transform.position)
{
SetState(EnemyState.Idle);
break;
}
_navMeshAgent.isStopped = false;
_navMeshAgent.SetDestination(patrolPoint);
// Patrol Animation 재생
_enemyAnimator.SetBool("Patrol", true);
break;
}
case EnemyState.Trace:
{
// 감지된 Player를 향해 이동
_navMeshAgent.isStopped = false;
_updateDestinationCoroutine = StartCoroutine(UpdateDestination());
// Trace Animation 재생
_enemyAnimator.SetBool("Trace", true);
break;
}
case EnemyState.Attack:
{
break;
}
case EnemyState.Hit:
{
break;
}
case EnemyState.Dead:
{
break;
}
}
switch (_currentState) // 기존의 State에 대한 처리 (Exit)
{
case EnemyState.Idle:
{
// Idle Animation 종료
_enemyAnimator.SetBool("Idle", false);
break;
}
case EnemyState.Patrol:
{
// Patrol Animation 종료
_enemyAnimator.SetBool("Patrol", false);
break;
}
case EnemyState.Trace:
{
// Player의 위치를 갱신하는 Coroutine 중지
if (_updateDestinationCoroutine != null)
{
StopCoroutine(UpdateDestination());
_updateDestinationCoroutine = null;
}
// Trace Animation 종료
_enemyAnimator.SetBool("Trace", false);
break;
}
case EnemyState.Attack:
{
break;
}
case EnemyState.Hit:
{
break;
}
case EnemyState.Dead:
{
break;
}
}
_currentState = newState;
}
#region 적 감지
private Vector3 FindRandomPatrolPoint()
{
Vector3 randomDirection = Random.insideUnitSphere * detectCircleRadius;
randomDirection += transform.position;
NavMeshHit hit;
if (NavMesh.SamplePosition(randomDirection, out hit, detectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
else
{
return transform.position;
}
}
IEnumerator UpdateDestination()
{
while (_playerTransform)
{
_navMeshAgent.SetDestination(_playerTransform.position);
yield return new WaitForSeconds(0.5f);
}
}
// 일정 반경에 플레이어가 진입하면 플레이어 소리를 감지했다고 판단
private Transform DetectPlayerInCircle()
{
// 현재 위치에서 일정 반경 안에 Collider가 있으면 해당 Object의 Collider를 hitColliders에 담게 된다.
// LayerMask로 원하는 Collider만 구분하도록 처리
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayer);
if (hitColliders.Length > 0)
{
return hitColliders[0].transform;
}
else
{
return null;
}
}
// 일정 반경에 플레이어가 진입하면 시야에 들어왔다고 판단
private bool DetectPlayerInSight(Transform playerTransform)
{
if (playerTransform == null)
{
return false;
}
// Player와 Enemy 사이의 각 구하는 방법 (1)
// Vector3 direction = playerTransform.position - transform.position;
// float angle = Vector3.Angle(direction, transform.forward);
// Player와 Enemy 사이의 각 구하는 방법 (2)
var cosTheta = Vector3.Dot(transform.forward,
(playerTransform.position - transform.position).normalized);
var angle = Mathf.Acos(cosTheta) * Mathf.Rad2Deg;
if (angle < detectSightAngle)
{
return true;
}
else
{
return false;
}
}
#endregion
#region 동작 처리
private void OnAnimatorMove()
{
// Animator에서 Enemy가 움직이고 난 이후의 Position이 'position'에 할당
var position = _enemyAnimator.rootPosition; // rootPosition : Animator가 가지고 있는 해당 Position (좌표)
position.y = _navMeshAgent.nextPosition.y; // nextPosition : Agent를 이동시킬 때 다음에 이동할 위치
_navMeshAgent.nextPosition = position;
transform.position = position;
}
#endregion
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, detectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -detectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
}
#endregion
}
>> EnemyControllerEditor.cs
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(EnemyControllerOld))]
public class EnemyControllerEditor : Editor
{
public override void OnInspectorGUI()
{
// 기본 인스펙터를 그리기
base.OnInspectorGUI();
// 타겟 컴포넌트 참조 가져오기
EnemyControllerOld enemyControllerOld = (EnemyControllerOld)target;
// 여백 추가
EditorGUILayout.Space();
EditorGUILayout.LabelField("상태 디버그 정보", EditorStyles.boldLabel);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
// 상태별 색상 지정
switch (enemyControllerOld.CurrentState)
{
case EnemyState.Idle:
GUI.backgroundColor = new Color(0, 0, 1, 1f);
break;
case EnemyState.Patrol:
GUI.backgroundColor = new Color(0, 1, 0, 1f);
break;
case EnemyState.Trace:
GUI.backgroundColor = new Color(1, 0, 1, 1f);
break;
case EnemyState.Attack:
GUI.backgroundColor = new Color(1, 1, 0, 1f);
break;
case EnemyState.Hit:
GUI.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 1f);
break;
case EnemyState.Dead:
GUI.backgroundColor = new Color(1, 0, 0, 1f);
break;
}
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.LabelField("현재 상태", enemyControllerOld.CurrentState.ToString(),
EditorStyles.boldLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.EndVertical();
// Color 초기화
GUI.backgroundColor = Color.white;
}
// 인스펙터가 활성화될 때 에디터 업데이트 함수 등록
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
}
// 인스펙터가 비활성화될 때 에디터 업데이트 함수 해제
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
}
// 에디터 업데이트 시 인스펙터를 계속 새로고침하여 실시간으로 상태 반영
private void OnEditorUpdate()
{
if (target != null)
{
Repaint();
}
}
}
>> EnemyController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public enum EnemyState { None, Idle, Patrol, Trace, Attack, Hit, Dead }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class EnemyController : MonoBehaviour
{
}
>> IEnemyState.cs
public interface IEnemyState
{
void Enter(EnemyController enemyController);
void Update();
void Exit();
}
>> EnemyStateIdle.cs
using UnityEngine;
public class EnemyStateIdle : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
>> EnemyStatePatrol.cs
using UnityEngine;
public class EnemyStatePatrol : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
>> EnemyStateTrace.cs
using UnityEngine;
public class EnemyStateTrace : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
>> EnemyStateAttack.cs
using UnityEngine;
public class EnemyStateAttack : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
>> EnemyStateHit.cs
using UnityEngine;
public class EnemyStateHit : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
>> EnemyStateDead.cs
using UnityEngine;
public class EnemyStateDead : IEnemyState
{
public void Enter(EnemyController enemyController)
{
}
public void Update()
{
}
public void Exit()
{
}
}
'Development > C#' 카테고리의 다른 글
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 90일차 (0) | 2025.04.17 |
---|---|
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 89일차 (0) | 2025.04.04 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 88일차 (0) | 2025.04.03 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 87일차 (1) | 2025.04.02 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 79, 80일차 (0) | 2025.03.21 |