벽 배치 전략 시스템

목차

1. 요구 사항

2. 흐름도

3. 구현

        3.1. 클래스 구조와 상태 필드

       3.2. 생성자와 공통 데이터 연결

       3.3. 선택 시작 처리

       3.4. 선택 진행 처리

       3.5. 배치 검증

       3.6. 선택 종료 처리

4. 개발 의도

1. 시스템 요구 사항

Grid 기반 건축 시스템에서 벽은 바닥이나 가구와는 전혀 다른 배치 규칙을 가진다.

바닥은 셀의 중심을 기준으로 배치되지만, 벽은 두 셀 사이의 경계(edge)에 배치되어야 한다.

이 차이 때문에 동일한 선택 로직이나 좌표 해석 방식으로는 벽을 정확하게 배치할 수 없다.

특히 벽 배치는 단순히 한 점을 선택하는 것이 아니라, 플레이어가 드래그한 방향을 따라 연속적으로 배치되는 형태를 가진다.

즉 시작 지점과 끝 지점 사이의 경로를 계산하고, 그 경로를 따라 벽을 일정한 방향으로 정렬하여 배치해야 한다.

이 과정에서는 단순 좌표 나열이 아니라 경로 기반 선택이 필요하다.

또한 벽은 방향성을 가지기 때문에 단순히 위치만 계산하는 것이 아니라 각 위치에서의 회전값도 함께 계산되어야 한다.

같은 셀 경계라도 어떤 방향으로 벽이 놓이느냐에 따라 완전히 다른 구조물이 되기 때문이다.

이와 함께 벽 배치는 기존 구조물과 충돌하지 않아야 하며, 이미 동일한 위치에 같은 방향의 벽이 존재하는 경우에는 배치가 불가능해야 한다.

즉, 선택 단계에서부터 Grid 데이터 기반 검증이 함께 수행되어야 한다.

따라서 WallPlacementStrategy는 다음과 같은 요구사항을 만족해야 한다.

플레이어 입력을 기반으로 시작점과 끝점을 설정한다.

두 지점 사이의 Grid 경로를 계산한다.

경로를 따라 벽 배치 위치를 생성한다.

각 위치에 대한 회전값을 계산한다.

Grid 데이터 기준으로 배치 가능 여부를 검증한다.

셀 중심이 아닌 경계 기준 좌표 해석을 수행한다.

이 전략은 단순 선택 로직이 아니라 '경로 기반 배치 + 방향 계산 + 경계 좌표 해석' 을 동시에 수행하는 선택 전략이다.

2. 흐름도

마우스 입력 시작

   ↓

StartSelection()

   ↓

시작 Grid 좌표 저장

   ↓

마우스 이동

   ↓

ModifySelection()

   ↓

현재 Grid 좌표 계산

   ↓

이전 좌표와 비교

   ↓

경로(A*) 계산

   ↓

경로를 SelectionData로 변환

   ↓

회전값 계산

   ↓

ValidatePlacement()

   ↓

배치 가능 여부 반영

   ↓

선택 종료

   ↓

FinishSelection()

벽 배치 전략의 핵심 흐름은 단순한 범위 계산이 아니라 '경로 생성 → 방향 계산 → 데이터 구성' 의 순서로 진행된다는 점이다.

먼저 StartSelection에서 시작 지점을 기록한 뒤, ModifySelection이 호출될 때마다 현재 위치를 기준으로 시작점과 끝점 사이의 경로를 계산한다.

이때 GridSelectionHelper의 A* 알고리즘을 사용하여 실제 이동 가능한 경로를 생성한다.

이 경로는 단순 좌표 리스트가 아니라 벽이 놓일 위치를 의미하며, 이어서 해당 경로의 방향을 기반으로 각 위치에 필요한 회전값을 계산한다.

마지막으로 이 정보는 SelectionData로 정리되고, PlacementValidator를 통해 배치 가능 여부가 검증된다.

WallPlacementStrategy는 '선택 → 경로 생성 → 회전 계산 → 검증'으로 이어지는 구조를 가진다.

3. 구현

