몬스터 3종에게 서로 다른 성격을 부여하는 법 — 상태 기반 AI 설계

개발 노트 — 적 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연발이 나가도록 구성해서 “화력 밀도”로 위협을 주는 역할을 맡습니다.

이렇게 나눴을 때의 장점

  1. 공통 로직 재사용: 추적, 상태 전환 로직은 BaseEnemy에서 한 번만 구현
  2. 몬스터별 개성은 PerformAttack()만 다르게: 새 몬스터를 추가할 때도 이 부분만 새로 작성하면 됨
  3. 역할이 명확하게 나뉨: 돌진형(근접 압박), 단발형(견제), 연사형(물량) — 플레이어가 몬스터를 보고 “이건 이런 위협이구나”를 패턴으로 학습할 수 있음

몬스터 종류를 늘릴 때 고려할 점

몬스터가 3종에서 더 늘어난다면, 무작정 새 행동을 추가하기보다 “기존 행동의 조합으로 새로움을 만들 수 있는지”부터 검토하는 걸 추천합니다. 예를 들어 “돌진 + 연사”처럼 기존 행동을 섞은 하이브리드형은, PerformAttack() 안에서 상태를 하나 더 세분화하는 것만으로 구현할 수 있어 완전히 새로 짜는 것보다 효율적입니다.

행동 패턴이 있는 적 AI를 설계하실 때, “공통 상태 + 차별화 지점”으로 구조를 나눠보시면 코드도 깔끔해지고 새 적을 추가하기도 훨씬 수월해집니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