자유 배치 전략

목차

1. 요구 사항

2. 흐름도

3. 구현

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

       3.2. 생성자 및 외부 데이터 연결

       3.3. 선택 시작 처리

       3.4. 선택 진행 처리

       3.5. 배치 검증

       3.6. 선택 종료 처리

4. 개발 의도

1. 시스템 요구 사항

Grid 기반 건축 시스템에서 가구나 일반 오브젝트처럼 비교적 자유롭게 배치되는 구조물은, 벽처럼 경로를 따라 이어지지도 않고 바닥처럼 넓은 영역을 박스로 채우지도 않는다.

대신 플레이어가 마우스로 가리키는 현재 위치를 기준으로 단일 배치 위치를 결정하고, 그 위치에 해당 오브젝트가 실제로 들어갈 수 있는지를 검사하는 방식으로 동작해야 한다.

하지만 여기서 자유 배치는 아무 제약 없이 아무 셀에나 놓을 수 있다는 뜻이 아니다.

선택된 위치가 Grid 범위 안에 있어야 하고, 이미 다른 오브젝트가 점유하고 있지 않아야 하며, 오브젝트의 크기가 1x1이 아닌 경우에는 배치 방향에 따라 점유 셀 범위가 달라지는 점까지 고려해야 한다.

또한 이 시스템에서는 벽과 벽 내부 오브젝트까지 별도 레이어로 관리하고 있기 때문에, 일반 오브젝트가 이 레이어들을 비정상적으로 가로지르거나 겹치지 않는지도 함께 확인해야 한다.

따라서 자유 배치 전략은 단순히 현재 위치를 선택한다는 수준에서 끝나지 않는다.

입력 좌표를 Grid 좌표로 해석하고, 이전 프레임과 같은 위치라면 불필요한 계산을 생략하며, 현재 오브젝트의 크기와 회전을 반영한 배치 검증을 수행하고, 최종적으로 실제 배치 가능 여부를 SelectionData 안에 기록해야 한다.

즉, FreeObjectPlacementStrategy는 단일 위치 선택 전략이면서 동시에 회전 보정과 다단계 충돌 검증을 함께 수행하는 자유 배치 전용 전략이어야 한다.

2. 흐름도

마우스 클릭

   ↓

StartSelection()

   ↓

마지막 감지 위치 초기화

   ↓

ModifySelection() 즉시 호출

   ↓

현재 마우스 위치를 Grid 좌표로 변환

   ↓

이전 위치와 비교

   ├─ 같으면 종료

   └─ 다르면 선택 데이터 초기화

           ↓

           현재 Grid/World 좌표 기록

           ↓

           오브젝트 크기에 따라 회전 정보 구성

           ↓

           ValidatePlacement()

           ↓

           Grid 범위 검사

           ↓

           현재 오브젝트 레이어 점유 검사

           ↓

           벽 내부 객체 교차 검사

           ↓

           벽 교차 검사

           ↓

           PlacementValidity 저장

   ↓

마우스 버튼 해제

   ↓

FinishSelection()

   ↓

마지막 감지 위치 초기화

이 전략의 흐름은 WallPlacementStrategy처럼 경로를 계산하지도 않고, BoxSelection처럼 박스 범위를 확장하지도 않는다.

선택 시작 시점에서 바로 현재 위치를 선택 대상으로 삼고, 이후 마우스가 다른 셀로 이동했을 때만 그 위치를 다시 선택 데이터로 갱신한다.

이때 핵심은 좌표 선택 자체보다, 그 위치가 실제로 비어 있는지, 오브젝트 크기와 회전까지 고려했을 때 다른 벽 또는 벽 내부 구조물을 가로지르지 않는지까지 순차적으로 검사하는 데 있다.

이 전략은 단일 위치 선택 구조를 가지지만, 내부적으로는 꽤 많은 검증 단계를 거쳐 최종 배치 가능 여부를 판단한다.

3. 구현