3.1. 클래스 구조와 상태 필드
public class WallPlacementStrategy : SelectionStrategy
{
    protected Vector3Int? startposition;
    protected PlacementGridData objectPlacementData, inWallPlacementData;

    ...
}

이 클래스는 SelectionStrategy를 상속받는 구체 전략 클래스이다.

즉, SelectionStrategy가 정의한 공통 선택 흐름 위에, 벽 전용 규칙만 추가하는 형태다.

여기서 가장 먼저 눈에 들어오는 필드는 startposition이다.

이 변수는 벽 배치의 시작 지점을 저장한다.

벽은 단순히 현재 마우스 위치만으로 선택할 수 없고, 반드시 어디서부터 시작했는지가 필요하기 때문에 이 상태값이 꼭 있어야 한다.

타입이 Vector3Int?인 것은 아직 시작점이 정해지지 않은 상태와 실제 Grid 좌표가 저장된 상태를 구분하기 위해서다.

C#에서 ?가 붙은 값 형식은 Nullable 타입을 의미하며, 값이 있을 수도 있고 없을 수도 있다는 것을 타입 레벨에서 표현할 수 있다.

이 방식을 사용하면 초기값을 임의의 좌표로 두지 않아도 되기 때문에 상태 표현이 명확하다.

단점은 사용할 때마다 HasValue나 Value를 통해 확인해야 해서 코드가 조금 길어지지만, 시작점이 없는 상태를 분명하게 표현해야 하는 현재 상황에서는 훨씬 적절하다.

또한 objectPlacementData와 inWallPlacementData라는 두 개의 추가 PlacementGridData를 들고 있다는 점도 중요하다.

placementData는 SelectionStrategy 상위 클래스에서 이미 들고 있으며, 여기서는 기본적으로 벽 레이어를 의미한다.

그런데 벽 배치는 벽 데이터만 보면 끝나지 않는다.

벽 내부 오브젝트와도 충돌하면 안 되고, 일반 오브젝트가 차지한 공간을 비정상적으로 가로질러서도 안 된다.

그래서 이 전략은 벽 자신이 기록될 레이어 외에, 일반 오브젝트 레이어와 벽 내부 오브젝트 레이어까지 함께 참조해야 한다.

즉, 이 전략은 단일 레이어가 아니라, 여러 배치 레이어 간의 관계를 동시에 고려하는 전략이라는 것이 이 필드 구조에 그대로 드러난다.

3.2. 생성자와 공통 데이터 연결
public WallPlacementStrategy(PlacementGridData placementData, PlacementGridData inWallPlacementData,
    PlacementGridData objectPlacementData, GridManager gridManager) : base(placementData, gridManager)
{
    this.inWallPlacementData = inWallPlacementData;
    this.objectPlacementData = objectPlacementData;
}

이 생성자는 WallPlacementStrategy가 동작하기 위해 필요한 외부 의존성을 받아 내부 필드에 연결한다.

먼저 : base(placementData, gridManager)를 통해 상위 SelectionStrategy 생성자를 호출한다.

이 과정에서 공통 필드인 placementData, gridManager, lastDetectedPosition이 초기화된다.

벽 전략 역시 SelectionStrategy가 제공하는 공통 인프라 위에서 동작한다.

그 다음 이 클래스 고유의 의존성인 inWallPlacementData와 objectPlacementData를 저장한다.

생성자 주입을 사용한 이유는 현재 전략이 어떤 Grid 데이터 위에서 동작할지를 외부 상태가 결정해야 하기 때문이다.

예를 들어 배치 상태가 바뀌거나 다른 맵 데이터 구조를 사용하게 되더라도, 생성자 인자를 바꿔 전략을 다시 만들면 된다.

만약 이 클래스가 내부에서 직접 new를 사용해 GridData를 만들었다면, 현재 상태와 연결된 실제 데이터 레이어를 알 수 없었을 것이다.

생성자 주입 방식의 장점은 의존성이 명확해지고 테스트나 확장이 쉬워진다는 점이고, 단점은 객체 생성 시 전달해야 할 인자가 많아진다는 점이다.

