Grid 기반 건축 좌표
목차
1. 시스템 요구 사항
심즈 스타일의 건축 시스템에서는 오브젝트를 월드 좌표에 자유롭게 배치하는 방식보다, 격자(Grid) 단위로 정렬된 위치에 배치하는 방식이 필요하다.
건축 오브젝트가 셀 단위로 정확히 정렬되어야 벽, 바닥, 가구와 같은 다양한 구조물이 서로 어긋나지 않고 일관된 규칙 위에서 배치될 수 있기 때문이다.
따라서 건축 시스템의 가장 하위 단계에서는 월드 좌표를 Grid 좌표로 변환할 수 있어야 하고, 반대로 Grid 좌표를 실제 월드 공간의 위치로 되돌릴 수 있어야 한다.
또한 단순히 셀 위치만 계산하는 것이 아니라, 건축물의 종류에 따라 셀 중심에 놓이는지, 혹은 셀의 경계(Edge)에 놓이는지를 구분할 수 있어야 한다.
예를 들어 바닥이나 자유 배치 오브젝트는 셀 중심 기준으로 배치되지만, 벽과 같은 구조물은 셀 경계를 기준으로 처리해야 하므로 동일한 좌표 계산 방식으로는 정확한 배치를 보장할 수 없다.
이와 함께 플레이어가 건축 모드에 들어갔을 때 Grid를 시각적으로 표시하고, 일반 플레이 모드에서는 이를 숨길 수 있어야 한다.
즉, 이 시스템은 건축 배치의 기준 좌표를 제공하고, 배치 타입에 따라 좌표 계산 규칙을 분기하며, Grid 시각화까지 제어하는 기반 시스템이어야 한다.
2. 설계 목표
- 월드 좌표와 Grid 좌표 간 변환 기능 제공
- 셀 중심 기준 배치와 셀 경계 기준 배치를 구분
- 건축 시스템 전체가 공통으로 사용하는 좌표 기준 제공
- Grid 시각화의 활성화 / 비활성화 제어
- PlacementType 기반 배치 규칙 분리
3. 흐름도
[마우스 입력 / 배치 대상 위치 결정]
↓
월드 좌표(World Position)
↓
GridManager.GetCellPosition(worldPosition, placementType)
↓
PlacementType에 따라 좌표 보정
↓
Grid.WorldToCell()로 셀 좌표 계산
↓
GetWorldPosition() / GetCenterPositionForCell()
↓
실제 배치용 월드 좌표로 변환
↓
Placement System / Preview System으로 전달
이 시스템의 핵심 흐름은 월드 좌표를 그대로 쓰지 않고, 먼저 Grid 좌표로 해석한 뒤, 다시 배치에 적합한 월드 좌표로 재계산한다는 점이다.
즉 플레이어 입력은 월드 공간에서 발생하지만, 건축 시스템은 그 입력을 곧바로 사용하지 않는다.
먼저 Grid 기준으로 좌표를 정규화하고, 이후 셀 중심 또는 경계 기준으로 다시 위치를 계산해 건축 배치의 일관성을 유지한다.
또한 이 흐름 안에는 단순한 좌표 변환만 있는 것이 아니라, PlacementType에 따라 배치 규칙 자체가 달라지는 분기 구조가 포함된다.
이 구조 덕분에 바닥, 벽, 벽 내부 오브젝트, 자유 배치 오브젝트가 서로 다른 규칙으로 동작하면서도, 전체적으로는 하나의 Grid 시스템 안에서 관리될 수 있다.
4. 구현
4.1. 초기화
// GridManager.cs
[SerializeField]
private Grid grid;
[SerializeField]
private Renderer gridRenderer;
[SerializeField]
private Vector3 gridCellSize;
private Vector3 halfGridCellSize;
[SerializeField]
private Vector2Int defaultScale = new Vector2Int(10, 10);
public Vector2Int GridSize =>
Vector2Int.RoundToInt(
defaultScale *
new Vector2(
gridRenderer.transform.localScale.x,
gridRenderer.transform.localScale.z)
);
[SerializeField]
private string cellSizeParameter = "_GridSize", defaultScaleParameter = "_DefaultScale";
private void Start()
{
grid.cellSize = gridCellSize;
halfGridCellSize = gridCellSize / 2f;
gridRenderer.material.SetVector(cellSizeParameter, new Vector2(1 / gridCellSize.x, 1 / gridCellSize.z));
gridRenderer.material.SetVector(defaultScaleParameter, new Vector2(defaultScale.x, defaultScale.y));
}이 코드는 GridManager가 사용하는 좌표 기준과 시각화 기준을 초기화하는 역할을 한다.
건축 시스템에서 가장 중요한 것은 모든 배치 계산이 동일한 좌표 기준을 공유하는 것이다.
만약 좌표 기준이 서로 다른 방식으로 계산된다면 건축 프리뷰, 실제 배치 위치, 충돌 판정 위치가 서로 어긋나는 문제가 발생할 수 있다.
따라서 GridManager는 건축 시스템에서 사용할 Grid 셀 크기와 좌표 기준을 중앙에서 설정하는 역할을 맡는다.
먼저 Grid 컴포넌트는 Unity에서 제공하는 좌표 변환 시스템이다.
Grid는 월드 좌표와 셀 좌표 간의 변환을 제공하는 컴포넌트이며, 타일맵 시스템에서도 동일하게 사용된다.
직접 좌표 변환 공식을 구현할 수도 있지만, Unity Grid를 사용하면 셀 크기 변경이나 Grid 방향 변경에도 자동으로 대응할 수 있기 때문에 안정성이 높다.
특히 건축 시스템에서는 셀 크기나 Grid 규모가 변경될 가능성이 있기 때문에, 엔진이 제공하는 Grid 시스템을 사용하는 것이 유지보수 측면에서 유리하다.
gridCellSize는 하나의 셀이 차지하는 실제 월드 공간의 크기를 의미한다.
이 값은 단순한 시각적 Grid 크기가 아니라, 건축 오브젝트가 배치되는 기준 단위가 된다.
Start에서 grid.cellSize를 설정하는 이유는 Unity Grid 컴포넌트가 내부적으로 좌표 변환 계산을 수행할 때 이 값을 사용하기 때문이다.
만약 Grid 셀 크기와 실제 건축 오브젝트의 기준 크기가 맞지 않으면, 월드 좌표를 셀 좌표로 변환할 때 예상과 다른 결과가 발생할 수 있다.
halfGridCellSize는 셀 크기의 절반을 의미한다.
이 값을 별도 변수로 저장하는 이유는 Edge Placement 보정 계산에서 반복적으로 사용되기 때문이다.
벽과 같은 구조물은 셀 중심이 아니라 셀 경계를 기준으로 배치되기 때문에 좌표를 절반 셀만큼 이동시켜 보정해야 한다.
이 값을 매번 계산하는 대신 미리 변수로 저장해 두면 코드의 의미가 명확해지고, 계산 과정도 단순해진다.
GridSize 프로퍼티는 현재 Grid가 몇 칸 규모로 표시되는지를 계산한다.
여기서는 defaultScale과 gridRenderer.transform.localScale을 결합하여 최종 Grid 크기를 계산한다.
단순히 defaultScale만 사용하는 대신 Renderer의 스케일 값을 함께 사용하는 이유는 Grid 시각화 오브젝트의 크기가 씬에서 변경될 수 있기 때문이다.
Renderer의 실제 스케일을 반영하면 Grid 표시 크기와 좌표 계산이 서로 어긋나는 문제를 방지할 수 있다.
Vector2Int.RoundToInt를 사용하는 이유는 Grid 셀 개수는 반드시 정수여야 하기 때문이다.
셀 개수가 실수로 유지되면 셀 인덱스 계산이나 충돌 판정에서 오류가 발생할 가능성이 있다.
Start 함수에서 초기화를 수행한 이유도 중요한 설계 요소다.
Unity에서 Awake는 오브젝트가 생성되자마자 실행되며, Start는 모든 Awake 실행 이후 호출된다.
GridManager는 GridRenderer와 같은 씬 객체를 참조하고 머티리얼 파라미터를 설정해야 하기 때문에, 모든 객체 초기화가 끝난 이후 실행되는 Start가 더 안전한 시점이다.
특히 Renderer의 material에 값을 전달하는 작업은 렌더링 관련 컴포넌트가 모두 초기화된 이후 실행하는 것이 안정적이다.
gridRenderer.material.SetVector 부분은 Grid 시각화에 사용되는 셰이더 파라미터를 설정하는 코드다.
셰이더에 셀 크기 정보를 전달함으로써 화면에 보이는 Grid 패턴이 실제 셀 크기와 동일하게 보이도록 만든다.
만약 이 정보를 전달하지 않으면 화면에 보이는 Grid와 실제 좌표 계산 기준이 서로 다를 수 있다.
즉 이 코드는 단순히 시각적 Grid를 표시하는 것이 아니라 렌더링 Grid와 좌표 시스템 Grid를 동기화하는 역할을 수행한다.
4.2. 월드 좌표를 Grid 좌표로 변환
public Vector3Int GetCellPosition(Vector3 worldPosition, PlacementType placementType)
{
if (placementType.IsEdgePlacement())
worldPosition += halfGridCellSize;
return grid.WorldToCell(worldPosition);
}GetCellPosition 함수는 건축 시스템에서 가장 많이 사용되는 좌표 변환 함수이다.
역할은 월드 공간의 위치를 Grid 셀 좌표로 변환하는 것이다.
하지만 단순히 Unity Grid의 WorldToCell을 호출하는 것에서 끝나지 않는다.
이 함수는 배치 타입에 따라 좌표를 먼저 보정한 뒤 Grid 좌표로 변환한다.
Unity의 Grid.WorldToCell 함수는 월드 좌표가 어떤 셀에 속하는지를 계산해 Vector3Int 형태의 셀 좌표로 반환한다.
여기서 Vector3Int를 사용하는 이유는 Grid 좌표가 연속적인 실수 공간이 아니라 정수 기반의 셀 인덱스 공간이기 때문이다.
셀 좌표가 정수로 유지되면 셀 점유 여부 검사, 충돌 검사, 건축 배치 가능 여부 판단 같은 로직을 단순한 배열 인덱스 수준으로 처리할 수 있다.
하지만 건축 시스템에는 셀 중심 기준으로 배치되는 오브젝트와 셀 경계 기준으로 배치되는 오브젝트가 동시에 존재한다.
예를 들어 바닥이나 가구는 셀 중심에 배치되지만, 벽은 셀 경계에 배치된다.
단순히 월드 좌표를 셀 좌표로 변환하면 벽 위치가 한 셀 정도 어긋나는 문제가 발생할 수 있다.
이 문제를 해결하기 위해 placementType.IsEdgePlacement() 조건을 사용한다.
이 조건이 true인 경우에는 worldPosition += halfGridCellSize 보정을 수행한다.
즉 월드 좌표를 셀 절반 크기만큼 이동시킨 뒤 Grid 좌표 계산을 수행한다.
이렇게 하면 벽처럼 셀 경계 기준으로 배치되는 오브젝트도 정확한 셀 좌표로 매핑된다.
이 보정 로직을 GridManager 안에 두는 이유도 중요한 설계 포인트다.
만약 각 배치 시스템에서 벽 좌표 보정을 개별적으로 수행한다면 코드가 분산되고 규칙이 통일되지 않을 수 있다.
반면 GridManager 내부에서 처리하면 모든 배치 시스템이 동일한 좌표 해석 규칙을 사용하게 된다.
4.3. Grid 좌표를 월드 좌표로 다시 변환하는 구조
public Vector3 GetWorldPosition(Vector3Int cellPosition)
{
return grid.CellToWorld(cellPosition);
}GetWorldPosition 함수는 Grid 좌표를 실제 월드 좌표로 변환하는 역할을 한다.
Unity Grid 컴포넌트의 CellToWorld 함수는 셀 인덱스를 월드 공간 좌표로 변환해 준다.
건축 시스템에서는 플레이어 입력이 월드 좌표에서 시작되지만, 실제 배치 계산은 Grid 좌표 기준으로 수행된다.
즉 입력 좌표를 Grid 좌표로 변환한 뒤, 다시 월드 좌표로 변환하는 과정이 필요하다.
이 함수는 그 과정에서 사용되는 역변환 함수다.
코드는 단순하지만 시스템 설계 측면에서는 중요한 의미가 있다.
Grid 좌표 변환 로직을 GridManager 내부에 캡슐화함으로써 상위 시스템이 Grid 컴포넌트에 직접 접근할 필요가 없도록 만들었다.
이렇게 하면 좌표 변환 책임이 GridManager에 집중되며, 상위 시스템은 좌표 계산 세부 구현을 알 필요 없이 GridManager의 인터페이스만 사용하면 된다.
4.4. 셀 중심 월드 좌표 계산
public Vector3 GetCenterPositionForCell(Vector3Int cellPosition)
{
return GetWorldPosition(cellPosition) + halfGridCellSize;
}GetCenterPositionForCell 함수는 특정 Grid 셀의 중심 위치를 계산한다.
CellToWorld가 반환하는 위치는 일반적으로 셀의 기준점이며, 실제 셀 중앙 위치는 아니다.
건축 오브젝트는 대부분 셀 중앙 기준으로 배치되어야 시각적으로 자연스럽게 정렬된다.
그래서 이 함수에서는 GetWorldPosition으로 셀 시작 좌표를 구한 뒤 halfGridCellSize를 더해 셀 중심 위치를 계산한다.
이 방식은 Grid 셀 크기가 변경되더라도 동일한 계산 로직을 사용할 수 있기 때문에 유지보수에 유리하다.
이 함수는 특히 건축 프리뷰 시스템에서 중요한 역할을 한다.
플레이어가 마우스를 움직일 때 프리뷰 오브젝트가 셀 중심에 정확히 위치해야 하기 때문이다.
만약 셀 중심 계산을 하지 않으면 프리뷰 위치와 실제 배치 위치가 서로 어긋나는 문제가 발생할 수 있다.
4.5. Grid 표시 On / Off 제어
public void ToggleGrid(bool value)
{
gridRenderer.gameObject.SetActive(value);
}ToggleGrid 함수는 Grid 시각화 오브젝트의 활성 상태를 제어한다.
Unity의 SetActive 함수는 GameObject의 활성 상태를 제어하는 기능으로, 비활성화된 오브젝트는 렌더링과 컴포넌트 동작이 모두 중지된다.
건축 시스템에서는 플레이어가 건축 모드에 들어갔을 때 Grid를 표시하고, 일반 플레이 모드에서는 이를 숨기는 것이 일반적이다.
따라서 Grid 표시를 간단하게 제어할 수 있는 인터페이스가 필요하다.
이 함수는 그 역할을 수행한다.
SetActive를 사용한 이유는 구현이 단순하고 관리가 쉽기 때문이다.
Grid 표시를 머티리얼 알파 값이나 셰이더 파라미터로 제어하는 방법도 있지만, 현재 구조에서는 Grid 표시 여부만 제어하면 되기 때문에 GameObject 활성화 방식이 가장 직관적이다.
또한 Grid 표시 로직을 GridManager 안에 두면 상위 시스템은 GridManager의 ToggleGrid만 호출하면 된다.
GridRenderer가 어떤 오브젝트인지, 어떻게 표시되는지는 GridManager가 내부적으로 관리한다.
4.6. PlacementType을 통한 배치 방식 구분
public enum PlacementType
{
None,
Floor,
Wall,
InWalls,
NearWallObject,
FreePlacedObject
}PlacementType은 건축 시스템에서 배치 대상의 성격을 정의하는 열거형(enum)이다.
건축 시스템에서는 오브젝트마다 배치 규칙이 다르기 때문에, 먼저 오브젝트의 배치 유형을 명확하게 정의할 필요가 있다.
예를 들어 바닥은 Grid 셀 중심에 배치되어야 하고, 벽은 셀의 경계선에 배치되어야 한다.
또한 가구와 같은 오브젝트는 셀 중심을 기준으로 자유롭게 배치되지만, 벽 근처에 설치되는 오브젝트는 벽 방향을 기준으로 위치가 조정되어야 한다.
이러한 차이를 코드에서 직접 if 문으로 처리하기 시작하면 배치 로직이 복잡해지고, 새로운 타입이 추가될 때마다 조건 분기가 계속 증가하게 된다.
이 문제를 해결하기 위해 배치 대상의 성격을 먼저 PlacementType이라는 열거형으로 정의하였다.
열거형을 사용하면 문자열이나 숫자 값을 직접 비교하는 방식보다 타입 안정성(type safety)을 확보할 수 있으며, 컴파일 단계에서 지원 가능한 값의 범위를 명확하게 제한할 수 있다.
또한 IDE 자동완성 기능을 통해 사용 가능한 타입 목록을 바로 확인할 수 있기 때문에 코드 가독성과 유지보수성도 좋아진다.
PlacementType의 각 항목은 단순한 이름 이상의 의미를 가진다.
Floor는 셀 중심 배치를 의미하고, Wall과 InWalls는 셀 경계 기준 배치를 의미한다.
NearWallObject는 벽 방향에 영향을 받는 오브젝트를 의미하며, FreePlacedObject는 Grid 중심 기준으로 자유 배치가 가능한 오브젝트를 의미한다.
즉, 이 enum은 단순히 어떤 오브젝트인지를 표현하는 것이 아니라, 어떤 좌표 해석 규칙을 적용해야 하는지를 결정하는 핵심 분류 기준이다.
이 구조를 사용하면 배치 시스템은 오브젝트 종류를 직접 판단할 필요 없이 PlacementType만 확인하면 된다.
결과적으로 오브젝트의 배치 규칙이 코드 전반에 흩어지지 않고 하나의 명확한 타입 체계 안에서 관리된다.
4.7. PlacementTypeExtensions 설계
public static class PlacementTypeExtensions
{
public static bool IsEdgePlacement(this PlacementType placementType)
=> placementType switch
{
PlacementType.Wall => true,
PlacementType.InWalls => true,
_ => false
};
public static bool IsObjectPlacement(this PlacementType placementType)
=> placementType switch
{
PlacementType.FreePlacedObject => true,
PlacementType.NearWallObject => true,
_ => false
};
}PlacementTypeExtensions는 PlacementType의 의미를 해석하는 로직을 별도의 확장 메서드 형태로 분리한 구조이다.
이 구조의 핵심 목적은 배치 규칙을 enum 자체가 아니라 확장된 동작으로 표현하는 것이다.
일반적으로 enum을 사용하면, 'type = PlacementType.타입 이름' 과 같은 코드가 자주 등장한다.
이 방식은 코드가 여러 곳에 반복되기 쉽고, 새로운 타입이 추가될 때마다 모든 조건문을 수정해야 하는 문제가 있다.
또한 조건문 자체가 길어지면서 코드의 의미도 쉽게 파악하기 어려워진다.
이 문제를 해결하기 위해 C#의 Extension Method 기능을 사용하였다.
확장 메서드는 기존 타입에 새로운 메서드를 추가한 것처럼 사용할 수 있는 기능이다.
이를 사용하면 enum 값을 'placementType.IsEdgePlacement()' 와 같이 해석할 수 있다.
이 방식의 가장 큰 장점은 코드의 의도를 직접적으로 표현할 수 있다는 점이다.
단순히 enum 값을 비교하는 것이 아니라 이 타입이 Edge 배치인가라는 의미를 그대로 코드로 표현할 수 있다.
즉, 조건문이 아니라 도메인 개념(domain concept)으로 코드를 작성할 수 있게 된다.
또한 배치 규칙이 변경되거나 새로운 타입이 추가될 경우 수정해야 할 위치도 명확하다.
기존 방식이라면 여러 클래스에 흩어진 조건문을 모두 찾아 수정해야 하지만, 확장 메서드를 사용하면 PlacementTypeExtensions 클래스 한 곳만 수정하면 된다.
이러한 구조는 코드 중복을 줄이고, 유지보수성을 높이는 데 큰 도움이 된다.
확장 메서드 내부에서는 전통적인 switch 문 대신 C# switch expression 문법을 사용하였다.
placementType switch
{
PlacementType.Wall => true,
PlacementType.InWalls => true,
_ => false
};switch expression은 C# 8에서 도입된 문법으로, 조건 분기 결과를 값으로 바로 반환할 수 있는 특징이 있다.
전통적인 switch 문은 여러 줄의 case 문과 break 문이 필요하지만, switch expression은 하나의 표현식으로 결과를 반환할 수 있어 코드가 훨씬 간결해진다.
이 문법을 사용하면 다음과 같은 장점이 있다.
첫째, 코드 가독성이 높아진다.
조건 분기가 값을 반환하는 표현식으로 작성되기 때문에 로직의 목적이 명확해진다.
둘째, 불필요한 변수 선언이나 break 문이 필요 없기 때문에 코드 길이가 줄어든다.
셋째, 컴파일러가 모든 경우를 처리했는지 검사할 수 있기 때문에 enum 확장 시 안정성이 높아진다.
특히 enum과 switch expression은 함께 사용될 때 장점이 더욱 커진다.
새로운 enum 값이 추가되면 switch expression에서 해당 값이 처리되지 않았다는 경고를 받을 수 있기 때문이다.
이는 런타임 오류를 줄이고 코드 안정성을 높이는 데 도움이 된다.
이 구조의 핵심은 배치 타입과 배치 규칙을 분리한 것이다.
건축 시스템에서는 오브젝트의 종류가 늘어날수록 배치 규칙도 함께 늘어난다.
만약 이러한 규칙을 배치 시스템 내부의 if 문으로 처리하기 시작하면 코드 구조가 빠르게 복잡해진다.
특히 벽, 바닥, 가구, 벽 근처 오브젝트처럼 배치 방식이 다른 오브젝트가 늘어날수록 조건 분기가 기하급수적으로 증가한다.
PlacementType과 PlacementTypeExtensions 구조를 사용하면 이 문제를 상당 부분 해결할 수 있다.
배치 시스템은 단순히 타입을 확인하고, 타입의 의미 해석은 확장 메서드가 담당한다.
즉, 배치 시스템은 어떤 타입인지만 알면 되고, 그 타입이 어떤 규칙을 의미하는지는 확장 메서드가 처리한다.
결과적으로 배치 시스템은 PlacementType을 확인하고, PlacementTypeExtensions를 통해 배치 규칙을 해석한다.
이 구조는 배치 로직을 단순화하고, 새로운 배치 타입이 추가될 때 코드 변경 범위를 최소화한다.
5. 개발 의도
이 Grid 기반 건축 좌표 시스템의 핵심 의도는 건축 시스템 전체가 하나의 공통 좌표 기준 위에서 동작하도록 만드는 것이다.
건축 프리뷰, 배치 판정, 실제 설치, 벽 배치, 바닥 배치가 각각 제각기 다른 방식으로 좌표를 계산하면, 시각적으로는 같은 위치를 가리키는 것처럼 보여도 실제 셀 해석이 어긋나 오류가 발생할 수 있다.
그래서 '월드 좌표 ↔ Grid 좌표' 변환, 셀 중심 계산, 경계 배치 보정을 모두 GridManager 안에 모아두었다.
또한 단순히 좌표만 바꾸는 수준에서 끝나지 않고, PlacementType과 확장 메서드를 통해 배치 규칙 자체를 타입 기반으로 해석하는 구조를 만들었다.
이 덕분에 상위 시스템은 좌표를 직접 보정하거나 타입을 해석할 필요 없이, GridManager가 제공하는 규칙을 그대로 사용할 수 있다.
Grid 시각화 역시 같은 맥락이다.
건축 모드에서 플레이어가 Grid를 보고 배치할 수 있게 하되, 일반 플레이에서는 숨길 수 있도록 함으로써 좌표 시스템과 시각적 가이드를 함께 관리하도록 설계했다.
결과적으로 이 시스템은 단순한 좌표 계산 클래스가 아니라, 건축 시스템 전체의 배치 기준을 통일하고, 타입에 따른 배치 규칙을 안정적으로 연결하며, 시각적 Grid까지 함께 제어하는 기반 인프라 시스템으로 설계되었다.
