좌표 계산 시스템

목차

1. 요구 사항

2. 흐름도

3. 구현

    3.1. 셀 기반 좌표 계산

    3.2. Edge 기반 좌표 계산

4. 개발 의도

1. 시스템 요구 사항

Grid 기반 건축 시스템에서 어디에 놓을 수 있는가를 판단하려면, 먼저 이 구조물이 실제로 어떤 좌표들을 차지하는가를 정확하게 계산해야 한다.

플레이어는 단 하나의 기준 좌표를 클릭하지만, 실제로 구조물은 그 한 칸에만 존재하지 않는다.

구조물의 크기(size)와 회전(rotation)에 따라 여러 셀을 동시에 점유할 수 있고, 벽처럼 셀 내부가 아니라 셀과 셀 사이의 경계선(Edge)을 점유하는 경우도 존재한다.

따라서 좌표 계산 시스템은 단순히 기준 위치를 반환하는 것이 아니라, 구조물의 실제 점유 영역 전체를 계산해서 반환하는 역할을 수행해야 한다.

이 시스템은 다음을 반드시 만족해야 한다.

첫째, 구조물의 크기와 회전을 반영하여 점유 좌표를 정확하게 계산해야 한다.

둘째, 셀 기반 구조물과 Edge 기반 구조물을 각각 다른 방식으로 계산해야 한다.

셋째, 계산된 좌표는 이후 공간 검사, 배치, 제거, 저장 시스템에서 그대로 사용되므로 항상 일관된 기준을 가져야 한다.

GetCellPositions와 GetEdgePositions는 단순 보조 함수가 아니라, 건축 시스템 전체에서 사용하는 좌표 해석의 기준 정의 함수다.

2. 흐름도

플레이어가 위치 선택

   ↓

기준 Grid 좌표 확보 (currentTilePosition)

   ↓

구조물 크기(size)와 회전(rotation) 전달

   ↓

셀 기반인가?

   ├─ YES → GetCellPositions

   │        ↓

   │       점유할 모든 셀 좌표 계산

   │

   └─ NO → GetEdgePositions

            ↓

           점유할 모든 Edge 계산

   ↓

계산된 좌표 반환

   ↓

공간 검사 / 배치 / 제거 시스템에서 사용

이 흐름에서 중요한 점은, 좌표 계산이 모든 시스템의 출발점이라는 것이다.

공간 검사도, 실제 배치도, 저장도 전부, 이 함수가 반환한 좌표를 기준으로 동작한다.

즉 이 함수가 틀리면 시스템 전체가 틀린다.

3. 구현

3.1. 셀 기반 좌표 계산
public List<Vector3Int> GetCellPositions(Vector3Int currentTilePosition, Vector2Int objectSize, int rotation)
{
    IEnumerable<int> xRange = null;
    IEnumerable<int> zRange = null;
    objectSize -= Vector2Int.one;

    if (rotation == 0)
    {
        xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.x, 1);
        zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.y, 1);
    }
    else if (rotation == 90)
    {
        objectSize = new Vector2Int(objectSize.y, objectSize.x);
        xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.x, 1);
        zRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.y, 0, 1);
    }
    else if (rotation == 180)
    {
        xRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.x, 0, 1);
        zRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.y, 0, 1);
    }
    else if (rotation == 270)
    {
        objectSize = new Vector2Int(objectSize.y, objectSize.x);
        xRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.x, 0, 1);
        zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.y, 1);
    }

    List<Vector3Int> positions = new List<Vector3Int>();
    foreach (int x in xRange)
    {
        foreach (int z in zRange)
        {
            Vector3Int offset = new Vector3Int(x, 0, z);
            positions.Add(currentTilePosition + offset);
        }
    }
    return positions;
}

이 함수는 셀 기반 구조물이 실제로 점유하게 될 모든 Grid 좌표를 계산하는 함수다.

입력으로는 기준 위치(currentTilePosition), 구조물 크기(objectSize), 회전(rotation)이 들어오고, 출력으로는 해당 구조물이 차지하는 모든 Vector3Int 좌표 리스트가 반환된다.

이 함수의 핵심은 기준 좌표 하나를 받아서 오프셋(offset)을 계산한 뒤, 그 오프셋을 기준 위치에 더해서 실제 점유 좌표를 만들어내는 구조다.