하지만 이 전략은 다중 레이어 충돌 검사를 수행해야 하므로, 어떤 레이어를 참조할지 생성 시점에 명시하는 편이 훨씬 안전하다.

3.3. 선택 시작 처리
public override void StartSelection(Vector3 mousePosition, SelectionData selectionData)
{
    selectionData.Clear();

    startposition = gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType);
    selectionData.AddToWorldPositions(gridManager.GetWorldPosition(startposition.Value));
    selectionData.PlacementValidity = true;

    lastDetectedPosition.TryUpdatingPositon(startposition.Value);
    Debug.Log($"{lastDetectedPosition.GetPosition()} 선택");

    if (selectionData.PlacementValidity == false) startposition = null;
}

이 메서드는 플레이어가 벽 배치를 시작하기 위해 마우스를 눌렀을 때 호출된다.

가장 먼저 selectionData.Clear()를 호출하는데, 이 코드는 이전 선택 상태를 완전히 지우는 역할을 한다.

벽 배치는 현재 시작점과 현재 위치를 기준으로 다시 계산되는 구조이기 때문에, 이전 프레임이나 이전 선택의 결과가 남아 있으면 안 된다.

SelectionData 안에는 선택된 Grid 좌표, 월드 좌표, 프리뷰 위치, 회전값, 유효성 정보 등이 담기므로, 새로운 선택을 시작할 때 이 데이터를 모두 깨끗하게 초기화하는 것이 필수적이다.

그 다음 줄에서 실제 시작 위치를 계산한다.

startposition = gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType);

이 코드는 단순한 월드 좌표 변환이 아니다.

gridManager.GetCellPosition은 현재 배치 타입을 함께 받아 셀 중심 배치인지, 경계 배치인지에 따라 보정까지 포함해서 Grid 좌표를 반환한다.

벽은 PlacementType.Wall이므로 Edge 기준 해석이 필요하고, GridManager 내부에서는 이를 위해 half cell 보정이 적용된다.

이 보정이 없다면 플레이어가 마우스로 가리킨 경계 위치와 실제 벽이 기록되는 좌표가 어긋날 수 있다.

즉, WallPlacementStrategy가 직접 좌표 해석을 복잡하게 구현하지 않고, GridManager가 제공하는 공통 좌표 해석 규칙을 재사용하는 구조다.

이렇게 책임을 분리한 덕분에 전략은 벽 배치 규칙에만 집중할 수 있고, 좌표계 문제는 GridManager가 전담하게 된다.

그 다음에는 시작점의 월드 좌표를 SelectionData에 추가한다.

selectionData.AddToWorldPositions(gridManager.GetWorldPosition(startposition.Value));

여기서는 아직 경로가 만들어지지 않았으므로 시작점 하나만 들어간다.

그런데도 WorldPosition을 저장하는 이유는 프리뷰 시스템이나 이후 위치 계산이 Grid 좌표만으로는 바로 동작하지 않기 때문이다.

Preview는 실제 씬 좌표가 필요하고, PlacementManager도 실행 단계에서는 월드 좌표를 참조한다.

그래서 선택 시작 직후부터 Grid 좌표와는 별도로 월드 좌표를 함께 보관한다.

selectionData.PlacementValidity = true;는 시작 시점의 기본 유효성을 true로 두는 코드다.

아직 실제 경로 검증은 하지 않았으므로, 일단 시작 자체는 허용한 상태로 두고 이후 ModifySelection에서 실제 유효성을 다시 계산한다는 의미다.

그 다음 lastDetectedPosition.TryUpdatingPositon(startposition.Value)를 호출한다.

이것은 선택 시작과 동시에 “마지막으로 처리한 위치”를 시작점으로 갱신하는 역할을 한다.

이렇게 해야 ModifySelection이 바로 다음 프레임에 같은 셀 위에서 호출되더라도, 위치가 바뀌지 않았다고 판단해서 불필요한 계산을 하지 않게 된다.