3.1. 클래스 구조와 상태 필드
public class FreeObjectPlacementStrategy : SelectionStrategy
{
    protected PlacementGridData wallPlacementData, inWallPlacementData;

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

SelectionStrategy가 정의한 공통 선택 흐름 위에 자유 배치 오브젝트 전용 해석 규칙을 추가하는 구조다.

여기서 가장 먼저 봐야 하는 필드는 wallPlacementData와 inWallPlacementData다.

이 클래스는 일반 오브젝트를 배치하는 전략이기 때문에 겉보기에는 현재 오브젝트 레이어만 보면 될 것 같지만, 실제로는 벽과 벽 내부 오브젝트를 가로지르지 않는지도 함께 검사해야 한다.

그래서 현재 오브젝트가 기록될 기본 placementData는 상위 SelectionStrategy에서 받고, 여기에 추가로 벽 레이어와 벽 내부 레이어를 별도 필드로 들고 있는 것이다.

이 구조의 의미는 분명하다.

이 전략은 현재 오브젝트가 들어갈 수 있는 빈 공간인지만 판단하는 것이 아니라, 다른 구조 레이어와 공간적으로 충돌하지 않는지까지 확인해야 한다.

즉, 단일 레이어 검사가 아니라 다중 레이어 검사를 수행하는 전략이라는 점이 이 필드 선언에 그대로 드러나 있다.

3.2. 생성자와 외부 데이터 연결
public FreeObjectPlacementStrategy(PlacementGridData placementData, PlacementGridData wallPlacementData, PlacementGridData inWallPlacementData, GridManager gridManager) : base(placementData, gridManager)
{
    this.wallPlacementData = wallPlacementData;
    this.inWallPlacementData = inWallPlacementData;
}

이 생성자는 이 전략이 동작하기 위해 필요한 외부 데이터를 연결하는 부분이다.

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

이 과정에서 현재 오브젝트 레이어를 의미하는 placementData, 좌표 변환을 담당하는 gridManager, 그리고 내부 캐시 역할을 하는 lastDetectedPosition이 공통 필드로 준비된다.

그 다음 FreeObjectPlacementStrategy는 자신의 고유 의존성인 wallPlacementData와 inWallPlacementData를 저장한다.

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

만약 이 클래스가 내부에서 직접 Grid 데이터를 만들거나 찾아오려 했다면, 현재 맵 상태와 연결된 실제 데이터 레이어를 올바르게 사용할 수 없었을 것이다.

이런 구조의 장점은 어떤 데이터 위에서 이 전략이 동작하는지가 코드상에서 분명하게 드러난다는 점이다.

단점은 생성자 인자가 많아질수록 초기화 코드가 길어진다는 점인데, 현재 전략은 여러 배치 레이어와 충돌 검사를 수행해야 하므로 오히려 이 명시성이 더 중요하다.

이 생성자는 단순 값 대입이 아니라, 이 전략은 세 개의 배치 레이어를 함께 참조한다는 사실을 선언하는 부분이라고 볼 수 있다.

3.3. 선택 시작 처리
public override void StartSelection(Vector3 mousePosition, SelectionData selectionData)
{
    this.lastDetectedPosition.Reset();
    ModifySelection(mousePosition, selectionData);
}

이 함수는 플레이어가 마우스를 처음 클릭해서 자유 배치를 시작할 때 호출되는 함수이다.

여기서 중요한 점은 이 전략에서 선택 시작이 별도의 복잡한 초기 상태를 만드는 단계가 아니라는 것이다.

WallPlacementStrategy처럼 시작점을 따로 저장하지도 않고, BoxSelection처럼 선택 범위의 기준점을 잡지도 않는다.

자유 배치에서는 현재 마우스가 가리키는 한 위치가 곧 선택 결과이기 때문에, 선택 시작과 선택 진행의 경계가 거의 없다.

첫 줄에서 lastDetectedPosition.Reset()을 호출하는 이유는 이전 선택에서 남아 있던 마지막 감지 위치를 초기화하기 위해서다.

이 값을 초기화하지 않으면, 새로운 선택이 시작되어도 첫 위치가 이전 선택의 마지막 위치와 같다고 판단되어 ModifySelection이 실행되지 않을 수 있다.

즉, 이 Reset은 단순 초기화가 아니라 새로운 선택 세션을 깨끗하게 시작하기 위한 상태 리셋이다.

그 다음 바로 ModifySelection(mousePosition, selectionData)를 호출한다.

이 구조는 매우 실용적이다. 보통은 StartSelection에서 시작점만 저장하고, 실제 데이터 갱신은 다음 프레임의 Update 루프에서 처리할 수도 있다.

하지만 자유 배치는 시작하자마자 바로 현재 위치 하나를 선택해야 하므로, 첫 클릭 순간에 즉시 ModifySelection을 실행하는 편이 더 자연스럽다.

이렇게 하면 마우스를 누른 첫 프레임부터 프리뷰가 바로 나타나고, 입력 지연도 줄어든다.

즉, 이 함수는 별도의 독립 로직을 거의 가지지 않고, 선택 시작 순간에도 ModifySelection과 동일한 흐름을 즉시 적용한다는 의미를 가진다.

3.4. 선택 진행 처리
public override bool ModifySelection(Vector3 mousePosition, SelectionData selectionData)
{
    Vector3Int tempPos = gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType);
    if (lastDetectedPosition.TryUpdatingPositon(tempPos))
    {
        // 선택 데이터를 초기화
        selectionData.Clear();
        selectionData.AddToWorldPositions(gridManager.GetWorldPosition(lastDetectedPosition.GetPosition()));
        selectionData.AddToGridPositions(lastDetectedPosition.GetPosition());

        if (selectionData.PlacedItemData.size.x == selectionData.PlacedItemData.size.y)
        {
            // 그리드 확인을 위해 회전은 0으로 유지
            selectionData.SetGridCheckRotation(new() { Quaternion.identity });
        }
        else
        {
            List<Quaternion> rotations = new() { HandleRotation(selectionData.Rotation,selectionData) };
            selectionData.SetGridCheckRotation(rotations);
            selectionData.SetObjectRotation(rotations);
        }

        selectionData.PlacementValidity = ValidatePlacement(selectionData);

        return true;
    }
    return false; 
}

