배치 가능 여부 판단 구조

목차

1. 요구 사항

2. 흐름도

3. 구현

        3.1. 정적 유틸리티 구조의 의미

       3.2. 점유 여부 검사

       3.3. 빈 공간 여부 검사

       3.4. Grid 범위 유효성 검사

       3.5. 멀티 타일 구조물 교차 방지 검사

       3.6. Edge 구조물 교차 방지 검사

       3.7. 벽 인접 조건 검사

4. 개발 의도

1. 시스템 요구 사항

Grid 기반 건축 시스템에서 구조물을 배치할 때 가장 중요한 것은 선택된 위치가 실제로 유효한지를 정확하게 판단하는 것이다.

플레이어가 마우스로 선택한 위치가 있다고 해서 항상 바로 배치할 수 있는 것은 아니다.

해당 위치가 Grid 범위 안에 있는지, 이미 다른 구조물이 점유하고 있지는 않은지, 멀티 타일 구조물과 겹치지는 않는지, 벽 근처에 설치되어야 하는 오브젝트라면 실제로 벽과 맞닿아 있는지까지 모두 확인해야 한다.

특히 이 시스템은 단일 셀 배치만 처리하는 것이 아니라, 여러 셀을 동시에 차지하는 구조물, 셀 중심에 놓이는 구조물, 셀 경계를 기준으로 놓이는 구조물, 벽 근처 설치가 필요한 구조물까지 모두 지원해야 한다.

따라서 검증 시스템은 단순한 충돌 검사 수준에서 끝나는 것이 아니라, 구조물의 타입과 크기, 회전 값, 배치 방식에 따라 서로 다른 규칙을 종합적으로 판단해야 한다.

또한 배치 검증은 프리뷰 시스템과 직접 연결되기 때문에, 이 판단은 한 번만 수행되는 것이 아니라 마우스 이동이나 회전 입력에 따라 반복적으로 다시 계산될 수 있어야 한다.

그러므로 검증 로직은 실행 계층에 섞여 있지 않고, 별도의 독립된 계층으로 분리되어 있는 것이 바람직하다.

이렇게 해야 PlacementManager는 결과만 받아 사용할 수 있고, 검증 규칙이 변경되더라도 실행 계층을 크게 수정하지 않아도 된다.

결국 PlacementValidator는 배치 요청을 실행할지 말지 결정하는 전처리 시스템으로서, 단순 좌표 검사 기능이 아니라 Grid 데이터, 구조물 속성, 회전 상태를 모두 결합하여 최종 배치 가능 여부를 계산하는 판단 계층이어야 한다.

2. 흐름도

SelectionResult 또는 선택 좌표 목록

   ↓

PlacementValidator

   ↓

Grid 범위 검사

   ↓

점유 상태 검사

   ↓

멀티타일 / Edge 교차 검사

   ↓

벽 인접 조건 검사

   ↓

최종 배치 가능 여부 반환

   ↓

PlacementManager / Preview 시스템 반영

이 시스템의 흐름은 단순히 하나의 함수를 호출해 true 또는 false를 얻는 구조가 아니다.

실제로는 선택된 위치 목록과 구조물 크기, 회전 값, 배치 타입 정보를 함께 받아, 여러 검증 함수를 순차적으로 적용하는 구조에 가깝다.

먼저 기본적으로 선택된 위치가 현재 Grid 범위 안에 존재하는지를 검사해야 한다.

그 다음에는 그 위치가 이미 점유되어 있는지 또는 비어 있는지를 검사한다.

여기에 더해 구조물이 여러 셀을 차지하는 경우에는 다른 멀티 타일 구조물과 비정상적으로 교차하는지 확인해야 하고, 경계 기반 구조물 근처에 설치되는 오브젝트라면 벽과 실제로 맞닿아 있는지도 검사해야 한다.

즉, PlacementValidator는 하나의 검사 함수가 아니라, 배치 타입에 따라 여러 규칙을 조합해서 판단하는 시스템이다.

PlacementManager나 BuildingState는 이 결과를 받아서 placementValidity를 갱신하고, PlacementPreview는 이 값을 바탕으로 흰색 또는 빨간색 피드백을 보여주게 된다.

3. 구현

3.1 정적 유틸리티 구조의 의미
public static class PlacementValidator

PlacementValidator는 MonoBehaviour가 아니라 static class로 구현되어 있다.

이 선택은 클래스의 성격과 잘 맞는다.