즉, LastDetectedPosition은 단순 캐시가 아니라 StartSelection과 ModifySelection을 연결하는 상태 유지 장치다.

이어지는 Debug.Log는 개발 중 디버깅을 위한 로그로, 현재 감지된 시작 위치를 콘솔에 출력해 선택이 올바르게 시작되었는지 확인하는 데 사용된다.

마지막 줄의 if (selectionData.PlacementValidity == false) startposition = null는 방어 코드다.

현재 코드 흐름상 StartSelection 단계에서는 PlacementValidity를 true로만 두고 있기 때문에 실제로는 거의 발생하지 않지만, 시작 단계에서 유효하지 않은 상태가 감지되면 아예 시작점을 무효화해 이후 계산을 막겠다는 의도가 반영된 코드다.

이런 방어 코드는 선택 흐름이 나중에 확장되었을 때 잘못된 시작 상태가 계속 유지되는 문제를 막는 데 도움이 된다.

3.4. 선택 진행 처리
public override bool ModifySelection(Vector3 mousePosition, SelectionData selectionData)
{
    Vector3Int tempPos = gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType);
    if (lastDetectedPosition.TryUpdatingPositon(tempPos))
    {
        selectionData.Clear();

        if (startposition.HasValue && startposition.Value != lastDetectedPosition.lastPosition.Value)
        {
            List<Vector3Int> path = GridSelectionHelper.AStar(startposition.Value, tempPos, placementData);

            Vector3 worldPos;

            for (int i = 0; i < path.Count - 1; i++)
            {
                worldPos = gridManager.GetWorldPosition(path[i]);
                selectionData.AddToWorldPositions(worldPos);
                selectionData.AddToPreviewPositions(worldPos);
                selectionData.AddToGridPositions(path[i]);
            }

            worldPos = gridManager.GetWorldPosition(lastDetectedPosition.lastPosition.Value);
            selectionData.AddToPreviewPositions(worldPos);

            List<Quaternion> rotationValues = GridSelectionHelper.CalculateRotation(path);

            rotationValues.Add(rotationValues[^1]);

            selectionData.SetObjectRotation(rotationValues);
            selectionData.SetGridCheckRotation(rotationValues);
            selectionData.PlacementValidity = ValidatePlacement(selectionData);
        }
        else
        {
            selectionData.AddToWorldPositions(gridManager.GetWorldPosition(lastDetectedPosition.GetPosition()));
            selectionData.PlacementValidity = true;
        }
        return true;
    }
    return false;
}

이 메서드는 WallPlacementStrategy의 핵심이다.

플레이어가 마우스를 움직일 때마다 호출되며, 현재 위치를 기준으로 벽 배치 경로를 다시 계산한다.

첫 줄에서는 현재 마우스 위치를 Grid 좌표로 변환한다.

여기서도 GridManager를 사용하는 이유는 벽 배치가 경계 기준 좌표 해석을 필요로 하기 때문이다.

즉, tempPos는 단순 클릭 셀이 아니라, 현재 벽이 이어질 끝점 후보라고 볼 수 있다.

그 다음 if (lastDetectedPosition.TryUpdatingPositon(tempPos))가 실행된다.

이 조건은 매우 중요하다.

TryUpdatingPositon은 현재 좌표가 이전에 처리한 좌표와 다를 때만 true를 반환한다.

즉, 같은 셀 위에 마우스가 그대로 있으면 이 메서드 전체가 더 이상 진행되지 않는다.

Unity의 입력 시스템은 보통 Update 루프 안에서 매 프레임 현재 마우스 위치를 읽게 되므로, 이런 캐싱 구조가 없으면 매 프레임 경로 탐색과 검증이 반복될 수 있다.

벽 배치에서는 A* 경로 계산까지 수행하므로, 이 최적화는 매우 의미가 크다.

반환형이 bool인 이유도 바로 여기 있다.

상위 PlacementSelector는 true일 때만 선택이 실제로 바뀌었다고 판단하고 변경 이벤트를 발생시킨다.

즉, 이 bool 값은 단순 성공/실패가 아니라, 다음 연산을 해도 되는지를 알려주는 신호다.