이 함수는 자유 배치 전략의 핵심 함수이며, 선택이 시작된 이후 마우스 위치가 바뀔 때마다 반복적으로 호출되는 함수이다.

자유 배치 전략에서 실제 선택 상태 갱신은 대부분 이 함수 안에서 일어난다.

가장 먼저 현재 마우스 위치를 Grid 좌표로 변환한다.

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

여기서 중요한 점은 단순히 mousePosition만 넘기는 것이 아니라 selectionData.PlacedItemData.objectPlacementType도 함께 넘긴다는 것이다. GridManager는 배치 타입에 따라 셀 중심 기준인지, 경계 기준인지 다른 방식으로 좌표를 해석한다. 자유 배치 오브젝트는 일반적으로 셀 중심 기준이지만, 이 전략 자체를 좀 더 일반적인 구조로 유지하기 위해 현재 배치 타입을 명시적으로 전달하고 있다. 즉 이 코드는 단순한 좌표 변환이라기보다 “현재 오브젝트의 배치 규칙을 반영한 Grid 좌표 해석”이다.

그 다음 줄은 성능과 직결되는 핵심 조건문이다.

if (lastDetectedPosition.TryUpdatingPositon(tempPos))

TryUpdatingPositon은 내부적으로 이전에 처리한 좌표와 현재 좌표를 비교해서, 실제로 위치가 바뀌었을 때만 true를 반환한다.

Unity의 입력 처리는 보통 Update 루프 안에서 매 프레임 수행되는데, 마우스가 같은 셀 안에 그대로 있는 동안에도 함수는 계속 호출될 수 있다.

이때 이 조건이 없다면 SelectionData를 매 프레임 다시 지우고 다시 채우고 다시 검증하게 된다.

자유 배치 전략은 경로 계산까지는 없지만, 검증 로직은 여전히 적지 않기 때문에 이런 중복 연산은 불필요하다.

따라서 이 조건은 단순 비교가 아니라, 동일 셀에서는 아무 작업도 하지 않게 만드는 갱신 필터 역할을 한다.

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

상위 PlacementSelector는 이 값이 true일 때만 OnSelectionChanged 이벤트를 발생시키기 때문에, 전체 선택 시스템의 이벤트 노이즈까지 줄일 수 있다.

조건을 통과하면 가장 먼저 selectionData.Clear()를 호출한다.

이 전략은 선택 결과를 누적하는 방식이 아니라, 현재 위치 하나를 기준으로 다시 구성하는 방식이므로 이전 선택 데이터를 모두 지워야 한다.

이 초기화가 없으면 이전 위치 정보가 남아 잘못된 선택 결과가 누적될 수 있다.

