AI에게는 여러 행동들이 있습니다. 일단 행동들의 부모가 될 추상 클래스 EnemyState스크립트와 IdleState(대기), PatrolState(순찰), ChaseState(추적), AttackState(공격)스크립트를 생성합니다.
// EnemyState.cs
public abstract class EnemyState
{
protected Enemy enemy;
public EnemyState(Enemy enemy)
{
this.enemy = enemy;
}
public abstract void Enter();
public abstract void Update();
public abstract void Exit();
}
// IdleState.cs
public class IdleState : EnemyState
{
// 생성자 문법
// public 생성자(매개변수) : 부모생성자호출 {생성자 내용}
// 생성자 내용이 빈 이유는 부모 생성자가 이미 다 해줘서
public IdleState(Enemy enemy) : base(enemy) { }
public override void Enter()
{
Debug.Log("적이 대기 상태로 진입");
}
public override void Update()
{
Debug.Log("적이 대기 행동을 하는 중");
}
public override void Exit()
{
Debug.Log("적이 대기 행동을 벗어남");
}
}
// PatrolState.cs
public class PatrolState : EnemyState
{
public PatrolState(Enemy enemy) : base(enemy) { }
public override void Enter()
{
Debug.Log("적이 순찰 상태로 진입");
}
public override void Update()
{
Debug.Log("적이 순찰 행동을 하는 중");
}
public override void Exit()
{
Debug.Log("적이 순찰 행동을 벗어남");
}
}
// ChaseState.cs
public class ChaseState : EnemyState
{
public ChaseState(Enemy enemy) : base(enemy) { }
public override void Enter()
{
Debug.Log("적이 추적 상태로 진입");
}
public override void Update()
{
Debug.Log("적이 추적 행동을 하는 중");
}
public override void Exit()
{
Debug.Log("적이 추적 행동을 벗어남");
}
}
// AttackState.cs
public class AttackState : EnemyState
{
public AttackState(Enemy enemy) : base(enemy) { }
public override void Enter()
{
Debug.Log("적이 공격 상태로 진입");
}
public override void Update()
{
Debug.Log("적이 공격 행동을 하는 중");
}
public override void Exit()
{
Debug.Log("적이 공격 행동을 벗어남");
}
}
// Enemy.cs
private EnemyState currentState;
protected override void Start()
{
// 처음은 대기상태
ChangeState(new IdleState(this));
base.Start();
}
private void Update()
{
currentState?.Update();
}
// EnemyState를 바꿔주는 함수
public void ChangeState(EnemyState newState)
{
currentState?.Exit(); // 현재 상태에서 벗어나고
currentState = newState; // 새로운 상태를 할당
currentState?.Enter(); // 새로운 상태에 진입
}
처음 생성될땐 대기 상태로 진입했다는 로그를 볼 수 있습니다.
임의로 2초 이상이 흐를 경우 대기 행동을 벗어나도록 했습니다. 그러면 2초간 Update함수가 호출되다가 Exit하게 됩니다.
플레이어가 일정범위안에 들어가고, 적이 볼 수 있는 시야(각도)내에 들어와야 합니다. 또한, 장애물이 있을 경우 감지하지 않은 것으로 처리될겁니다.
// Enemy.cs
private GameObject player;
private float fieldOfViewAngle = 120f;
public float sightRange = 10f;
public float attackRange = 2f;
protected override void Start()
{
player = GameObject.Find("Player");
maxHealth = enemyData.maxHealth;
ChangeState(new IdleState(this));
base.Start();
}
private void Update()
{
if (CanSeePosition(player.transform) && !IsObstacleBetween(player.transform) && IsInDistance(player.transform))
{
Debug.Log("플레이어 발견!!");
}
else
{
Debug.Log("찾는 중.....");
}
}
// 플레이어를 볼 수 있는 각도인지
public bool CanSeeAngle(Transform target)
{
Vector3 directionToTarget = (target.position - transform.position).normalized;
float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
// 각도는 좌우 대칭이므로 나누기 2
return angleToTarget <= fieldOfViewAngle / 2f;
}
// Raycast에 닿은 것이 플레이어인지 사물인지 (벽이나 다른 사물이라면 true)
public bool IsObstacleBetween(Transform target)
{
Vector3 direction = (target.position - transform.position).normalized;
float distance = Vector3.Distance(transform.position, target.position);
if (Physics.Raycast(transform.position, direction, out RaycastHit hit, distance))
{
return hit.transform != target;
}
return false;
}
// 감지 가능 거리 안에 있는지
public bool IsInDistance(Transform target)
{
return Vector3.Distance(transform.position, target.position) <= sightRange;
}
각도, 거리, 충돌에 따라 감지하는 로그가 띄워집니다.
적의 시야내에 플레이어가 온다면 추적 상태로 바꾸는 로직을 짜겠습니다.
// Enemy.cs
private bool isChase = false;
private void Update()
{
if (CanSeeAngle(player.transform) && !IsObstacleBetween(player.transform) && IsInDistance(player.transform))
{
if (!isChase)
{
// 시야내에 있다면 ChaseState로 변경
ChangeState(new ChaseState(this));
isChase = true;
}
}
else
{
// 추적 중 사라진다면 IdleState로 변경
if (isChase)
{
ChangeState(new IdleState(this));
isChase = false;
}
}
// isChase가 true일때 isChase의 Update를 실행함
if (isChase)
{
currentState.Update();
}
}
플레이어를 향해 회전하고 이동하는 코드를 짜봅시다.
// ChaseState.cs
private void Moving()
{
Vector3 direction = (enemy.target.transform.position - enemy.transform.position).normalized;
// y는 0으로 해서 수직상승을 하지 않게 함
enemy.rb.MovePosition(enemy.transform.position + new Vector3(direction.x, 0, direction.z) * enemy.enemyData.moveSpeed);
if (direction != Vector3.zero)
{
// y는 0으로 하여 돌아가지 않게 함
Quaternion targetRotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z));
enemy.transform.rotation = Quaternion.Slerp(enemy.transform.rotation, targetRotation, enemy.enemyData.rotationSpeed * Time.deltaTime);
}
}
// Enemy.cs
// 중간에 Enemy가 회전하지 않기 위한 설정
protected override void Start()
{
rb = GetComponent<Rigidbody>();
// 질량 증가 - 다른 물체에게 밀리지 않도록 무겁게 만듦
rb.mass = 10f;
// 공기 저항 - 이동 시 점점 느려지게 해서 부드러운 정지
rb.drag = 10f;
// 회전 저항 - 회전 시 빠르게 멈추게 해서 흔들림 방지
rb.angularDrag = 20f;
// X, Z축 회전 고정 - 앞뒤/좌우로 넘어지지 않게 (Y축 회전만 허용)
rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ;
// 무게중심을 아래쪽으로 이동 - 바닥에 가깝게 해서 안정성 증가
rb.centerOfMass = new Vector3(0, -0.5f, 0);
}
적 상태를 효율적으로 관리하기 위해 StateType을 생성합시다.
// StateType.cs
public enum StateType
{
Idle,
Chase,
Attack,
Patrol,
Dead
}
// EnemyState.cs
public abstract StateType CurrentStateType { get; }
// IdleState.cs
public override StateType CurrentStateType => StateType.Idle;
// PatrolState.cs
public override StateType CurrentStateType => StateType.Patrol;
// ChaseState.cs
public override StateType CurrentStateType => StateType.Chase;
// AttackState.cs
public override StateType CurrentStateType => StateType.Attack;
// Enemy.cs
private void Update()
{
switch(currentState.CurrentStateType)
{
case StateType.Idle:
DoIdle();
break;
case StateType.Patrol:
DoPatrol();
break;
case StateType.Chase:
DoChase();
break;
case StateType.Attack:
DoAttack();
break;
case StateType.Dead:
break;
default:
break;
}
currentState.Update();
}
// Idle일때 목표를 포착하면 Chase로
private void DoIdle()
{
if (CanSeeAngle(target.transform) && !IsObstacleBetween(target.transform) && IsInDistance(target.transform))
{
ChangeState(new ChaseState(this));
}
}
// Patrol일때 목표를 포착하면 chase로
private void DoPatrol()
{
if (CanSeeAngle(target.transform) && !IsObstacleBetween(target.transform) && IsInDistance(target.transform))
{
ChangeState(new ChaseState(this));
}
}
// Chase일때 공격범위 내에 들어오면 Attack으로, 나간다면 Idle로
private void DoChase()
{
if (IsInAttackRange(target.transform))
{
ChangeState(new AttackState(this));
}
else if (!IsInDistance(target.transform))
{
ChangeState(new IdleState(this));
}
}
// Attack일때 공격범위에서 나간다면 Idle로
private void DoAttack()
{
if (!IsInAttackRange(target.transform))
{
ChangeState(new IdleState(this));
}
}
플레이어를 포착하면 따라오고, 공격범위 내에 들어온다면 멈춰서 공격하는 모습입니다.
실제로 공격을 하고 데미지를 받는 것을 처리해봅시다. 여러가지 방법이 있지만, 전 OnTriggerEnter을 이용했습니다.
적이 공격하는 범위를 설정합니다.
AttackPoint라는 빈 오브젝트를 만들어 Collider를 넣고 IsTrigger를 체크합니다.
충돌을 관리할 AttackCollider 스크립트를 생성합시다.
// AttackCollider.cs
public class AttackCollider : MonoBehaviour
{
private Enemy enemy;
void Start()
{
enemy = GetComponentInParent<Enemy>();
// 시작할 때는 콜라이더 비활성화
GetComponent<Collider>().enabled = false;
}
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
other.gameObject.GetComponent<PlayerState>().ModifyHealth(-enemy.enemyData.damage);
Debug.Log($"플레이어 체력 {enemy.enemyData.damage} 감소!");
}
}
}
만약 공격범위내에 있다면 적은 공격을 시도하게 됩니다. 그 시도는 Collider를 활성화 하는 것으로 적용됩니다.
// AttackState.cs
private float currentCooldown;
public override void Enter()
{
currentCooldown = enemy.enemyData.attackCooldown;
}
public override void Update()
{
if (CanAttack())
{
enemy.PerformAttack();
}
}
private bool CanAttack()
{
currentCooldown -= Time.deltaTime;
if (currentCooldown <= 0)
{
Debug.Log("공격!");
currentCooldown = enemy.enemyData.attackCooldown;
return true;
}
return false;
}
투명한 원 부분이 적의 공격 범위 입니다.
다음 글에서는: