[게임 개발] Tiny 2D RPG: 8 - 몬스터 AI

이정석·2023년 10월 26일

Tiny 2D RPG

목록 보기
8/9

Tiny 2D RPG

📖 [몬스터 소환]에서 구현한 몬스터에 간단한 AI를 만들차례인데 우선 몬스터의 상태(State)에 대해서 먼저 정의하고 넘어가야할 필요가 있다.

  1. 대기(Idle): 주변에 플레이어가 없어 대기하고 있는 상태
  2. 이동(Walk): 플레이어를 발견해 이동중인 상태
  3. 공격(Attack): 공격 범위안에 들어와 공격중인 상태
  4. 점프(Jump): 벽에 가로막혀 점프중인 상태

이 중, 공격과 점프는 플레이어의 공격과 점프와 동일하고 상위 Controller인 Creature Controller에 구현이 되어있다.

위 상태들을 위해 Monster에 필요한 정보들은 아래와 같다. 추적하는 플레이어의 Controller Component인 _target, 탐색 범위를 나타내는 _searchRange, 공격 범위를 나타내는 _attackRange가 있다.

    // TODO: 이후에 PlayerController로 교체
    MyPlayerController _target = null;
    float _searchRange = 5.0f;
    float _attackRange = 0.8f;

1. 탐색

몬스터는 특정 상태에서 지속적으로 플레이어를 탐색해야 한다. 그리고 몬스터는 추적하는 플레이어를 바라보아야 한다. 예를들면, 플레이어가 왼쪽에서 오른쪽으로 넘어가면 몬스터는 오른쪽으로 고개를 틀어야한다.

이를 위해 플레이어의 방향으로 전환하는 SetDir()과 가장 가까운 플레이어를 찾는 SetPlayer()를 아래와 같이 구현하였다.

    protected override void UpdateController()
    {
        // 방향 설정
        switch (State)
        {
            case CreatureState.Idle:
            case CreatureState.Walk:
            case CreatureState.Jump:
                SetDir();
                SetPlayer();
                break;
        }

        base.UpdateController();
    }

    void SetDir()
    {
        if (_target == null)
            return;

        Vector3 nowPos = _target.Pos - Pos;

        if (nowPos.x < 0.0f)
        {
            Dir = MoveDir.Left;
        }
        else
        {
            Dir = MoveDir.Right;
        }
    }

    void SetPlayer()
    {
        MyPlayerController pc = Managers.Object.GetClosestPlayer(Pos, _searchRange);
        _target = pc;
    }

몬스터가 플레이어를 탐색할 때 몬스터가 직접 찾는 것 보다는 '플레이어 소환', '몬스터 소환'에서 만든 Object Manager에게 가장 가까운 플레이어 전달 받는 구조가 자연스러울 것이라 생각했다. 몬스터와 플레이어가 소환될 때 Object Manager를 통해 소환되고 Scene에 존재하는 모든 Object는 Object Manager에게 있기 때문에 플레이어의 위치는 쉽게 찾을 수 있다.

public class ObjectManager
{
    public MyPlayerController MyPlayer { get; set; }
    Dictionary<int, GameObject> _objects = new Dictionary<int, GameObject>();
	
    /* 생략 */

    public MyPlayerController GetClosestPlayer(Vector3 pos)
    {
        MyPlayerController ret = null;
        float retDist = 0.0f;
        foreach (GameObject obj in _objects.Values)
        {
            MyPlayerController mpc = obj.GetComponent<MyPlayerController>();
            if (mpc == null)
                continue;

            if (ret == null)
            {
                ret = mpc;
                retDist = (mpc.Pos - pos).magnitude;
            }
            else
            {
                float tDist = (mpc.Pos - pos).magnitude;
                if (retDist > tDist)
                {
                    ret = mpc;
                    retDist = tDist;
                }
            }
        }
        return ret;
    }

    public MyPlayerController GetClosestPlayer(Vector3 pos, float range)
    {
        MyPlayerController rmpc = GetClosestPlayer(pos);
        if (rmpc == null)
            return null;

        if ((rmpc.Pos - pos).magnitude > range)
            return null;
        return rmpc;
    }
}

편의상 GetClosestPlayer()는 2개의 버전을 만들었고 '탐지범위를 받는 버전''받지 않는 버전'을 만들었다.

2. 대기

대기 상태의 상태전이는 _target이 있을 경우에만 이루어 진다. 그리고 몬스터와 플레이어의 거리가 공격 사정거리 안에 있을 때에는 공격을 그렇지 않다면 이동 상태로 전이한다.

위의 상태는 플레이어가 몬스터의 머리 바로 위에 있을 때는 공격을 하는 구조이지만, 실제 공격판정은 나중에 서버를 붙일 때 서버에서 연산을 할 것이고 서버 계획은 nodejs로 하기 때문에 지금 당장은 간단하게 구현하였다.

    protected override void UpdateIdle()
    {
        if (_target == null)
            return;

        if ((_target.Pos - Pos).magnitude < _attackRange)
        {
            State = CreatureState.Attack;
        }
        else
        {
            State = CreatureState.Walk;
        }
    }

3. 이동

이동같은 경우는 대기 상태와 다르게 점프 상태에 대한 전이가 이루어지는데 '앞으로 이동중 앞에 벽이 있으면 점프를 시도한다.'는 방식으로 상태를 전이하도록 하였다. 공격 판정은 대기 상태와 같다.

    protected override void UpdateWalk()
    {
        if (_target == null)
        {
            State = CreatureState.Idle;
            return;
        }

        // 점프 판정
        if (Managers.Map.CanGo(GetFrontPos()) == false)
        {
            State = CreatureState.Jump;
            return;
        }

        // 공격 판정
        if ((_target.Pos - Pos).magnitude < _attackRange)
        {
            State = CreatureState.Attack;
        }
    }
    
    Vector3 GetFrontPos()
    {
        Vector3 ret = Pos;

        if (Dir == MoveDir.Left)
        {
            ret.x -= 0.1f;
        }
        else if (Dir == MoveDir.Right)
        {
            ret.x += 0.1f;
        }

        return ret;
    }

[Map 템플릿 (2)]에서 Map의 충돌 영역에 대한 판정을 위해 CanGo()라는 함수를 만들었는데 이 함수를 이용해서 앞에 벽이 있는지를 확인한다.

이 함수는 처음에는 바닥과의 충돌 판정을 위해 만들었는데 앞에 있는 장애물을 판정하는데에도 활용할 수 있을 것 같아서 사용해봤는데 잘 작동하는 것을 확인할 수 있다.


결과

처음 실행 했을 때는 몬스터가 가만히 서 있는다.

플레이어가 탐지범위 안에 들어올 경우 플레이어를 향해 걸어온다.

공격 사정거리안에 들어오면 몬스터를 캐릭터를 향해 공격을 한다.

이동 중 벽을 만나면 점프를 해 계단위로 올라간다.

이것으로 싱글플레이 RPG를 거의 마무리하였는데 체력 판정이나 몬스터 소멸같은 기능은 서버를 붙이면서 추가할 예정이기 때문에 이제 유니티 작업보다는 프로젝트 초기에 설명했던 SocketIO 에셋과 nodejs 서버에 대한 정보조사와 구조를 알아보아야 할 것 같다.

profile
게임 개발자가 되고 싶은 한 소?년

0개의 댓글