그 다음 현재 위치를 두 가지 형태로 저장한다.

selectionData.AddToWorldPositions(gridManager.GetWorldPosition(lastDetectedPosition.GetPosition()));
selectionData.AddToGridPositions(lastDetectedPosition.GetPosition());

여기서 Grid 좌표는 논리적인 충돌 검사와 배치 데이터 기록에 사용되고, 월드 좌표는 실제 오브젝트 생성이나 프리뷰 이동에 사용된다.

이 둘을 분리해 저장하는 이유는 역할이 다르기 때문이다.

만약 Grid 좌표만 저장하고 나중에 필요할 때마다 월드 좌표로 다시 바꾸면 좌표 변환이 계속 반복될 것이고, 반대로 월드 좌표만 저장하면 Grid 기반 검증이 불편해진다.

그래서 SelectionData는 두 형태를 모두 유지한다.

이 구조 덕분에 이후 계층은 자신의 목적에 맞는 좌표계만 바로 사용할 수 있다.

그 다음은 자유 배치 전략에서 중요한 회전 처리 분기다.

if (selectionData.PlacedItemData.size.x == selectionData.PlacedItemData.size.y)
{
    selectionData.SetGridCheckRotation(new() { Quaternion.identity });
}
else
{
    List<Quaternion> rotations = new() { HandleRotation(selectionData.Rotation,selectionData) };
    selectionData.SetGridCheckRotation(rotations);
    selectionData.SetObjectRotation(rotations);
}

이 분기는 오브젝트가 정사각형인지 직사각형인지를 판단한다.

정사각형이면 x와 y 크기가 같기 때문에, 90도 회전을 하더라도 점유 셀 범위가 바뀌지 않는다.

예를 들어 1x1, 2x2 오브젝트는 회전해도 Grid 기준 점유 결과가 동일하다.

그래서 이런 경우에는 검사용 회전을 굳이 복잡하게 계산할 필요가 없고, Quaternion.identity를 넣어 기본 회전으로 처리해도 충분하다.

반대로 직사각형이면 회전에 따라 점유 범위가 달라지므로 현재 회전을 고려해야 한다.

이때 HandleRotation(selectionData.Rotation, selectionData)를 호출해 회전을 보정한 뒤, 그 결과를 SetGridCheckRotation과 SetObjectRotation 모두에 넣는다.

이 구조는 매우 중요하다.

GridCheckRotation은 검증용 회전이고, ObjectRotation은 실제 오브젝트 시각 회전이다.

현재는 같은 값을 사용하지만, 둘을 분리해 둔 덕분에 나중에 검증 회전과 시각 회전이 달라지는 경우에도 대응할 수 있다.

즉, 이 분기는 단순한 크기 검사라기보다 오브젝트 크기에 따라 회전 처리 방식을 달리하는 규칙을 구현한 부분이다.

마지막으로 selectionData.PlacementValidity = ValidatePlacement(selectionData);를 호출한다.

이 한 줄은 선택 위치가 실제로 배치 가능한지를 최종적으로 결정한다.

이 값은 이후 Preview 시스템의 색상, PlacementManager의 실제 배치 허용 여부 등 여러 계층에 영향을 준다.

즉, ModifySelection은 단순히 현재 위치를 기록하는 함수가 아니라, 현재 위치를 하나의 완전한 SelectionData로 만들어내는 함수라고 볼 수 있다.

