[게임 개발] Tiny 2D RPG: 4 - Map 템플릿 (2)

이정석·2023년 10월 3일

Tiny 2D RPG

목록 보기
4/9

Tiny 2D RPG

📖 [Map 템플릿 (1)]에서 맵의 충돌영역을 추출하는 기능을 만들었다면 '[Map 템플릿 (2)]'는 추출한 txt파일을 Scene에 적용시키는 단계를 구현하고자 한다.

아무것도 없는 Scene에서 맵의 식별번호같은 데이터를 넘기면 해당 맵을 랜더링하는 구조로 만들면 이후에 2개 이상의 맵이동을 구현할 때 굉장히 편하게 구현할 수 있을 것 같다.

첫번째 맵은 아래와 같이 하였는데 아래 구조라면 '벽에 막히는 것', '점프로 높은 지형에 올라가는 것', '발판이 없을 때' 등 여러가지를 시험할 수 있다.

1. MapManager

맵을 불러오고, 소멸시키는 것과 같은 기능을 담당하는 Manager역할을 하는 클래스를 만들어 두는 것이 좋을 것 같다고 생각하는데 맵에 대한 정보를 알고 있으면 어떤 좌표가 충돌영역에 해당하는지 이동하고자하는 위치로 이동이 가능한지에 대해서 알기 쉬운 구조로 게임을 만들 수 있다.

MapManager를 만들어 놓으면 이후에 몬스터에 대한 컨트롤러를 만든다 했을 때 몬스터의 이동판정도 Mapmanager에서 처리가능하다.

2. 좌표

[Map 템플릿 (1)]에서 Scene의 좌표영역에 대해서 얘기를 했었는데 구현할 MapManager에서 가지고 있을 충돌영역은 하나의 2차원 배열로 나타낼 수 있다. 아래 그림은 Scene과 MapManager의 좌표의 차이점을 나타낸 그림이다.

Scene의 좌표는 float로 0.3같은 실수값이 될 수 있고 MapManager의 인덱스로는 정수만 들어갈 수 있기 때문에 좌표변환과 정수변환작업이 필요하다.

정수변환과 좌표변환을 구현한 코드는 아래와 같다.

    public Vector2Int FindClosePos(Vector3 pos)
    {
        Vector2Int ret = new Vector2Int(0, 0);
        ret.x = (int)Mathf.Floor(pos.x);
        ret.y = (int)Mathf.Floor(pos.y);
        return ret;
    }

    public Vector2Int SceneToArr(Vector2Int pos)
    {
        Vector2Int ret = pos;
        ret.x = ret.x - MinX;
        ret.y = MaxY - ret.y - 1;
        return ret;
    }

    public Vector2Int ArrToScene(Vector2Int pos)
    {
        Vector2Int ret = pos;
        ret.x = ret.x + MinX;
        ret.y = MaxY - 1 - ret.y;
        return ret;
    }

Floor함수는 입력한 값보다 작은 정수 중 가장 큰 정수를 반환하는 함수로 이를 이용해 실수좌표를 정수좌표로 변환할 수 있다. 예를들어 [-0.5f, 0.7f]의 좌표는 Scene의 [-1,0]에 해당하고 Floor함수를 적용하면 알맞는 좌표로 쉽게 변환할 수 있다.

3. 불러오기

Prefab으로 존재하는 Map GameObject를 Scene으로 불러오고 맵에 맞는 txt의 정보를 읽는 코드는 아래와 같이 구현할 수 있다.

    public void LoadMap(int mapId)
    {
        DestroyMap();

        string mapName = "Map" + mapId.ToString("000");
        GameObject go = Managers.Resource.Instantiate($"Map/{mapName}");
        go.name = "Map";

        GameObject collision = Util.FindChild(go, "Collision", true);
        if (collision != null)
            collision.SetActive(false);

        // 충돌 영역 불러오기
        TextAsset txt = Managers.Resource.Load<TextAsset>($"Map/{mapName}");
        StringReader reader = new StringReader(txt.text);

        MinX = int.Parse(reader.ReadLine());
        MaxX = int.Parse(reader.ReadLine());
        MinY = int.Parse(reader.ReadLine());
        MaxY = int.Parse(reader.ReadLine());

        int xCount = MaxX - MinX + 1;
        int yCount = MaxY - MinY + 1;
        _collision = new bool[yCount - 1, xCount - 1];

        for (int y = 0; y < yCount; y++)
        {
            string line = reader.ReadLine();
            if (y == 0) continue;
            for (int x = 0; x < xCount; x++)
            {
                if (x == xCount - 1) continue;
                _collision[y - 1, x] = (line[x] == '1' ? true : false);
            }
        }
    }

주의해야 할 점은 Collision Object여부를 저장하는 _collision의 좌표 접근이 [y,x]라는 점이다. Scene의 좌표는 오른쪽으로 갈 수록 x값이 오르지만 _collision에는 2번째 인덱스가 증가하기 때문에 x가 두번째 인덱스가 되어야 한다.