위치가 실제로 바뀌었다면 가장 먼저 selectionData.Clear()를 호출한다.

벽 경로는 현재 시작점과 끝점을 기준으로 처음부터 다시 계산되므로, 이전에 누적된 선택 결과를 유지하면 안 된다.

이 메서드는 Grid 위치, 월드 위치, 프리뷰 위치, 회전값, 유효성 정보까지 모두 비우기 때문에, 이후 계산은 완전히 새 상태에서 시작된다.

그 다음 분기문이 벽 전략의 본질을 보여준다.

if (startposition.HasValue && startposition.Value != lastDetectedPosition.lastPosition.Value)

이 조건은 시작점이 존재하고, 시작점과 현재 위치가 다를 때를 의미한다.

즉, 실제로 경로를 계산할 수 있는 상황인지 확인하는 것이다.

시작점과 현재 위치가 같다면 경로라고 부를 만한 것이 없으므로, 굳이 A*를 돌릴 필요가 없다.

조건이 참이면 A* 경로 계산을 수행한다.

List<Vector3Int> path = GridSelectionHelper.AStar(startposition.Value, tempPos, placementData);

이 줄이 벽 전략의 핵심이다.

BoxSelection이 박스 범위를 계산했다면, WallPlacementStrategy는 시작점과 끝점 사이의 경로를 계산한다.

여기서 사용되는 A는 GridSelectionHelper에 구현된 단순화된 경로 탐색 알고리즘이다.

일반적인 A는 G cost와 H cost를 함께 사용하지만, 현재 구현은 Manhattan Distance 중심의 단순화된 구조를 사용한다.

Grid 기반 직교 이동에서는 이 정도 휴리스틱으로도 충분히 자연스러운 경로를 얻을 수 있기 때문에, 복잡한 비용 계산보다 구현 단순성과 가독성을 우선한 선택으로 볼 수 있다.

이런 선택은 특히 게임 플레이에서 이론적으로 최적보다 예측 가능하고 안정적인 결과가 더 중요할 때 자주 쓰인다.

경로가 계산되면 그 결과를 SelectionData로 옮긴다.

for (int i = 0; i < path.Count - 1; i++)
{
    worldPos = gridManager.GetWorldPosition(path[i]);
    selectionData.AddToWorldPositions(worldPos);
    selectionData.AddToPreviewPositions(worldPos);
    selectionData.AddToGridPositions(path[i]);
}

이 반복문은 경로상의 각 지점을 실제 선택 데이터로 변환하는 단계다.

먼저 gridManager.GetWorldPosition(path[i])를 통해 Grid 좌표를 월드 좌표로 변환한다.

이렇게 만든 worldPos를 AddToWorldPositions와 AddToPreviewPositions에 모두 넣는데, 둘을 분리해 둔 이유는 데이터의 의미가 다르기 때문이다.

WorldPositions는 실제 배치에 사용될 기준 위치 리스트이고, PreviewPositions는 프리뷰 시스템이 시각적으로 보여줄 위치 리스트다.

현재 코드에서는 같은 값을 넣고 있지만, 두 리스트를 분리해 둔 덕분에 향후 프리뷰만 약간 띄워서 표시하거나 별도 처리하는 확장이 가능하다.

GridPositions에는 원래 경로상의 Grid 좌표를 그대로 넣는다.

즉, 이 루프는 하나의 경로를 Grid 기반 논리 데이터와 월드 기반 시각 데이터로 동시에 풀어내는 과정이다.

여기서 path.Count - 1까지만 순회하는 것도 중요하다.

마지막 위치는 뒤에서 별도로 PreviewPosition에 추가한다.

이는 이후 회전값 리스트 크기와 프리뷰 표시 구조를 맞추기 위한 처리다.

worldPos = gridManager.GetWorldPosition(lastDetectedPosition.lastPosition.Value);
selectionData.AddToPreviewPositions(worldPos);

이 코드는 마지막 끝점 자체를 프리뷰 위치에 추가하는 부분이다.