3.5 배치 검증
protected override bool ValidatePlacement(SelectionData selectionData)
{
    bool validity = PlacementValidator.CheckIfPositionsAreValid(
        selectionData.GetSelectedGridPositions(),
        placementData,
        selectionData.PlacedItemData.size,
        selectionData.GetSelectedPositionsGridRotation(),
        false);

    // 이전 검사가 TRUE인 경우에만
    if (validity)
    {
        // ObjectPlacementData에서 배치 위치가 비어 있는지 확인
        validity = PlacementValidator.CheckIfPositionsAreFree(
        selectionData.GetSelectedGridPositions(),
        placementData,
        selectionData.PlacedItemData.size,
        selectionData.GetSelectedPositionsGridRotation(),
        false);
    }
    if(validity) // 벽 내부(IN WALL) 객체
    {
        // 객체를 배치할 때, 그들의 크기로 인해 두 번째 타일이 벽 안에 배치되지 않도록(벽을 교차하지 않도록) 확인해야 함 (예: 2x1 크기).

        validity = PlacementValidator.CheckIfNotCrossingEdgeObject(
        selectionData.GetSelectedGridPositions(),
        inWallPlacementData, 
        selectionData.PlacedItemData.size,
        selectionData.GetSelectedPositionsGridRotation(),
        false);
    }
    if (validity) // 벽(WALL) 객체
    {
        // 객체를 배치할 때, 그들의 크기로 인해 두 번째 타일이 벽 안에 배치되지 않도록(벽을 교차하지 않도록) 확인해야 함 (예: 2x1 크기).
        validity = PlacementValidator.CheckIfNotCrossingEdgeObject(
        selectionData.GetSelectedGridPositions(),
        wallPlacementData, 
        selectionData.PlacedItemData.size,
        selectionData.GetSelectedPositionsGridRotation(),
        false);
    }
    return validity;
}

이 함수는 현재 선택된 자유 배치 오브젝트가 실제로 배치 가능한지 판단할 때 호출되는 함수이다.

이 함수는 ModifySelection 내부에서 호출되며, 선택 위치가 바뀔 때마다 반복 실행되며, 단발성 검사가 아니라 실시간 프리뷰와 함께 계속 업데이트되는 검증 함수다.

첫 번째 검사는 PlacementValidator.CheckIfPositionsAreValid(...)이다.

이 검사는 선택된 위치가 Grid 범위 내에서 유효한지를 확인한다.

여기서 중요한 점은 단순히 시작 좌표만 보는 것이 아니라, 오브젝트 크기와 회전까지 함께 넘겨서 전체 점유 영역을 기준으로 유효성을 검사한다는 것이다.

예를 들어 2x1 오브젝트는 시작 좌표 하나만 Grid 안에 있어도, 회전 방향에 따라 두 번째 셀이 Grid 밖으로 나갈 수 있다.

그래서 size와 rotation을 함께 넘겨 전체 점유 영역을 기준으로 검증하는 것이다.

마지막 인자를 false로 둔 것은 이 전략이 Edge 기반 배치가 아니라 일반 셀 기반 배치이기 때문이다.

그 다음 조건문은 이전 검사가 통과했을 때만 이어진다.

if (validity){    validity = PlacementValidator.CheckIfPositionsAreFree(...)}

이 구조는 검증을 단계별로 쌓아 가는 전형적인 방식이다.

이미 범위가 유효하지 않다면, 그 다음의 점유 여부나 교차 여부를 검사할 필요가 없기 때문이다.

두 번째 검사는 현재 오브젝트 레이어(placementData)에서 해당 위치가 비어 있는지를 확인한다.

즉, 같은 일반 오브젝트끼리 서로 겹치지 않는지를 보는 단계다.

이 검사를 별도로 두는 이유는, 좌표가 유효한 것과 그 좌표가 비어 있는 것은 전혀 다른 조건이기 때문이다.

세 번째 검사는 inWallPlacementData를 대상으로 CheckIfNotCrossingEdgeObject(...)를 호출한다.

이 부분이 자유 배치 전략의 중요한 특징 중 하나다.

현재 오브젝트는 셀 기반으로 배치되지만, 그 크기가 1x1이 아니면 두 번째 셀이나 세 번째 셀이 벽 내부 구조물과 비정상적으로 겹칠 수 있다.

예를 들어 2x1 가구가 놓이면서 그 한쪽이 창문이나 문 같은 InWall 객체를 가로지르면 어색하고 잘못된 배치가 된다.

그래서 단순 점유 여부가 아니라 교차하지 않는지를 별도로 검사하는 것이다.

함수 이름 자체가 그 의도를 잘 보여준다.

이 검사는 자유 배치가 사실상 다중 셀 구조물도 포함한다는 것을 코드 차원에서 드러내는 부분이다.

네 번째 검사는 wallPlacementData를 대상으로 같은 CheckIfNotCrossingEdgeObject(...)를 호출한다.

벽 내부 구조물뿐 아니라 실제 벽 자체와도 비정상적으로 교차하지 않는지 확인하는 것이다.