가장 먼저 범위 계산을 위한 크기 보정이다.

objectSize -= Vector2Int.one;

이 코드는 단순한 값 변경처럼 보이지만 실제로는 범위 계산 방식과 깊게 연결되어 있다.

이 함수는 이후 MoveMinToMaxInclusive 같은 inclusive 범위 생성 함수를 사용한다.

inclusive 범위는 시작값과 끝값을 모두 포함하기 때문에, 크기를 그대로 사용하면 실제보다 한 칸 더 많은 좌표가 생성된다.

예를 들어 size가 (2,2)라면 실제 점유는 2칸이어야 하지만, 그대로 사용하면 3칸이 생성될 수 있다.

이를 방지하기 위해 미리 1을 빼서 범위 계산을 보정한다.

이 코드는 단순한 감소가 아니라, 좌표 범위 생성 방식에 맞춘 보정 처리다.

다음으로 IEnumberable 기반 범위 생성 부분이다.

IEnumerable<int> xRange = null;
IEnumerable<int> zRange = null;

이 변수들은 단순 배열이 아니라, 자연 실행되는 반복 시퀀스다.

IEnumerable을 사용한 이유는 범위 데이터를 즉시 생성하지 않고, 실제 반복이 수행되는 시점에 값을 생성하기 위함이다.

이 방식은 불필요한 리스트 생성을 피할 수 있어 메모리 사용을 줄일 수 있으며, 동시에 반복 로직을 선언적으로 표현할 수 있다.

특히 회전에 따라 범위 생성 방식이 달라지는 구조에서, 반복 구조 자체를 함수로 분리하고 지연 실행으로 처리함으로써 코드의 복잡도를 낮추는 효과가 있다.

C#의 IEnumerable<T>는 실제 데이터를 미리 생성하지 않고, 반복 시점에 값을 만들어낸다.

IEnumerable<int>는 C#에서 반복 가능한 시퀀스를 의미한다.

GridSelectionHelper의 함수들은 특정 범위를 순회하는 값을 반환하며, 이를 통해 for문 없이도 범위 기반 반복을 구성할 수 있다.

그 다음 중요한 부분은 회전에 따른 분기다.

if (rotation == 0)
{
    xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.x, 1);
    zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.y, 1);
}
else if (rotation == 90)
{
    objectSize = new Vector2Int(objectSize.y, objectSize.x);
    xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.x, 1);
    zRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.y, 0, 1);
}
else if (rotation == 180)
{
    xRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.x, 0, 1);
    zRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.y, 0, 1);
}
else if (rotation == 270)
{
    objectSize = new Vector2Int(objectSize.y, objectSize.x);
    xRange = GridSelectionHelper.MoveMaxToMinInclusive(-objectSize.x, 0, 1);
    zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, objectSize.y, 1);
}

rotation 값에 따라 xRange와 zRange를 다르게 설정하고 있다.

'0부터 size까지 증가한다' 또는 '-size부터 0까지 감소한다' 라는 의도가 함수 이름으로 바로 드러난다.

회전이 0도일 때는 단순히 x와 z 모두 양의 방향으로 확장된다.

여기서 GridSelectionHelper를 사용하였다.

GridSelectionHelper.MoveMinToMaxInclusive(...)

이 부분은 단순 반복이 아니라, 좌표 생성 책임을 별도 클래스에 위임한 구조다.

이걸 직접 for문으로 구현하면 '0도 / 90도 / 180도 / 270도' 각각 다른 반복 구조로, 코드 중복이 심해진다.

그래서 범위 생성 로직을 외부로 분리했다.

90도, 270도에서는 다음 코드가 추가된다.

objectSize = new Vector2Int(objectSize.y, objectSize.x);

회전이 발생하면 구조물의 가로/세로가 뒤바뀌기 때문에,좌표 계산 기준도 함께 바뀌어야 한다.

이 처리가 없으면 회전 후에도 원래 크기 기준으로 좌표가 계산되어 잘못된 점유 영역이 생성된다.

이 한 줄은 단순 값 변경이 아니라, 회전된 구조물의 실제 공간 형태를 반영하는 핵심 로직이다.

180도와 270도에서는 범위가 음수 방향으로 확장된다.

이는 기준 좌표를 중심으로 뒤쪽이나 왼쪽으로 구조물이 배치되는 상황을 반영한다.