경로 구간은 시작점부터 끝점 직전까지를 배치될 벽 구간으로 보고, 마지막 현재 위치는 프리뷰 상의 종착점으로 따로 보여주는 구조라고 해석할 수 있다.

이 설계 덕분에 드래그 끝점까지 시각적으로 자연스럽게 연결된다.

그 다음 회전값 계산이 나온다.

List<Quaternion> rotationValues = GridSelectionHelper.CalculateRotation(path);
rotationValues.Add(rotationValues[^1]);

CalculateRotation(path)는 경로의 각 구간 방향을 보고 Quaternion 회전값 리스트를 만든다.

경로에 점이 N개 있으면 실제 구간은 N-1개이므로 회전값도 N-1개만 생긴다.

그런데 실제로 선택 데이터에 넣으려면 경로 위치 개수와 회전 개수가 맞아야 한다.

그래서 마지막 회전값을 한 번 더 복사해서 리스트 길이를 맞춘다.

여기서 rotationValues[^1]는 C#의 index-from-end 문법으로, 리스트의 마지막 요소를 의미한다.

장점은 rotationValues[rotationValues.Count - 1]보다 간결하다는 점이고, 단점은 문법에 익숙하지 않은 사람에게는 직관성이 떨어질 수 있다는 점이다.

하지만 현재처럼 마지막 요소를 다시 사용한다는 상황에서는 매우 읽기 좋은 문법이다.

그 다음 SetObjectRotation과 SetGridCheckRotation에 같은 회전값 리스트를 넣는다.

selectionData.SetObjectRotation(rotationValues);
selectionData.SetGridCheckRotation(rotationValues);

이렇게 두 종류의 회전을 분리해 저장하는 구조는 매우 좋은 설계다.

현재는 둘 다 같은 값을 사용하지만, 의미는 다르다.

ObjectRotation은 실제로 오브젝트를 어떤 방향으로 돌려서 배치할지에 대한 정보이고, GridCheckRotation은 배치 가능 여부를 검사할 때 어떤 방향을 기준으로 셀 점유를 볼지에 대한 정보다.

지금은 벽 배치에서는 둘이 같지만, 구조를 분리해 두었기 때문에 나중에 시각 회전과 검증 회전이 달라지는 특수 케이스도 대응할 수 있다.

즉, 이 코드는 현재보다 미래 확장을 염두에 둔 구조다.

마지막으로 selectionData.PlacementValidity = ValidatePlacement(selectionData)를 호출해 현재 경로 전체가 유효한지 검사한다.

이 값은 이후 Preview가 정상색인지 경고색인지 결정하는 데 사용되고, PlacementManager가 실제 배치를 실행할지 여부를 판단하는 데도 사용된다.

즉, ModifySelection은 단순히 선택 데이터를 채우는 것으로 끝나지 않고, 그 결과가 실행 가능한 상태인지까지 완성하는 메서드다.

else 분기는 시작점과 현재 위치가 같은 경우를 처리한다.

selectionData.AddToWorldPositions(gridManager.GetWorldPosition(lastDetectedPosition.GetPosition()));
selectionData.PlacementValidity = true;

이 상황에서는 벽 경로를 만들 수 없으므로, 현재 위치 하나만 world position에 넣고 일단 유효하다고 본다.

즉, 시작점 하나를 찍은 직후의 상태를 유지하는 처리다.

이 분기가 없으면 드래그를 시작하지 않은 첫 입력 상태에서 프리뷰가 비어 버릴 수 있다.

따라서 이 코드는 시작 직후의 최소 프리뷰 상태를 유지하기 위한 안전 장치라고 볼 수 있다.

전체적으로 ModifySelection은 단순 마우스 처리 메서드가 아니라, 현재 위치를 경로로 해석하고, 그 경로를 배치 데이터로 풀어낸 뒤, 회전과 유효성까지 완성하는 핵심 메서드라고 볼 수 있다.