예를 들어 긴 가구가 벽을 반쯤 가로지르는 상황을 막기 위한 조건이라고 볼 수 있다.

이 단계가 별도로 존재하는 이유는 벽과 벽 내부 구조물이 서로 다른 레이어에 저장되기 때문이다. 따라서 각각 별도로 검사해야 한다.

이 함수 전체를 보면 단순히 한 번의 if로 끝나는 것이 아니라, '범위 유효성 → 현재 레이어 비어 있음 → InWall 교차 없음 → Wall 교차 없음' 이라는 순차적 검증 파이프라인을 구성하고 있다.

이 구조의 장점은 각 조건의 의미가 분리되어 있어 디버깅과 확장이 쉽다는 점이다.

어떤 조건에서 false가 되었는지 파악하기도 쉽고, 새로운 검증 규칙을 추가할 때도 중간에 조건 하나를 더 끼워 넣으면 된다.

ValidatePlacement는 단순한 bool 반환 함수가 아니라, 자유 배치 오브젝트에 필요한 공간 규칙을 단계적으로 적용하는 핵심 검증 함수다.

3.6. 회전 처리
public override Quaternion HandleRotation(Quaternion rotation, SelectionData selectionData)
{
    if (selectionData.PlacedItemData.size.x == selectionData.PlacedItemData.size.y)
        return rotation;

    int currentRotation = Mathf.RoundToInt(rotation.eulerAngles.y);

    Quaternion valueToReturn;

    if (selectionData.PlacedItemData.size.x > selectionData.PlacedItemData.size.y)
    {
        
        if (currentRotation == 0 || currentRotation == 180)
            valueToReturn = Quaternion.identity;
        else
            valueToReturn = Quaternion.Euler(0, 270, 0);
    }
    else
    {
        if (currentRotation == 0 || currentRotation == 180)
            valueToReturn = Quaternion.identity;
        else
            valueToReturn = Quaternion.Euler(0, 90, 0);
    }
    selectionData.SetObjectRotation(new() { valueToReturn });
    selectionData.SetGridCheckRotation(new() { valueToReturn });
    return valueToReturn;
}

이 함수는 자유 배치 오브젝트의 회전을 처리할 때 호출되는 함수이다.

SelectionStrategy에서 기본 구현은 단순히 입력된 rotation을 그대로 반환하지만, FreeObjectPlacementStrategy는 직사각형 오브젝트의 회전 규칙을 제한해야 하기 때문에 이를 override하고 있다.

가장 먼저 정사각형인지 검사한다.

if (selectionData.PlacedItemData.size.x == selectionData.PlacedItemData.size.y) return rotation;

이 조건이 참이라는 것은 오브젝트의 가로와 세로 길이가 같다는 뜻이다.

정사각형이면 90도 회전을 해도 점유 셀 범위가 변하지 않기 때문에, 별도의 보정 없이 현재 rotation을 그대로 써도 된다.

이 early return은 불필요한 회전 보정 계산을 생략하는 최적화이기도 하다.

정사각형이 아니라면 현재 회전을 Y축 각도로 읽어온다.

int currentRotation = Mathf.RoundToInt(rotation.eulerAngles.y);

Unity의 Quaternion은 내부 계산에는 좋지만 사람이 직접 비교하기에는 불편하다.

그래서 여기서는 rotation.eulerAngles.y를 사용해 Y축 회전값을 꺼내고, Mathf.RoundToInt로 반올림해 정수 각도로 만든다.

이 과정을 거치는 이유는 Grid 시스템에서는 회전이 연속적인 값이 아니라 0, 90, 180, 270 같은 직교 각도로 해석되기 때문이다.

부동소수점 값 그대로 비교하면 89.9999 같은 오차 때문에 조건문이 불안정해질 수 있으므로, 정수화하는 것이 안전하다.

그 다음 selectionData.PlacedItemData.size.x > size.y인지 확인한다.

이 분기는 오브젝트가 가로로 긴지, 세로로 긴지를 나누는 부분이다.

현재 코드의 주석에도 적혀 있듯이, 이 시스템은 직사각형 오브젝트를 모든 방향으로 자유 회전시키지 않고 특정 두 방향만 허용한다.

이유는 단순하다.

데이터 저장과 Grid 검증을 쉽게 만들기 위해서다.