이 함수는 단순히 크기만 반영하는 것이 아니라, 회전에 따라 좌표 확장 방향 자체를 바꾸는 구조다.

다음은 실제 좌표 생성 부분이다.

foreach (int x in xRange)
{
    foreach (int z in zRange)
    {
        Vector3Int offset = new Vector3Int(x, 0, z);
        positions.Add(currentTilePosition + offset);
    }
}

이중 반복문에서는 xRange와 zRange를 조합해 실제 좌표를 만든다.

이중 반복문은 xRange와 zRange의 모든 조합을 순회하며, 결과적으로 생성되는 좌표의 개수는 (가로 크기 × 세로 크기)와 동일하다.

예를 들어 구조물 크기가 (2,2)라면 총 4개의 좌표가 생성되며, 각 좌표는 동일한 구조물의 점유 영역을 구성하게 된다.

이 반복 구조는 단순히 좌표를 나열하는 것이 아니라, 구조물이 Grid 상에서 실제로 차지하는 모든 셀을 완전히 열거하는 과정이다.

각 반복에서 offset을 만들고, 이를 기준 위치에 더해 최종 좌표를 생성한다.

여기서 중요한 점은 'offset 기반 좌표 생성'이라는 것이다.

구조물의 점유 영역은 항상 '기준 좌표 + 상대 좌표(offset)' 방식으로 계산된다.

‍이 구조를 이용하면, 회전 대응이 쉬워지며, 크기 확장이 쉬워진다.

또한, 좌표 계산 일관성을 유지할 수 있다.

여기서 y값을 0으로 고정하는 이유는, 이 Grid 시스템이 실제로는 XZ 평면 기반으로 동작하기 때문이다.

Unity의 Vector3Int를 사용하지만, 실제 공간 계산은 2차원 Grid 상에서 이루어지므로 y축은 높이 개념이 아니라 무시되는 축으로 처리된다.

마지막으로 position을 반환한다.

이 함수가 반환하는 List<Vector3Int>는 추후에 IsSpaceFree, IsSpaceOccupied, AddCellObject, RemoveCellObject 함수에서 그대로 사용된다.

GetCellPositions 함수는 셀 기반 구조물의 점유 영역을 정의하는 기준 함수이다.

3.2. Edge 기반 좌표 계산
public List<Edge> GetEdgePositions(Vector3Int currentTilePosition, Vector2Int size, int rotation)
{
    IEnumerable<int> xRange = null;
    IEnumerable<int> zRange = null;
    Vector3Int edgeDirection = Vector3Int.zero;

    Vector2Int calculatedSize = size - Vector2Int.one;

    if (rotation == 0)
    {
        xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.x, 1);
        zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.y, 1);
        edgeDirection = Vector3Int.right;
    }
    else if (rotation == 90)
    {
        calculatedSize = new Vector2Int(calculatedSize.y, calculatedSize.x);
        xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.x, 1);
        zRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.y, 0, 1);
        edgeDirection = Vector3Int.back;
    }
    else if (rotation == 180)
    {
        xRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.x, 0, 1);
        zRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.y, 0, 1);
        edgeDirection = Vector3Int.left;
    }
    else if (rotation == 270)
    {
        calculatedSize = new Vector2Int(calculatedSize.y, calculatedSize.x);
        xRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.x, 0, 1);
        zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.y, 1);
        edgeDirection = Vector3Int.forward;
    }

    List<Edge> positions = new();

    foreach (int x in xRange)
    {
        foreach (int z in zRange)
        {
            Vector3Int offset = new Vector3Int(x, 0, z);

            Vector3Int point_1 = currentTilePosition + offset;
            Vector3Int point_2 = currentTilePosition + offset + edgeDirection;

            Vector3Int min, max;

            if (point_1.x > point_2.x || point_1.z > point_2.z)
            {
                min = point_2;
                max = point_1;
            }
            else
            {
                min = point_1;
                max = point_2;
            }

            positions.Add(new Edge(min, max));
        }
    }

    return positions;
}

이 함수는 셀 기반 좌표 계산보다 한 단계 더 복잡하다.

이유는 Edge가 단일 좌표가 아니라 두 점으로 구성된 선분이기 때문이다.

이 함수 역시 기본 구조는 GetCellPositions와 동일하다.

