개발 노트 — 적 AI 설계
“다 똑같이 쫓아오기만 하면 재미없다”
던전 크롤러류 게임에서 몬스터가 전부 “플레이어를 향해 직진”만 한다면 금방 지루해집니다. 몬스터 종류가 몇 안 되더라도, 서로 다른 위협 패턴을 주는 것만으로 전투의 긴장감이 크게 달라집니다.
개인 프로젝트로 개발한 2D 슈팅 액션 게임 Enter the Room에는 몬스터가 3종류 있습니다. 셋 다 “플레이어를 추적한다”는 공통 행동은 있지만, 여기에 더해지는 행동이 다릅니다.
| 몬스터 | 기본 행동 | 추가 행동 |
|---|---|---|
| 방패 몬스터 | 추적 | 근접 시 돌진 |
| 단발 사수 | 추적 | 일정 거리에서 단발 사격 |
| 연사 사수 | 추적 | 일정 거리에서 3연발 사격 |
이 글에서는 “추적”이라는 공통 행동 위에 서로 다른 특수 행동을 얹는 구조를 어떻게 설계했는지 정리합니다.
핵심 아이디어: 상태(State) 기반으로 나누기
몬스터마다 완전히 다른 스크립트를 짜는 대신, “추적” 상태와 “공격” 상태를 분리하고, 몬스터 종류에 따라 “공격 상태에서 무엇을 하는지”만 다르게 구현하는 방식이 유지보수에 유리합니다.
public enum EnemyState
{
Chase,
Attack
}
public abstract class BaseEnemy : MonoBehaviour
{
protected EnemyState _state = EnemyState.Chase;
protected float _attackRange;
protected virtual void Update()
{
float distance = Vector2.Distance(transform.position, player.position);
_state = distance <= _attackRange ? EnemyState.Attack : EnemyState.Chase;
switch (_state)
{
case EnemyState.Chase:
MoveTowardPlayer();
break;
case EnemyState.Attack:
PerformAttack();
break;
}
}
protected void MoveTowardPlayer()
{
Vector2 direction = (player.position - transform.position).normalized;
transform.position += (Vector3)(direction * moveSpeed * Time.deltaTime);
}
// 몬스터 종류마다 다르게 구현할 부분
protected abstract void PerformAttack();
}
공통 로직(추적, 상태 전환)은 BaseEnemy에 두고, PerformAttack()만 몬스터 종류별로 다르게 구현하면 됩니다.
몬스터별 구현
방패 몬스터: 돌진형
public class ShieldEnemy : BaseEnemy
{
private bool _isDashing;
protected override void PerformAttack()
{
if (!_isDashing)
{
StartCoroutine(DashRoutine());
}
}
private IEnumerator DashRoutine()
{
_isDashing = true;
Vector2 dashDirection = (player.position - transform.position).normalized;
float elapsed = 0f;
while (elapsed < dashDuration)
{
transform.position += (Vector3)(dashDirection * dashSpeed * Time.deltaTime);
elapsed += Time.deltaTime;
yield return null;
}
yield return new WaitForSeconds(dashCooldown);
_isDashing = false;
}
}
근접 범위에 들어오면 플레이어 방향으로 일정 속도로 돌진하고, 쿨다운 후 다시 돌진할 수 있는 구조입니다.
단발 사수: 원거리 견제형
public class SingleShotEnemy : BaseEnemy
{
private float _fireCooldown;
protected override void PerformAttack()
{
if (_fireCooldown <= 0f)
{
FireProjectile();
_fireCooldown = fireRate;
}
_fireCooldown -= Time.deltaTime;
}
private void FireProjectile()
{
Vector2 direction = (player.position - transform.position).normalized;
var projectile = projectilePool.Get();
projectile.Launch(transform.position, direction, projectileSpeed);
}
}
일정 거리 안에 들어오면 멈춰서 조준 발사합니다. 돌진형과 달리 “다가오지 않고 거리를 유지하며 견제”하는 역할입니다.
연사 사수: 물량형
public class BurstShotEnemy : BaseEnemy
{
protected override void PerformAttack()
{
if (_fireCooldown <= 0f)
{
StartCoroutine(BurstFireRoutine());
_fireCooldown = fireRate;
}
_fireCooldown -= Time.deltaTime;
}
private IEnumerator BurstFireRoutine()
{
for (int i = 0; i < 3; i++)
{
FireProjectile();
yield return new WaitForSeconds(burstInterval);
}
}
}
단발 사수와 뼈대는 비슷하지만, 한 번 발사할 때 3연발이 나가도록 구성해서 “화력 밀도”로 위협을 주는 역할을 맡습니다.
이렇게 나눴을 때의 장점
- 공통 로직 재사용: 추적, 상태 전환 로직은
BaseEnemy에서 한 번만 구현 - 몬스터별 개성은
PerformAttack()만 다르게: 새 몬스터를 추가할 때도 이 부분만 새로 작성하면 됨 - 역할이 명확하게 나뉨: 돌진형(근접 압박), 단발형(견제), 연사형(물량) — 플레이어가 몬스터를 보고 “이건 이런 위협이구나”를 패턴으로 학습할 수 있음
몬스터 종류를 늘릴 때 고려할 점
몬스터가 3종에서 더 늘어난다면, 무작정 새 행동을 추가하기보다 “기존 행동의 조합으로 새로움을 만들 수 있는지”부터 검토하는 걸 추천합니다. 예를 들어 “돌진 + 연사”처럼 기존 행동을 섞은 하이브리드형은, PerformAttack() 안에서 상태를 하나 더 세분화하는 것만으로 구현할 수 있어 완전히 새로 짜는 것보다 효율적입니다.
행동 패턴이 있는 적 AI를 설계하실 때, “공통 상태 + 차별화 지점”으로 구조를 나눠보시면 코드도 깔끔해지고 새 적을 추가하기도 훨씬 수월해집니다.