이 클래스는 씬에 붙어서 상태를 유지하는 객체가 아니라, 이미 존재하는 선택 데이터와 Grid 데이터를 받아 즉시 검증 결과를 반환하는 순수 계산 계층에 가깝다.

C#의 static class는 인스턴스를 생성하지 않고 클래스 이름만으로 함수에 접근할 수 있도록 하는 구조이다.

장점은 상태를 들고 있지 않는 공용 로직을 명확하게 표현할 수 있다는 점이다.

호출하는 쪽에서는 객체 생성 비용 없이 바로 사용할 수 있고, 이 클래스는 데이터를 저장하지 않는다는 의도가 코드 구조로도 드러난다.

반면 단점은 내부 상태를 주입하거나 인터페이스로 대체하기 어렵기 때문에 테스트 구조가 복잡해질 수 있다는 점이다.

하지만 PlacementValidator는 현재 코드 기준으로 순수 계산 함수 집합이기 때문에 static class가 매우 잘 맞는 선택이다.

3.2. 점유 여부 검사
public static bool CheckIfPositionsAreOccupied(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    for (int i = 0; i < selectedPositions.Count; i++)
    {
        if (placementData.IsSpaceOccupied(
            selectedPositions[i],
            objectSize,
            Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y),
            edgePlacement) == false)
        {
            return false;
        }
    }
    return true;
}

이 함수는 선택된 모든 위치가 이미 구조물에 의해 점유되어 있는지를 검사한다.

이름만 보면 단순한 충돌 검사처럼 보일 수 있지만, 실제 의미는 제거 시스템이나 교체 시스템에서 매우 중요하다.

예를 들어 제거 모드에서는 이 위치에 실제로 제거할 대상이 있는가를 확인해야 하는데, 그때 이 함수가 사용될 수 있다.

함수는 selectedPositions 리스트를 순회하면서 각 위치에 대해 placementData.IsSpaceOccupied를 호출한다.

여기서 placementData는 특정 배치 타입에 대한 Grid 점유 정보를 관리하는 객체이며, 내부적으로 셀 또는 Edge 기반 점유 상태를 알고 있다.

즉, 이 함수는 직접 딕셔너리를 뒤지지 않고, PlacementGridData가 제공하는 도메인 함수를 사용해 점유 여부를 판단한다.

회전 값을 처리하는 부분도 중요하다.

Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y)

Unity에서 회전은 Quaternion으로 저장되지만, Grid 기반 건축 시스템에서는 대부분 0도, 90도, 180도, 270도 같은 직교 회전만 의미가 있다.

그래서 여기서는 Quaternion을 그대로 쓰지 않고 eulerAngles.y를 통해 Y축 회전값을 꺼낸 뒤, Mathf.RoundToInt로 정수 각도로 변환한다.

Mathf.RoundToInt는 Unity 수학 유틸리티 함수로, 부동소수점 회전값을 가장 가까운 정수로 반올림해준다.

이렇게 하는 이유는 Grid 데이터 시스템이 회전 상태를 정수 각도로 해석하기 때문이다.

반복문 안에서 하나라도 점유되지 않은 위치가 나오면 곧바로 false를 반환하는 구조를 사용하고 있는데, 이는 불필요한 계산을 줄이기 위한 조기 종료 방식이다.

선택된 위치가 여러 개일 때 한 곳이라도 조건을 만족하지 않으면 전체 선택이 유효하지 않기 때문에, 끝까지 검사할 필요가 없다.

이런 구조는 단순하지만 실용적인 성능 최적화다.

3.3. 빈 공간 여부 검사
public static bool CheckIfPositionsAreFree(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    for (int i = 0; i < selectedPositions.Count; i++)
    {
        if (placementData.IsSpaceFree(
            selectedPositions[i],
            objectSize,
            Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y),
            edgePlacement) == false)
        {
            return false;
        }
    }
    return true;
}

이 함수는 방금 본 점유 검사와 형태는 거의 동일하지만 의미는 반대다.

이 함수는 선택된 모든 위치가 비어 있는지를 검사한다.

즉, 구조물 배치 시스템에서 가장 기본이 되는 검증 함수라고 볼 수 있다.

이 함수가 중요한 이유는 비어 있음이 단순히 셀 하나가 비어 있다는 뜻이 아니기 때문이다.

구조물 크기(objectSize)와 회전 값, 그리고 edgePlacement 여부를 함께 전달하고 있다는 점을 보면, 이 함수는 사실상 현재 구조물이 이 위치와 방향으로 들어갈 수 있는가를 PlacementGridData에 묻는 역할을 한다.