3.5. 배치 검증
protected override bool ValidatePlacement(SelectionData selectionData)
{
    // 배치 위치가 유효한지 확인
    bool validity = PlacementValidator.CheckIfPositionsAreValid(
            selectionData.GetSelectedGridPositions(),
            placementData,
            selectionData.PlacedItemData.size,
            selectionData.GetSelectedPositionsGridRotation(),
            selectionData.PlacedItemData.objectPlacementType.IsEdgePlacement());
            
    if (validity)
    {
        // 해당 위치에 다른 벽이 없는지 확인
        validity = PlacementValidator.CheckIfPositionsAreFree(
            selectionData.GetSelectedGridPositions(),
            placementData,
            selectionData.PlacedItemData.size,
            selectionData.GetSelectedPositionsGridRotation(),
            selectionData.PlacedItemData.objectPlacementType.IsEdgePlacement());
    }
    if(validity)
    {
        // 해당 위치에 다른 벽 내부(IN Wall) 객체가 없는지 확인
        validity = PlacementValidator.CheckIfPositionsAreFree(
            selectionData.GetSelectedGridPositions(),
            inWallPlacementData,
            selectionData.PlacedItemData.size,
            selectionData.GetSelectedPositionsGridRotation(),
            selectionData.PlacedItemData.objectPlacementType.IsEdgePlacement());
    }
    if(validity)
    {
        // 가구 객체 내부에 벽을 배치하려는 것이 아닌지 확인
        validity = PlacementValidator.CheckIfNotCrossingMultiCellObject(
            selectionData.GetSelectedGridPositions(),
            objectPlacementData,
            selectionData.PlacedItemData.size,
            selectionData.GetSelectedPositionsGridRotation(),
            selectionData.PlacedItemData.objectPlacementType.IsEdgePlacement());
    }
    return validity;
}

이 메서드는 WallPlacementStrategy가 계산한 경로가 실제로 벽으로 배치 가능한지를 판단하는 핵심 검증 함수다.

SelectionStrategy에서 정의한 ValidatePlacement를 구현한 것으로, 외부에서 직접 호출되지는 않고 ModifySelection 내부에서 사용된다.

첫 번째 검사인 CheckIfPositionsAreValid는 가장 기본적인 범위 검사다.

선택된 모든 Grid 위치가 현재 Grid 범위 안에 존재하는지, 구조물 크기와 회전을 고려했을 때 유효한 좌표인지 확인한다.

벽은 Edge 기반 배치이므로 마지막 인자로 IsEdgePlacement의 결과를 넘기고 있다.

이 값 덕분에 PlacementValidator는 일반 셀 점유가 아니라 경계 기준 규칙으로 검사할 수 있다.

이 검사가 먼저 수행되는 이유는 간단하다.

Grid 범위 자체가 잘못되었다면, 그 다음의 점유 여부나 충돌 검사는 할 필요가 없기 때문이다.

즉, 가장 기본적인 조건부터 앞에 두는 구조다.

두 번째 검사인 CheckIfPositionsAreFree(... placementData ...)는 현재 벽 레이어 안에서 이미 점유된 자리가 없는지 확인한다.

즉, 같은 방향의 벽이 이미 존재하거나, 현재 경로가 다른 벽과 겹치는지를 검사한다.

이 검사는 첫 번째 검사가 true일 때만 수행된다.

이런 구조는 단순하지만 중요한 최적화다.

이미 범위가 틀린 경로에 대해 굳이 점유 여부까지 검사할 필요는 없기 때문이다.

세 번째 검사는 inWallPlacementData를 대상으로 같은 CheckIfPositionsAreFree를 호출한다.

이 부분이 벽 전략의 특징을 잘 보여준다.

벽은 벽끼리만 안 겹치면 되는 것이 아니라, 벽 내부에 들어가는 오브젝트와도 충돌하면 안 된다.

예를 들어 벽이 놓일 위치에 이미 창문이나 문 같은 InWall 오브젝트가 있다면, 그 경로는 유효하지 않다.

즉, 같은 검증 함수를 재사용하면서 대상 레이어만 바꾸는 방식으로 다층 충돌 검사를 구현하고 있다.

이건 매우 좋은 설계다.