모든 방향을 허용하면 점유 계산과 저장 규칙이 복잡해지므로, 현재 구조에서는 회전을 0도/90도 계열로 제한하는 방식을 선택했다.

가로가 더 긴 경우에는 0 또는 180이면 Quaternion.identity를 사용하고, 그렇지 않으면 Quaternion.Euler(0, 270, 0)을 사용한다.

실제로는 가로 배치와 세로 배치, 이 두 상태만 허용하는 셈이다.

세로가 더 긴 경우에는 같은 구조로 Quaternion.Euler(0, 90, 0)을 사용한다.

Quaternion.Euler는 Unity에서 오일러 각도를 기반으로 Quaternion을 만드는 함수다.

장점은 사람이 이해하기 쉬운 각도를 기준으로 회전을 만들 수 있다는 점이고, 단점은 오일러 각도 자체가 누적 회전에는 약하다는 점이다.

하지만 여기서는 최종 방향 한 번만 정하는 용도이므로 매우 적절하다.

마지막으로 계산된 회전값을 SelectionData에 넣는다.

selectionData.SetObjectRotation(new() { valueToReturn });
selectionData.SetGridCheckRotation(new() { valueToReturn });
return valueToReturn;

이 코드는 실제로 반환만 하는 것이 아니라 SelectionData 내부 상태까지 함께 갱신한다.

HandleRotation은 단순한 계산 함수가 아니라, 회전값이 바뀌었을 때 그 결과를 현재 선택 데이터에 즉시 반영하는 함수다.

ObjectRotation과 GridCheckRotation을 동시에 맞춰 두는 이유는, 검사용 회전과 실제 시각 회전이 현재 전략에서는 동일해야 하기 때문이다.

결과적으로 이 함수는 자유 회전 기능을 제공하기보다, 오브젝트 크기에 따라 허용 가능한 회전만 남기도록 보정하는 회전 제어 함수라고 볼 수 있다.

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

이 함수는 선택이 끝났을 때 호출되는 종료 처리 함수이다.

여기서 하는 일은 lastDetectedPosition.Reset() 하나뿐이지만, 이 초기화는 매우 중요하다.

자유 배치 전략은 lastDetectedPosition을 사용해 같은 셀에서 불필요한 갱신을 막는다.

그런데 이 값을 다음 선택에도 그대로 남겨 두면, 새로운 선택이 시작되었을 때 첫 위치가 예전 선택의 마지막 위치와 같다고 판단되어 ModifySelection이 실행되지 않을 수 있다.

그러면, 입력 반응이 끊긴 것처럼 보이는 버그가 생길 수 있다.

따라서 FinishSelection에서 lastDetectedPosition을 초기화하는 것은 단순 정리가 아니라, 다음 선택 세션이 정상적으로 동작하도록 보장하는 생명주기 종료 처리다.

이런 작은 초기화 코드가 없으면 선택 전략 전체가 불안정해질 수 있기 때문에, 코드 한 줄이지만 매우 중요한 역할을 한다.

4. 개발 의도

FreeObjectPlacementStrategy의 핵심 설계 의도는 “자유 배치”를 단순한 무제한 배치로 두지 않고, Grid 기반 게임 공간 안에서 논리적으로 안전한 배치로 제한하는 것이다.

플레이어는 어디에든 오브젝트를 놓을 수 있는 것처럼 느끼지만, 시스템 내부에서는 현재 오브젝트 레이어, 벽 레이어, 벽 내부 레이어를 모두 검사하여 실제로 들어갈 수 있는 위치만 허용한다.

자유 배치라는 표현은 플레이어 경험의 측면이고, 코드 수준에서는 상당히 많은 제약을 통과한 결과물이다.

또한 이 전략은 단일 위치 선택 구조를 사용하면서도, LastDetectedPosition을 통해 Update 기반 중복 연산을 줄이고, 오브젝트 크기에 따라 회전 규칙을 따로 제한하며, ValidatePlacement 안에 다단계 검증 파이프라인을 구성해 유지보수성과 확장성을 확보했다.

결과적으로 FreeObjectPlacementStrategy는 단순한 현재 셀 선택 코드가 아니라, 단일 선택·회전 보정·다중 레이어 충돌 검사를 하나의 전략 안에서 통합한 자유 배치 전용 전략 시스템으로 설계되었다.