특히 edgePlacement가 true일 경우 이 검사는 셀 점유 여부가 아니라 Edge 점유 여부로 해석된다.

즉 같은 함수 구조를 유지하면서도 내부적으로는 셀 기반 배치와 경계 기반 배치를 모두 지원하게 된다.

이게 중요한 이유는 Validator가 타입마다 서로 다른 검증 코드를 직접 들고 있지 않아도 되기 때문이다.

PlacementGridData가 셀과 Edge의 차이를 흡수하고, Validator는 이 규칙을 만족하는가만 묻는 구조가 유지된다.

코드가 단순한 반복문 구조인 것도 의도적이라고 볼 수 있다.

이 계층은 복잡한 전략 패턴보다는, GridData가 제공하는 도메인 함수들을 조합해 가독성 있게 사용하는 쪽이 유지보수에 더 유리하다.

3.4. Grid 범위 유효성 검사
public static bool CheckIfPositionsAreValid(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    for (int i = 0; i < selectedPositions.Count; i++)
    {
        if (placementData.IsSpaceValid(
            selectedPositions[i],
            objectSize,
            Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y),
            edgePlacement) == false)
        {
            return false;
        }
    }
    return true;
}

이 함수는 선택된 위치가 Grid 범위 안에서 유효한지를 검사한다.

배치 가능 여부를 판단할 때 가장 먼저 확인되어야 하는 것이 바로 현재 선택이 맵 범위를 벗어나지 않았는가이다.

여기서도 구조는 동일하다.

리스트를 순회하며 각 위치를 PlacementGridData의 IsSpaceValid에 전달한다.

하지만 이 함수가 의미하는 바는 단순한 좌표 범위 체크보다 조금 더 넓다.

구조물의 크기와 회전이 반영된 뒤에도 여전히 전체 점유 영역이 Grid 안에 있어야 하기 때문이다.

예를 들어 2x3 크기의 구조물을 90도 회전시키면 실제 점유 범위가 달라진다.

따라서 단순히 시작 좌표 하나만 Grid 안에 있다고 해서 유효하다고 볼 수 없다.

objectSize와 회전값을 함께 넘기는 이유가 바로 여기에 있다.

PlacementGridData는 이 값을 바탕으로 실제 점유 영역 전체를 계산하고, 그 전체가 유효한지 판단한다.

이 함수는 시스템 안정성 측면에서 매우 중요하다.

범위 검사 없이 배치를 허용하면 Grid 밖 좌표가 데이터에 기록되거나, 프리뷰는 보이는데 실제 배치가 실패하는 불일치 상황이 생길 수 있다.

따라서 이 검증은 충돌 검사보다도 더 기본적인 안전 장치라고 볼 수 있다.

3.5. 멀티 타일 구조물 교차 방지 검사
internal static bool CheckIfNotCrossingMultiCellObject(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    for (int i = 0; i < selectedPositions.Count; i++)
    {
        if (placementData.IsSpaceOccupiedByMultitileObject(
            selectedPositions[i],
            objectSize,
            Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y),
            edgePlacement))
        {
            return false;
        }
    }
    return true;
}

이 함수는 구조물 배치가 기존 멀티 타일 구조물과 비정상적으로 교차하는지를 검사한다.

기본적인 빈 공간 검사만으로는 해결되지 않는 문제를 다루기 위한 보조 검증이다.

멀티 타일 구조물은 여러 셀을 하나의 오브젝트가 차지하는 형태이기 때문에, 단순히 한 셀에 뭔가가 있느냐만 보면 충분하지 않을 수 있다.

특히 큰 구조물의 경계나 중간 셀을 기준으로 선택이 이루어질 때, 다른 구조물과 어색하게 교차하거나 잘못된 부분 침범이 발생할 수 있다.

이 함수는 PlacementGridData의 IsSpaceOccupiedByMultitileObject를 사용해, 특정 위치와 회전 상태가 이미 존재하는 멀티 타일 오브젝트의 영역과 부정확하게 겹치는지를 검사한다.

반환값이 true면 멀티 타일 구조물과 충돌 중이라는 뜻이므로, Validator 입장에서는 false를 반환해 배치를 막는다.

이 함수가 internal static으로 선언된 것도 의미가 있다.

외부 공개 API라기보다, 같은 어셈블리 내부의 검증 흐름에서만 사용하는 보조 규칙이라는 의미에 가깝다.

즉, Validator 내부에서 조합되는 규칙 중 하나라는 것을 코드 접근 수준으로도 표현하고 있다.