검증 로직 자체는 하나로 유지하면서, 어떤 레이어를 검사할지에 따라 의미가 달라지기 때문이다.

마지막 검사는 CheckIfNotCrossingMultiCellObject(... objectPlacementData ...)다.

이 함수는 벽이 일반 가구처럼 여러 셀을 차지하는 오브젝트 내부를 비정상적으로 관통하지 않는지 검사한다.

벽은 경계선 위에 놓이지만, 그 경계가 어떤 멀티셀 오브젝트의 내부 영역을 가로지른다면 어색한 결과가 발생할 수 있다.

따라서 일반 오브젝트 레이어와의 교차 여부를 마지막 단계에서 확인한다.

이 메서드 전체의 구조는 매우 중요하다.

단순히 여러 검사를 모아놓은 것이 아니라, '가장 기본적인 조건 → 같은 레이어 충돌 → 관련 레이어 충돌 → 특수 교차 조건' 순으로 계층적으로 검사를 쌓아 올리고 있다.

그리고 매 단계는 이전 단계가 통과했을 때만 수행된다.

이 구조 덕분에 불필요한 연산을 줄일 수 있고, 동시에 코드 흐름도 읽기 쉬워진다.

결과적으로 이 메서드는 현재 벽 경로가 실제 배치 레이어 전반에서 문제가 없는지를 판단하는 최종 관문이다.

3.6. 선택 종료 처리
public override void FinishSelection(SelectionData selectionData)
{
    startposition = null;
    lastDetectedPosition.Reset();
}

이 메서드는 선택이 끝났을 때 내부 상태를 정리하는 역할을 한다.

먼저 startposition = null로 시작점을 비운다.

이 처리가 없으면 다음 벽 배치가 시작될 때 이전 시작점이 그대로 남아 있어, 새로운 입력이 들어와도 과거의 경로를 기준으로 계산하는 버그가 발생할 수 있다.

즉, 시작점은 선택 사이클 하나가 끝나면 반드시 초기화되어야 한다.

그 다음 lastDetectedPosition.Reset()을 호출한다.

이 역시 중요하다.

LastDetectedPosition은 이전 프레임의 마지막 Grid 좌표를 기억하고 있기 때문에, 이를 초기화하지 않으면 다음 선택이 시작될 때 첫 입력이 이미 처리한 위치처럼 오인될 수 있다.

즉, FinishSelection은 단순 정리 함수가 아니라, 이 전략을 다음 선택에 다시 사용할 수 있는 깨끗한 상태로 되돌리는 생명주기 종료 메서드다.

4. 개발 의도

WallPlacementStrategy의 핵심 설계 의도는 벽 배치를 단순 좌표 선택이 아니라 경로 기반 배치로 정의하는 것이다.

박스 선택과 달리 벽은 방향성과 연결성이 중요한 구조물이기 때문에, 단순히 영역을 채우는 방식으로는 자연스러운 배치가 불가능하다.

따라서 이 전략은 시작점과 끝점 사이의 경로를 계산하고, 그 경로를 따라 벽을 배치하는 구조로 설계되었다.

또한 GridSelectionHelper의 A* 알고리즘과 회전 계산 로직을 결합하여 '경로 + 방향' 을 동시에 해결하도록 구성하였다.

이 구조 덕분에 벽은 단순히 이어지는 것이 아니라, 실제 게임에서 자연스럽게 연결된 형태로 배치될 수 있다.

마지막으로 SelectionStrategy 구조를 사용하여 벽 배치 로직을 다른 선택 전략과 완전히 분리하였다.

이 설계는 시스템 확장성 측면에서 매우 중요하다.

새로운 선택 방식이 추가되더라도 기존 코드를 수정하지 않고 새로운 전략 클래스를 추가하는 것만으로 기능을 확장할 수 있기 때문이다.

결과적으로 WallPlacementStrategy는 Grid 기반 건축 시스템에서 경로 기반 구조물 배치를 담당하는 핵심 전략으로 설계되었으며, 선택, 경로 계산, 회전, 검증을 하나의 흐름으로 통합한 시스템이다.