먼저 범위를 생성하고, 반복을 통해 모든 점유 위치를 계산한다.

하지만 차이점은 최종 결과가 Vector3Int가 아니라 Edge라는 점이다.

가장 먼저 범위 보정을 한다.

Vector2Int calculatedSize = size - Vector2Int.one;

이 부분은 셀 계산과 동일한 이유로 들어간다.

inclusive 범위를 사용할 때 정확한 반복 횟수를 맞추기 위한 보정이다.

그 다음 회전에 따라 xRange, zRange, 그리고 edgeDirection이 결정된다.

IEnumerable<int> xRange = null;
IEnumerable<int> zRange = null;
Vector3Int edgeDirection = Vector3Int.zero;

if (rotation == 0)
{
    xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.x, 1);
    zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.y, 1);
    edgeDirection = Vector3Int.right;
}
else if (rotation == 90)
{
    calculatedSize = new Vector2Int(calculatedSize.y, calculatedSize.x);
    xRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.x, 1);
    zRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.y, 0, 1);
    edgeDirection = Vector3Int.back;
}
else if (rotation == 180)
{
    xRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.x, 0, 1);
    zRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.y, 0, 1);
    edgeDirection = Vector3Int.left;
}
else if (rotation == 270)
{
    calculatedSize = new Vector2Int(calculatedSize.y, calculatedSize.x);
    xRange = GridSelectionHelper.MoveMaxToMinInclusive(-calculatedSize.x, 0, 1);
    zRange = GridSelectionHelper.MoveMinToMaxInclusive(0, calculatedSize.y, 1);
    edgeDirection = Vector3Int.forward;
}

여기서 edgeDirection이 핵심이다.

이 값은 해당 Edge가 어떤 방향으로 생성되는지를 의미한다.

예를 들어 rotation이 0이면 오른쪽 방향(Vector3Int.right)으로 Edge가 생성된다.

하나의 Edge는 현재 셀과 그 방향의 다음 셀을 연결하는 선분으로 정의된다.

이후 Edge 생성의 핵심 코드이다.

Vector3Int point_1 = currentTilePosition + offset;
Vector3Int point_2 = currentTilePosition + offset + edgeDirection;

이 두 점이 하나의 Edge를 구성한다.

셀이 점을 만든다면, 이 함수는 점이 아니라 선을 만들어내는 함수다.

그 다음 중요한 부분은 정렬이다.

if (point_1.x > point_2.x || point_1.z > point_2.z)
{
    min = point_2;
    max = point_1;
}

이 코드는 Edge를 정규화 하는 부분으로, 가장 중요한 부분이다.

이건 Edge를 Dictionary 키로 사용하기 위한 필수 처리다.

Edge는 '(A, B) == (B, A)' 이어야 한다.

하지만 그대로 저장하면 서로 다른 키로 인식되며, 중복이 발생하고, 조회를 실패하게 된다.

그래서 항상 작은 좌표를 min, 큰 좌표를 max로 맞춘다.

이 과정을 통해 Edge는 항상 동일한 형태로 저장된다.

여기서 HashSet이 아닌 List를 사용하였다.

이는, 생성 단계에서는 중복이 발생하지 않으며, 순차 생성 구조이기 때문이다.

반대로 검사 단계에서는 HashSet을 사용하였다.

이 함수가 반환하는 Edge리스트는 '벽 배치, 벽 제거, Edge 충돌 검사, 저장 데이터 생성' 에 그대로 사용된다.

즉, 이 함수는 벽 시스템 전체의 좌표 기준을 정의하는 함수다.

4. 개발 의도

이 시스템의 핵심 설계 의도는 좌표 계산을 모든 시스템의 공통 기준으로 만드는 것이다.

배치, 검사, 제거, 저장 시스템이 각각 좌표를 따로 계산하면 결과가 어긋나고 버그가 발생한다.

그래서 좌표 계산을 별도 함수로 분리하고, 모든 시스템이 이 결과를 그대로 사용하도록 설계했다.

또한 셀과 Edge를 완전히 분리하여 각각의 좌표 체계를 독립적으로 유지했다.

결과적으로 이 시스템은 단순 계산 함수가 아니라, 건축 시스템 전체의 공간 해석 기준을 정의하는 핵심 계층이다.