3.6. Edge 구조물 교차 방지 검사
internal static bool CheckIfNotCrossingEdgeObject(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    for (int i = 0; i < selectedPositions.Count; i++)
    {
        if (placementData.IsSpaceOccupiedByEdgeObject(
            selectedPositions[i],
            objectSize,
            Mathf.RoundToInt(selectedPositionsRotation[i].eulerAngles.y),
            edgePlacement))
        {
            return false;
        }
    }
    return true;
}

이 함수는 경계 기반 구조물과의 교차 여부를 검사한다.

벽이나 벽 근처에 배치되는 구조물은 셀 내부만 고려해서는 안 되고, 셀과 셀 사이의 경계선까지 포함해서 판단해야 한다.

그래서 기본적인 IsSpaceFree만으로는 충분하지 않을 수 있다.

PlacementGridData의 IsSpaceOccupiedByEdgeObject는 선택된 구조물이 지나가게 될 경계선들을 계산한 뒤, 그 경계선 위에 이미 다른 Edge 오브젝트가 존재하는지를 검사하는 함수다.

Validator는 이 결과를 받아 하나라도 교차가 있으면 전체 선택을 무효로 처리한다.

이 함수가 중요한 이유는 Edge 구조물이 일반 셀 점유 구조와 전혀 다른 규칙을 가지기 때문이다.

예를 들어 벽과 벽 근처 가구는 셀은 비어 있어도, 경계선이 이미 사용 중이면 배치가 안 되어야 한다.

이런 규칙을 별도의 함수로 분리해둠으로써, Validator는 셀 기반 충돌과 경계 기반 충돌을 명시적으로 나누어 다룰 수 있게 된다.

3.7. 벽 인접 조건 검사
internal static bool CheckIfPositionsAreNearWall(
    List<Vector3Int> selectedPositions,
    PlacementGridData placementData,
    Vector2Int objectSize,
    List<Quaternion> selectedPositionsRotation,
    bool edgePlacement)
{
    int rotationEulerY = Mathf.RoundToInt(selectedPositionsRotation[0].eulerAngles.y);

    HashSet<Edge> edges = new();
    foreach (Vector3Int pos in selectedPositions)
    {
        List<Vector3Int> cellsToOccupy = placementData.GetCellPositions(pos, objectSize, rotationEulerY);
        foreach (var cellPosition in cellsToOccupy)
        {
            Vector3Int offset = cellPosition - pos;
            if (rotationEulerY == 0)
            {
                if (offset.z == objectSize.y - 1 && placementData.IsCellAt(cellPosition + Vector3Int.forward))
                {
                    edges.UnionWith(placementData.GetEdgePositions(cellPosition + Vector3Int.forward, Vector2Int.one, 0));
                }
            }
            else if (rotationEulerY == 90 && placementData.IsCellAt(cellPosition + Vector3Int.right))
            {
                if (offset.x == objectSize.x - 1)
                {
                    edges.UnionWith(placementData.GetEdgePositions(cellPosition + Vector3Int.right, Vector2Int.one, 270));
                }
            }
            else if (rotationEulerY == 180)
            {
                if (offset.z == 0)
                {
                    edges.UnionWith(placementData.GetEdgePositions(cellPosition, Vector2Int.one, 0));
                }
            }
            else
            {
                if (offset.x == 0)
                {
                    edges.UnionWith(placementData.GetEdgePositions(cellPosition, Vector2Int.one, 270));
                }
            }
        }
    }

    foreach (var edgePos in edges)
    {
        if (placementData.IsEdgeObjectAt(edgePos) == false)
        {
            return false;
        }
    }
    return true;
}

이 함수는 PlacementValidator 안에서 가장 복잡한 검증 로직이다.

역할은 이름 그대로, 선택된 위치가 실제 벽과 맞닿아 있는지를 검사하는 것이다.

벽 근처에만 설치할 수 있는 오브젝트, 예를 들어 벽걸이 가구나 액자 같은 구조물에 필요할 수 있는 규칙이다.

가장 먼저 현재 회전 상태를 정수 각도로 구한다.

int rotationEulerY = Mathf.RoundToInt(selectedPositionsRotation[0].eulerAngles.y);

여기서는 첫 번째 회전값만 사용하고 있는데, 이는 이 검증이 기본적으로 동일한 회전을 공유하는 선택 집합을 전제로 하고 있기 때문이다.

Grid 기반 건축에서는 보통 한 번의 배치에서 동일한 구조물이 같은 방향으로 배치되므로 이 가정이 성립한다.