충돌 판정

어느 지형에 서있거나 벽을 향해 움직일 때 캐릭터의 위치를 고정시킬 수 있어야 한다.

충돌영역에 대한 정보를 MapManager가 가지고 있기 때문에 이동하고자 하는 위치로 이동이 가능한지를 알아내기 위해 아래와 같은 함수를 정의할 수 있다. 입력으로는 실수 좌표를 받고 가장 가까운 정수좌표를 구한 뒤 _collision배열에 장애물 존재 여부를 반환한다.

    public bool CanGo(Vector3 Pos)
    {
        if (Pos.x < MinX || Pos.x >= MaxX) return false;
        if (Pos.y < MinY || Pos.y >= MaxY) return false;

        Vector2Int pos = FindClosePos(Pos);

        // 좌표 변환
        pos = SceneToArr(pos);

        return !_collision[pos.y, pos.x];
    }

위의 CanGo를 적용해 캐릭터의 이동을 적용하는 ApplyMove함수는 아래와 같이 바꿀 수 있다. 여기서 x축과 y축을 따로 적용하는 이유는 예를들어 벽 방향으로 이동하면서 점프한다 했을 때 x축으로는 이동하면 안되지만 y축으로는 이동을 해야하기 때문이다.

    void ApplyMove()
    {
        Vector3 nextPos = transform.position;

        // X 축
        nextPos += Time.deltaTime * new Vector3(_speedVec.x, 0, 0);
        if (!Managers.Map.CanGo(nextPos))
        {
        	// 이동 적용
        }
        transform.position = nextPos;

        // Y 축
        nextPos += Time.deltaTime * new Vector3(0, _speedVec.y, 0);
        if (!Managers.Map.CanGo(nextPos))
        {
        	// 이동 적용
        }

        transform.position = nextPos;
    }

1. 서있는 판정

캐릭터나 몬스터와 같은 GameObject가 지형위에 서있는 판정은 위에 구현한 CanGo함수를 이용하면 된다. y축으로 매우 작은 거리만큼 떨어진 곳에 충돌 Object가 있으면 어딘가에 서있다는 판단을 할 수 있다.

밑의 함수는 현재 위치에 서있는 판정을 하는 IsStand함수로 CanGo함수와 다르게 입력값이 현재 위치임을 주의해야 한다.

    public bool IsStand(Vector3 currentPos)
    {
        if (currentPos.x < MinX || currentPos.x >= MaxX) return false;
        if (currentPos.y < MinY || currentPos.y >= MaxY) return false;

        Vector3 standingPos = new Vector3(currentPos.x, currentPos.y - 0.01f, 0);

        return !CanGo(standingPos);
    }

2. 좌표 보정

위의 IsStand를 사용하려면 캐릭터가 점프하고 내려올 때 멈춘 지점이 0.01f이내에 있어야 한다는 조건이 필요하다. 하지만, 높은곳에서 떨어진 경우와 같이 떨어지는 속도가 빠를 경우 멈추는 지점과 땅과의 거리가 0.01f이내라는 보장을 할 수 없다. 그렇기 때문에 특정 두 지점을 이은 선과 장애물이 만나는 지점을 구한다음 구한 위치로 보정을 해야한다.

위치보정을 해야하는 상황을 나타낸 그림은 아래와 같다. 위치보정을 해야하는 상황은 위에서 떨어지거나 빠른 속도로 이동하는 상황 등 여러가지 상황에서 쓰일 수 있다.

시작점에서 도착점까지의 보정위치를 찾는 방법으로는 '이진탐색'을 사용하였다. 중앙점(mid)이 장애물에 포함되었는지 계산해가면서 빠른 시간내에 보정위치를 구할 수 있다. 하지만, 이진탐색 특성상 입력값인 to가 충돌영역안에 있어야 한다는 단점이 있다.

    public Vector3 GetCorrectionPos(Vector3 from, Vector3 to)
    {
        Vector3 ret = from;
        while (true)
        {
            Vector3 mid = (from + to) / 2;
            if ((mid - ret).magnitude < 0.01f)
            {
                return ret;
            }

            if (CanGo(mid))
            {
                ret = mid;
                from = mid;
            }
            else
            {
                to = mid;
            }
        }
    }

결과

Map 템플릿을 구현하는데 생각지도 못하게 플레이어의 이동 방식까지 변경해야 했는데 큰 어려움 없이 잘 구현한 것 같다.

원래 다음 단계는 서버를 붙이는 작업인데 서버를 붙이기 전에 몬스터와 몬스터의 공격판정까지 싱글게임으로 구현하고 그 다음으로 서버를 붙어볼 예정인데 게임로직적으로는 거의 같을 것이라고 생각하지만 나중에 구현할 때 직접 느끼는게 나을것이다. 아마도...

Map 템플릿 (3)으로 뒤의 배경에 대한 설정을 다루고 Map 템플릿을 마무리 할 예정이다.

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

0개의 댓글