건축 배치 상태 시스템
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서는 플레이어가 배치하려는 구조물의 종류에 따라 선택 방식과 검증 방식이 달라져야 한다.
예를 들어 바닥은 여러 셀을 사각형으로 선택해 깔 수 있어야 하고, 벽은 셀의 중심이 아니라 경계를 기준으로 배치되어야 하며, 벽 내부에 배치되는 오브젝트는 기존 벽과의 관계를 고려해야 한다.
또한 자유 배치 오브젝트와 벽 근처 오브젝트도 배치라는 큰 흐름 안에 있지만, 실제 선택 규칙은 서로 다르다.
이런 차이를 상위 컨트롤러에서 모두 직접 처리하기 시작하면, PlacementManager 안에 배치 타입별 분기와 전략 선택 코드가 계속 늘어나게 되고, 입력 처리와 실행 로직, 선택 계산 로직이 한곳에 섞이게 된다.
따라서 배치 시스템 내부에는 현재 배치하려는 구조물의 타입에 따라 적절한 선택 전략과 배치 데이터를 구성해 주는 상태 계층이 필요하다.
PlacingObjectsState는 바로 이 역할을 수행한다.
이 클래스는 배치 모드에 들어왔을 때 현재 ItemData의 objectPlacementType을 확인하고, 그 타입에 맞는 PlacementGridData를 선택하며, 동시에 알맞은 SelectionStrategy를 갖는 PlacementSelector를 생성한다.
즉, 이 상태는 직접 배치를 실행하는 클래스가 아니라, 현재 배치 행위를 어떤 규칙으로 해석해야 하는지 결정하는 상태 초기화 계층이라고 볼 수 있다.
결과적으로 PlacingObjectsState는 구조물 종류별 배치 규칙을 상태 생성 시점에 고정하고, 이후 선택 시작 · 변경 · 완료 흐름은 BuildingState가 제공하는 공통 인터페이스 위에서 동일하게 처리되도록 만드는 역할을 가져야 한다.
2. 흐름도
PlacementManager.StartPlacingObject()
↓
PlacingObjectsState 생성
↓
SelectionData 생성
↓
ItemData.objectPlacementType 확인
↓
적절한 PlacementGridData 선택
↓
적절한 SelectionStrategy 생성
↓
PlacementSelector 생성
↓
ConnectToPlacementSelection()
↓
BuildingState 공통 입력 흐름 사용
이 클래스의 흐름은 입력을 처리한다기보다, 배치 상태가 시작되는 순간 현재 구조물에 맞는 배치 규칙을 구성한다는 데 있다.
먼저 PlacementManager가 특정 구조물 ID를 바탕으로 ItemData를 가져오고, 그 데이터를 가지고 PlacingObjectsState를 생성한다.
생성자 안에서는 우선 SelectionData를 생성해 현재 선택 상태를 담을 컨테이너를 준비한다.
그 다음 itemData.objectPlacementType을 검사하여, 현재 구조물이 바닥인지, 벽인지, 벽 내부 오브젝트인지, 자유 배치 오브젝트인지, 벽 근처 오브젝트인지 구분한다.
이 구분 결과에 따라 사용할 PlacementGridData가 달라지고, 동시에 연결할 SelectionStrategy도 달라진다.
예를 들어 바닥이라면 BoxSelection을 사용하고, 벽이라면 WallPlacementStrategy를 사용한다.
이 전략과 데이터가 정해지면 이를 PlacementSelector에 연결하고, 마지막으로 ConnectToPlacementSelection 함수를 호출하여 선택 결과 이벤트를 외부로 전달할 수 있도록 만든다.
즉, PlacingObjectsState는 입력 루프가 돌기 전에 이 상태는 어떤 규칙으로 동작할 것인가를 완성하는 상태 구성 클래스다.
3. 구현
3.1. 생성자 전체 구조와 역할
public PlacingObjectsState(GridManager gridManager, GridData gridData, ItemData itemData)
: base(gridManager, gridData, itemData)
{
selectionData = new SelectionData(itemData);
...
ConnectToPlacementSelection();
}이 클래스는 별도의 메서드보다 생성자 안에서 대부분의 구성이 이루어진다.
그 이유는 이 상태의 핵심 책임이 동작 중 처리보다 생성 시점 설정에 있기 때문이다.
즉, PlacingObjectsState는 만들어지는 순간, 이미 자신이 어떤 Grid 데이터 위에서 어떤 선택 전략을 사용할지 결정되어 있어야 한다.
생성자 첫 줄에서 : base(gridManager, gridData, itemData)를 호출하는 부분은 BuildingState의 공통 초기화를 수행한다.
이 단계에서 상위 클래스는 gridManager, gridData, ItemData를 자신의 공통 필드에 저장하게 된다.
즉 PlacingObjectsState는 공통 기반 위에 자신의 상태 전용 설정만 얹는 구조다.
이런 설계는 상태 패턴에서 매우 자연스럽다.
공통 흐름과 공통 데이터는 상위 클래스가 들고, 실제 상태별 전략 선택만 하위 클래스가 담당하게 되기 때문이다.
그 다음 줄은 이 상태에서 가장 먼저 해야 할 준비 작업이다.
selectionData = new SelectionData(itemData);SelectionData는 현재 선택 상태를 저장하는 가변 데이터 객체이다.
여기에는 선택된 Grid 위치, 프리뷰 위치, 회전 값, 배치 가능 여부 같은 정보가 누적된다.
이 객체를 생성할 때 itemData를 함께 넘기는 이유는, 선택 데이터가 구조물 크기나 배치 타입 같은 구조물 메타데이터를 알아야 하기 때문이다.
즉, SelectionData는 단순 빈 컨테이너가 아니라, 현재 어떤 구조물을 선택 중인가를 알고 있는 상태 컨테이너다.
이 구조의 장점은 상태마다 독립된 SelectionData를 가지게 된다는 점이다.
배치 상태가 시작될 때마다 새로운 SelectionData가 생성되므로, 이전 상태의 선택 흔적이 남지 않는다.
단점이라면 상태 전환이 많을 때 객체 생성이 반복된다는 점이지만, 건축 상태 전환은 매 프레임 일어나는 일이 아니기 때문에 충분히 감수할 수 있는 비용이다.
오히려 상태 간 오염이 없는 것이 훨씬 중요하다.
마지막의 ConnectToPlacementSelection 함수 호출은, 생성자 안에서 placementSelection이 올바르게 설정된 뒤에만 의미가 있다.
이 함수는 BuildingState에서 내려온 공통 메서드로, PlacementSelector 내부 이벤트를 현재 상태의 OnSelectionChanged, OnFinished와 연결해 준다.
즉, PlacingObjectsState는 생성자 끝에서 이제 선택 결과를 외부로 전달할 준비가 끝난 상태가 된다.
3.2. 바닥 배치 상태 구성
if (itemData.objectPlacementType == PlacementType.Floor)
{
currentPlacementData = gridData.FloorPlacementData;
placementSelection = new(new BoxSelection(currentPlacementData, gridManager), selectionData);
}이 분기는 현재 구조물이 바닥일 때 실행된다.
바닥은 일반적으로 여러 셀을 한 번에 선택해 깔 수 있기 때문에, 선택 방식이 박스 선택이어야 한다.
여기서 currentPlacementData를 gridData.FloorPlacementData로 설정하는 것은, 이후 배치 가능 여부 검사와 실제 기록이 바닥 전용 Grid 데이터 위에서 이루어져야 하기 때문이다.
GridData 안에는 FloorPlacementData, WallPlacementData, ObjectPlacementData, InWallPlacementData처럼 타입별로 분리된 PlacementGridData가 들어 있다.
이 구조 덕분에 같은 Grid 위에서도 바닥, 벽, 일반 오브젝트를 서로 다른 논리 레이어로 관리할 수 있다.
바닥 상태에서 FloorPlacementData를 고른다는 것은, 이후 이 상태의 모든 배치 검증과 기록이 바닥 레이어 위에서 일어난다는 뜻이다.
그 다음 placementSelection = new(new BoxSelection(currentPlacementData, gridManager), selectionData);가 실행된다.
여기서 BoxSelection은 바닥 배치에 적합한 선택 전략이다.
시작 위치와 현재 위치를 기준으로 직사각형 범위를 계산해 여러 셀을 선택하는 전략일 가능성이 높다.
PlacementSelector는 이 전략과 SelectionData를 묶어 상태 내부 선택 시스템을 구성한다.
C#의 new(new BoxSelection(...), selectionData) 구문은 대상 타입이 이미 왼쪽 변수 선언을 통해 추론되기 때문에, 타입 이름을 생략하는 target-typed new 문법이다.
장점은 코드가 짧아지고 중복 타입 명시를 줄일 수 있다는 점이고, 단점은 문맥이 불명확하면 초심자에게 읽기 어려울 수 있다는 점이다.
현재는 placementSelection의 타입이 PlacementSelector로 분명하기 때문에 자연스럽게 사용되고 있다.
이 분기 전체가 의미하는 것은 분명하다.
바닥 배치는 바닥 데이터 레이어를 사용하고, 선택 방식은 사각형 선택을 사용한다.
즉, 배치 타입이 바뀌면 선택 전략도 함께 바뀐다는 구조를 보여주는 첫 번째 사례다.
3.3. 벽 배치 상태 구성
else if (itemData.objectPlacementType == PlacementType.Wall)
{
currentPlacementData = gridData.WallPlacementData;
placementSelection = new(new WallPlacementStrategy(
currentPlacementData,
gridData.InWallPlacementData,
gridData.ObjectPlacementData,
gridManager), selectionData);
}이 분기는 현재 구조물이 벽일 때 실행된다.
벽은 바닥과 달리 셀 중심이 아니라 셀 경계를 기준으로 배치되어야 하므로, 단순 BoxSelection으로 처리할 수 없다.
그래서 여기서는 WallPlacementStrategy라는 별도의 전략을 사용한다.
먼저 currentPlacementData를 gridData.WallPlacementData로 설정한다.
이는 이후 이 상태에서의 점유 검사와 배치 기록이 벽 레이어에 쓰여야 함을 의미한다.
벽은 Edge 기반 구조물이기 때문에, 일반 셀 배치 데이터와는 다른 자료구조 위에서 관리되어야 한다.
그 다음 WallPlacementStrategy 생성 시 인자가 여러 개 들어간다는 점이 중요하다.
현재 벽 배치를 계산할 때 단순히 벽 레이어만 보면 안 되고, 벽 내부 오브젝트(InWallPlacementData)와 일반 오브젝트(ObjectPlacementData)까지 함께 알아야 한다는 뜻이다.
예를 들어 벽과 맞닿은 가구나 벽 내부 설치물과의 충돌 규칙이 있을 수 있기 때문이다.
즉, 벽 배치는 벽만의 문제가 아니라 주변 배치 레이어 전체와 관계를 가진다.
이 코드가 보여주는 핵심은, 배치 타입이 단순히 어느 GridData 레이어를 쓸지만 결정하는 것이 아니라, 선택 전략이 참조해야 할 데이터 집합의 범위까지 결정한다는 점이다.
바닥보다 벽 전략의 생성자가 더 복잡한 이유는, 벽이 공간 규칙 측면에서 더 많은 관계를 고려해야 하기 때문이다.
3.4. 벽 내부 오브젝트 배치 상태 구성
else if (itemData.objectPlacementType == PlacementType.InWalls)
{
currentPlacementData = gridData.InWallPlacementData;
placementSelection = new(new InWallPlacementStrategy(
gridData.WallPlacementData,
currentPlacementData,
gridManager), selectionData);
}이 분기는 벽 내부에 설치되는 구조물, 예를 들어 창문이나 문 같은 오브젝트를 처리하는 상태 구성을 담당한다.
여기서는 currentPlacementData를 InWallPlacementData로 설정한다.
즉, 실제 기록은 벽 내부 레이어에 남기되, 선택 계산은 기존 벽 레이어와 함께 고려되어야 한다.
생성되는 전략이 InWallPlacementStrategy이고, 여기에는 gridData.WallPlacementData와 currentPlacementData가 함께 전달된다.
이는 벽 내부 오브젝트가 독립적으로 공중에 놓이는 것이 아니라, 기존 벽 위나 벽 내부라는 맥락 안에서만 유효하다는 것을 의미한다.
다시 말해 이 전략은 현재 위치가 벽과 관계를 맺고 있는지를 먼저 알아야 한다.
이 분기의 의미는 구조적으로 아주 중요하다.
단순히 다른 타입이니까 다른 전략 수준이 아니라, 특정 타입의 구조물은 다른 레이어를 기반 조건으로 삼고 그 위에 자신이 배치되는 종속 구조라는 점을 보여주기 때문이다.
이 설계는 배치 시스템이 평면적인 구조가 아니라, 레이어 간 관계를 고려하는 입체적인 구조임을 드러낸다.
3.5. 자유 배치 오브젝트 상태 구성
else if (itemData.objectPlacementType == PlacementType.FreePlacedObject)
{
currentPlacementData = gridData.ObjectPlacementData;
placementSelection = new(new FreeObjectPlacementStrategy(
currentPlacementData,
gridData.WallPlacementData,
gridData.InWallPlacementData,
gridManager), selectionData);
}이 분기는 일반 가구처럼 자유롭게 배치 가능한 오브젝트를 처리한다.
이 경우 기록 대상은 ObjectPlacementData이므로 currentPlacementData를 그 레이어로 설정한다.
하지만 전략 생성 시에는 ObjectPlacementData뿐 아니라 WallPlacementData, InWallPlacementData도 함께 전달한다.
이건 자유 배치 오브젝트라 해도 완전히 독립적이지 않다는 뜻이다.
예를 들어 벽과 겹치면 안 되거나, 벽 내부 오브젝트와 충돌하면 안 되는 규칙이 있을 수 있다.
즉, 자유 배치는 아무 데나 놓는다는 뜻이 아니라, 기본적으로 셀 중심 배치를 따르되 다른 레이어와의 관계까지 고려하는 전략이라는 의미다.
이 분기가 보여주는 포인트는, 같은 ObjectPlacementData를 쓰더라도 전략이 다르면 완전히 다른 배치 행동을 만들 수 있다는 점이다.
결국 GridData는 어디에 기록할지를 결정하고, SelectionStrategy는 어떻게 선택하고 검증할지를 결정한다.
이 분리가 시스템 확장성의 핵심이다.
3.6. 벽 근처 오브젝트 상태 구성
else if (itemData.objectPlacementType == PlacementType.NearWallObject)
{
currentPlacementData = gridData.ObjectPlacementData;
placementSelection = new(new NearWallPlacementStrategy(
currentPlacementData,
gridData.WallPlacementData,
gridData.InWallPlacementData,
gridManager), selectionData);
}이 분기는 벽 근처에만 설치 가능한 구조물을 처리한다.
겉보기에는 바로 앞의 자유 배치 오브젝트와 매우 비슷해 보인다.
실제로 currentPlacementData도 동일하게 ObjectPlacementData를 사용한다.
하지만 여기서 중요한 것은 저장 레이어가 같아도 선택 전략이 다르면 완전히 다른 배치 규칙이 적용될 수 있다는 점이다.
NearWallPlacementStrategy는 이름 그대로 벽 근처 조건을 포함하는 전략이다.
PlacementValidator 쪽 코드를 보면 벽 근처 조건을 검사하는 별도 함수가 존재하는데, 이런 규칙은 결국 해당 전략이 선택 과정이나 유효성 계산 과정에서 사용하게 된다.
즉, FreeObjectPlacementStrategy와 NearWallPlacementStrategy는 같은 데이터 레이어를 사용해도, 후자는 벽 인접 여부를 반드시 고려하는 더 강한 제약을 가진다.
이 분기가 중요한 이유는, 배치 타입 설계가 단순히 어느 자료구조에 저장할 것인가 수준이 아니라, 이 오브젝트는 어떤 공간 규칙을 따라야 하는가까지 포함한다는 점을 보여주기 때문이다.
그래서 같은 ObjectPlacementData 위에서도 전략만 다르게 끼워 넣어 전혀 다른 배치 체험을 만들 수 있다.
3.7. 지원되지 않는 타입 처리와 초기화 마무리
else
{
Debug.LogWarning($"배치 유형 {itemData.objectPlacementType}에 대한 배치는 지원되지 않습니다.");
return;
}
ConnectToPlacementSelection();이 마지막 분기는 현재 정의된 PlacementType들 중 어느 것도 아닌 경우를 처리한다.
즉, 새로운 타입이 추가되었는데 아직 전략 연결을 구현하지 않았을 때, 시스템이 조용히 잘못 동작하는 대신 경고를 띄우고 상태 구성을 중단한다.
Debug.LogWarning은 Unity의 디버그 API 중 하나로, 단순 로그보다 한 단계 높은 경고 메시지를 콘솔에 남긴다.
장점은 개발 단계에서 미지원 타입을 빠르게 발견할 수 있다는 점이고, 단점은 런타임 사용자 경험을 직접 복구해 주지는 못한다는 점이다.
하지만 이 상황은 개발 중 구조 누락을 알려주는 것이 우선이므로 적절한 선택이다.
그리고 모든 정상 분기 이후에는 ConnectToPlacementSelection 함수를 호출한다.
이 시점에서만 호출하는 이유는 placementSelection이 반드시 유효하게 생성된 이후여야 하기 때문이다.
이 함수는 현재 상태가 가진 PlacementSelector의 OnSelectionChanged, OnSelectionFinished 이벤트를 BuildingState의 OnSelectionChanged, OnFinished에 연결한다.
결국 이 줄이 호출되어야만 PlacementManager가 현재 상태의 선택 결과를 받을 수 있게 된다.
즉, 생성자 마지막의 이 한 줄은 상태 구성이 끝났음을 선언하는 마무리 단계라고 볼 수 있다.
4. 개발 의도
PlacingObjectsState의 핵심 설계 의도는 배치 상태에서 어떤 규칙으로 선택과 검증을 수행할지를 상태 생성 시점에 고정하는 것이다.
PlacementManager가 구조물 타입마다 직접 분기하면서 전략과 데이터 레이어를 선택하게 두면, 상위 컨트롤러가 점점 비대해지고 구조물 타입이 늘어날수록 유지보수가 어려워진다.
그래서 배치 모드에 들어오는 순간 이 상태 객체가 생성되고, 그 안에서 현재 구조물 타입에 맞는 PlacementGridData와 SelectionStrategy를 스스로 연결하도록 한 것이다.
이 구조로 인해, PlacementManager는 배치 상태를 시작하라까지만 알고, 그 이후 어떤 전략이 동작하는지는 상태가 책임지게 된다.
또한 같은 배치 상태 안에서도 바닥, 벽, 벽 내부, 자유 배치, 벽 근처 배치가 서로 다른 전략을 갖게 되므로, 구조물 종류가 늘어나더라도 상위 계층을 크게 수정하지 않고 확장할 수 있다.
결과적으로 PlacingObjectsState는 단순한 상태 클래스가 아니라, 배치 타입과 선택 전략을 연결하는 핵심 상태 구성 계층으로 설계되었다.