그 다음 HashSet<Edge>를 생성한다.

HashSet<Edge> edges = new();

HashSet은 중복을 허용하지 않는 자료구조다.

이 함수에서는 여러 셀을 순회하면서 검사 대상 Edge들을 수집하는데, 같은 Edge가 반복해서 들어올 수 있기 때문에 List보다 HashSet이 더 적절하다.

장점은 중복 제거와 빠른 검색이고, 단점은 순서를 보장하지 않는다는 점이다.

하지만 여기서는 순서가 중요하지 않고, 중복 없는 Edge 집합이 필요하기 때문에 HashSet을 선택한 것이다.

이후 선택된 각 위치에 대해 실제 구조물이 차지할 셀 목록을 계산한다.

List<Vector3Int> cellsToOccupy = placementData.GetCellPositions(pos, objectSize, rotationEulerY);

이 코드는 매우 중요하다.

벽 근처 조건을 검사한다고 해서 시작 좌표만 보면 안 된다.

구조물이 여러 셀을 차지할 수 있기 때문에, 그 전체 셀 범위를 먼저 계산해야 어느 면이 벽과 접하는지를 알 수 있다.

그 다음 각 셀을 순회하면서 offset을 계산한다.

Vector3Int offset = cellPosition - pos;

이 값은 현재 셀이 구조물의 기준 위치에서 얼마나 떨어져 있는지를 나타낸다.

이후 회전 상태에 따라 어느 면이 벽과 맞닿아야 하는 면인지를 판별하는 데 사용된다.

그 다음부터는 회전 상태별로 분기한다.

회전이 0도일 때는 구조물의 위쪽 면이 벽과 접하는지 확인하고, 90도일 때는 오른쪽 면, 180도일 때는 아래쪽 면, 270도일 때는 왼쪽 면을 기준으로 검사한다.

즉 같은 구조물이라도 회전에 따라 벽과 맞닿아야 하는 방향이 달라지므로, 이 검증은 반드시 회전값을 고려해야 한다.

각 분기 안에서 수행하는 placementData.GetEdgePositions(...) 호출은 특정 셀에 인접한 벽 후보 Edge를 계산하는 역할을 한다.

그리고 그 Edge들을 HashSet에 모은 뒤, 마지막에 실제로 모든 Edge에 벽이 존재하는지 검사한다.

foreach (var edgePos in edges)
{
    if (placementData.IsEdgeObjectAt(edgePos) == false)
    {
        return false;
    }
}

이 코드는 수집된 모든 경계선 위치에 실제 Edge 오브젝트가 존재하는지를 확인한다.

하나라도 없다면 벽 근처에 있어야 한다는 조건을 만족하지 못하므로 false를 반환한다.

이 함수는 단순히 벽이 있는지 없는지를 보는 수준이 아니라, 구조물의 전체 점유 셀과 회전 방향을 고려해 실제로 어떤 면이 벽과 닿아야 하는지를 계산하는 정교한 검증 함수라고 볼 수 있다.

코드가 긴 이유는 단순히 복잡해서가 아니라, 공간 규칙을 정확하게 반영해야 하기 때문이다.

4. 개발 의도

PlacementValidator의 핵심 설계 의도는 배치 가능 여부 판단을 실행 계층으로부터 완전히 분리하는 것이다.

건축 시스템에서 배치 실행은 StructurePlacer나 PlacementManager가 담당하지만, 이 위치가 가능한가를 판단하는 규칙까지 그 안에 들어가면 클래스 책임이 지나치게 커지고, 배치 타입이 늘어날수록 조건문이 복잡하게 얽히게 된다.

그래서 검증 로직을 독립된 정적 검증 계층으로 분리해, PlacementManager는 결과만 받아서 실행 여부를 결정하도록 만든 것이다.

또한 이 시스템은 단순한 충돌 검사 함수 모음이 아니라, Grid 범위 검사, 셀 점유 검사, Edge 교차 검사, 멀티 타일 충돌 검사, 벽 인접 검사처럼 서로 다른 규칙을 역할별로 나누어 두고 있다.

이 구조 덕분에 배치 타입에 따라 필요한 검증 함수를 조합해 사용할 수 있고, 규칙이 추가되더라도 기존 실행 계층을 크게 바꾸지 않고 확장할 수 있다.

결과적으로 PlacementValidator는 단순한 보조 유틸리티가 아니라, 선택된 위치를 실제 배치 가능한 상태로 해석해 주는 핵심 판단 계층으로 설계되었다.