박스 기반 선택 전략
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서 바닥과 같이 여러 셀을 한 번에 선택해서 배치해야 하는 구조물은 단일 클릭만으로 처리하기 어렵다.
플레이어는 마우스를 누른 위치를 시작점으로 삼고, 마우스를 이동시키면서 선택 범위를 확장한 뒤, 그 범위 전체에 동일한 규칙으로 구조물을 배치할 수 있어야 한다.
특히 이때 중요한 것은 선택 영역이 단순히 사각형처럼 보이는 것에 그치지 않고, 실제 배치 대상 구조물의 크기와 Grid 규칙을 반영하여 일관되게 계산되어야 한다는 점이다.
예를 들어 1x1 바닥 타일이라면 선택 범위를 한 칸씩 채우면 되지만, 2x2처럼 더 큰 구조물이라면 어떤 모서리에서 선택을 시작했는지에 따라 배치 기준이 달라진다.
시작 위치를 무시하고 단순히 최소 좌표와 최대 좌표만 기준으로 선택하면, 플레이어가 처음 클릭한 지점을 기준으로 정렬되지 않아서 배치 결과가 어색해질 수 있다.
따라서 선택 전략은 시작 지점을 보존한 상태에서, 선택 방향에 따라 서로 다른 순회 방식을 사용해 항상 플레이어가 클릭한 위치를 기준점으로 유지해야 한다.
또한 박스 선택은 마우스 이동 중에 계속 다시 계산되기 때문에, 이전 프레임과 같은 셀 위에 머무르는 경우에는 불필요한 재계산을 피해야 한다.
선택 전략은 현재 위치가 실제로 바뀌었을 때만 선택 데이터를 다시 구성하고, 그렇지 않으면 상위 시스템으로 변경 이벤트를 보내지 않도록 설계되어야 한다.
그리고 이렇게 계산된 선택 결과는 Grid 범위 안에 있는지, 이미 점유된 곳은 아닌지까지 동시에 검증되어야 한다.
결국 BoxSelection은 단순한 좌표 리스트 생성기가 아니라, 시작 지점과 현재 위치를 기준으로 다중 셀 선택을 계산하고, 그 결과의 유효성을 함께 판단하는 박스 선택 전략이어야 한다.
2. 흐름도
마우스 버튼 입력
↓
StartSelection()
↓
시작 Grid 좌표 저장
↓
SelectionData 초기화 및 첫 위치 등록
↓
마우스 이동
↓
ModifySelection()
↓
현재 Grid 좌표 계산
↓
이전 좌표와 비교
├─ 동일하면 종료
└─ 다르면 선택 데이터 재구성
↓
시작점/현재점 기준 최소·최대 범위 계산
↓
시작 모서리에 맞는 SelectFrom...Corner() 실행
↓
Grid / World 위치 저장
↓
회전값 초기화
↓
ValidatePlacement()
↓
SelectionData.PlacementValidity 갱신
↓
선택 종료
↓
FinishSelection()
↓
시작 위치 및 마지막 감지 위치 초기화
BoxSelection의 흐름은 입력을 곧바로 배치 결과로 바꾸는 것이 아니라, 먼저 시작점과 현재점 사이의 사각형 범위를 어떤 순서로 채울 것인지를 계산하는 데 집중되어 있다.
먼저 StartSelection이 호출되면 시작 Grid 좌표가 기록되고, 현재 선택 데이터는 그 시작 위치 하나를 포함하는 상태로 초기화된다.
이후 마우스가 움직일 때마다 ModifySelection이 호출되며, 현재 마우스 위치를 Grid 좌표로 변환한 뒤 이전 좌표와 비교해 실제로 변화가 있었는지 확인한다.
변화가 있으면 선택 데이터를 다시 비우고, 시작점과 현재점 사이의 최소값과 최대값을 계산한 뒤, 시작점이 어느 모서리에 해당하는지에 따라 적절한 SelectFrom...Corner 함수를 호출한다.
그 결과로 선택된 Grid 위치와 월드 위치가 다시 구성되고, 마지막에는 ValidatePlacement가 실행되어 현재 선택 영역이 실제로 유효한지 판정된다.
선택이 끝나면 FinishSelection이 호출되어 내부 상태가 초기화된다.
이 전략은 선택 시작점 유지, 방향별 선택 순서 보장, 실시간 유효성 검증을 하나의 흐름으로 묶은 구조라고 볼 수 있다.
3. 구현
3.1. 클래스 구조와 시작 위치 필드
public class BoxSelection : SelectionStrategy
{
protected Vector3Int? startposition;
delegate void ProcessPositionAction(SelectionData selectionData, Vector3Int tempPos, int x, int y);BoxSelection은 SelectionStrategy를 상속받는 구체 전략 클래스이다. 즉 공통 선택 흐름 위에서 “박스 선택”이라는 구체적인 해석 규칙을 제공하는 역할을 맡는다.
이 클래스가 별도의 상태를 가지는 이유는, 박스 선택은 반드시 선택을 시작한 첫 지점을 기준으로 동작해야 하기 때문이다.
그래서 startposition이라는 필드를 별도로 들고 있다.
타입이 Vector3Int?인 것은 값이 아직 없는 상태와 실제 시작점이 기록된 상태를 구분하기 위해서다.
단순히 Vector3Int만 사용했다면 (0,0,0)이 초기값인지 실제 시작점인지 구분하기 어렵기 때문에 Nullable을 쓴 것이다.
장점은 상태 표현이 명확하다는 점이고, 단점은 HasValue, Value 확인 코드가 추가된다는 점이다.
하지만 시작점이 아직 없는 상태를 분명히 표현해야 하므로 현재 상황에서는 적절한 선택이다.
바로 아래의 delegate void ProcessPositionAction(...)는 현재 코드 기준으로 실제 사용되지는 않는다.
C#의 delegate는 메서드 참조 형식을 타입처럼 다룰 수 있게 해주는 기능인데, 이 코드에서는 아마 선택된 좌표 처리 방식을 함수로 전달하려는 초기 설계 흔적이었거나 향후 확장을 염두에 둔 선언으로 보인다.
현재 구현에서 호출되지 않기 때문에 실행 로직에 직접적인 영향은 없지만, 이런 선언이 남아 있다는 점은 중간에 함수형 분리 시도를 했던 흔적으로 해석할 수 있다.
3.2 생성자와 상위 전략 초기화
public BoxSelection(PlacementGridData placementData, GridManager gridManager)
: base(placementData, gridManager)
{
}이 생성자는 코드가 짧지만 의미는 작지 않다.
BoxSelection 자체는 별도의 추가 초기화 없이, SelectionStrategy가 공통으로 요구하는 placementData와 gridManager만 있으면 동작한다는 뜻이기 때문이다.
placementData는 현재 선택이 어떤 Grid 레이어를 기준으로 검증될지를 나타내고, gridManager는 월드 좌표를 Grid 좌표로 바꾸거나 Grid 좌표를 월드 좌표로 되돌릴 때 사용된다.
즉, BoxSelection은 어디에 배치되는지와 어떻게 좌표를 해석하는지를 상위 전략으로부터 이어받는다.
상속 구조를 사용했기 때문에 BoxSelection은 자신의 핵심 규칙인 박스 범위 계산에만 집중할 수 있고, 공통 인프라 초기화는 부모 클래스가 담당하게 된다.
이런 구조는 코드 중복을 줄이는 데 유리하며, 새 전략을 만들 때도 공통 필드를 매번 다시 정의할 필요가 없다는 장점이 있다.
3.3. 선택 진행 처리의 핵심
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)
{
Vector2Int minPoint = new(Mathf.Min(startposition.Value.x, tempPos.x), Mathf.Min(startposition.Value.z, tempPos.z));
Vector2Int maxPoint = new(Mathf.Max(startposition.Value.x, tempPos.x), Mathf.Max(startposition.Value.z, tempPos.z));
if (startposition.Value.x == maxPoint.x && startposition.Value.z == maxPoint.y)
SelectFromTopRightCorner(selectionData, tempPos, minPoint, maxPoint);
else if (startposition.Value.z == maxPoint.y && startposition.Value.x < maxPoint.x )
SelectFromTopLeftCorner(selectionData, tempPos, minPoint, maxPoint);
else if (startposition.Value.x == maxPoint.x && startposition.Value.z < maxPoint.y)
SelectFromBottomRightCorner(selectionData, tempPos, minPoint, maxPoint);
else
SelectFromBottomLeftCorner(selectionData, tempPos, minPoint, maxPoint);
}
else
{
selectionData.AddToGridPositions(lastDetectedPosition.GetPosition());
selectionData.AddToWorldPositions(gridManager.GetWorldPosition(lastDetectedPosition.GetPosition()));
}
selectionData.SetGridCheckRotation(selectionData.GetSelectedGridPositions().Select(rotation => Quaternion.identity).ToList());
selectionData.PlacementValidity = ValidatePlacement(selectionData);
return true;
}
return false;
}이 메서드는 BoxSelection의 핵심이다. PlacementSelector가 마우스 이동 시점마다 이 메서드를 호출하며, 현재 마우스 위치를 기준으로 선택 결과를 다시 계산한다.
첫 줄에서 gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType)를 호출하는 것은 단순 좌표 변환이 아니다.
GridManager는 PlacementType에 따라 셀 중심 배치인지 경계 배치인지 보정까지 포함해 Grid 좌표를 계산한다.
BoxSelection은 현재 전략상 주로 바닥처럼 셀 중심 배치에 사용되지만, 이 코드를 일반화해 둠으로써 GridManager의 규칙을 그대로 재사용한다.
Unity의 Vector3 월드 좌표를 Vector3Int Grid 좌표로 바꾸는 이유는, 선택 계산이 연속 공간이 아니라 셀 인덱스 기반으로 이루어져야 하기 때문이다.
그 다음 lastDetectedPosition.TryUpdatingPositon(tempPos)가 호출된다.
이 함수는 이전 프레임에 처리한 위치와 현재 위치를 비교하여, 실제로 셀이 바뀌었을 때만 true를 반환한다.
여기서 bool 반환을 사용하는 이유는 매우 실용적이다.
같은 셀 안에서 마우스가 조금 흔들리더라도 선택 결과는 달라지지 않으므로, 그때마다 프리뷰 갱신과 검증을 다시 할 필요가 없다.
따라서 위치가 바뀌지 않았으면 바로 false를 반환하고 아무 작업도 하지 않는 구조가 된다.
이 한 줄 덕분에 ModifySelection은 실시간 입력 루프 안에서 불필요한 재계산을 크게 줄일 수 있다.
위치가 바뀐 경우 가장 먼저 selectionData.Clear()를 호출한다.
이 코드는 기존에 누적되어 있던 선택 결과를 모두 비우는 역할을 한다.
BoxSelection은 현재 마우스 위치 기준으로 선택 범위를 다시 계산하는 전략이기 때문에, 이전 프레임의 결과를 조금씩 덧붙이는 방식이 아니라 매번 처음부터 다시 선택 결과를 구성해야 한다.
선택 데이터에는 Grid 위치, 월드 위치, 회전, 배치 가능 여부 등이 들어 있으므로, 이 초기화가 없으면 이전 범위의 잔재가 남아 잘못된 선택 결과가 만들어질 수 있다.
그 다음 if (startposition.HasValue) 분기가 나온다.
시작점이 존재한다는 것은 플레이어가 이미 마우스를 눌러 선택을 시작한 상태라는 뜻이다.
이 경우에는 현재 위치와 시작 위치를 기준으로 박스 범위를 계산해야 한다.
minPoint와 maxPoint는 각각 시작점과 현재점 중 x축과 z축의 최소·최대값을 구해서, 선택 범위의 바운딩 박스를 만드는 역할을 한다.
Vector2Int를 사용하는 이유는 박스 선택에서 사실상 y축은 의미가 없고, 평면상의 x-z 범위만 필요하기 때문이다.
Unity의 Mathf.Min, Mathf.Max는 두 값 중 최소/최대값을 구하는 수학 유틸리티 함수로, Grid 범위 계산에서 가장 기본적으로 사용된다.
그 다음 분기가 BoxSelection의 가장 중요한 설계 포인트다.
if (startposition.Value.x == maxPoint.x && startposition.Value.z == maxPoint.y)
SelectFromTopRightCorner(...)
else if (startposition.Value.z == maxPoint.y && startposition.Value.x < maxPoint.x )
SelectFromTopLeftCorner(...)
else if (startposition.Value.x == maxPoint.x && startposition.Value.z < maxPoint.y)
SelectFromBottomRightCorner(...)
else
SelectFromBottomLeftCorner(...)이 코드는 시작점이 현재 박스의 어느 모서리에 위치하는지를 판별한 뒤, 그에 맞는 순회 전략을 선택한다.
왜 이런 복잡한 분기가 필요한가 하면, 구조물의 크기가 1x1보다 클 때 어느 모서리를 기준으로 셀을 채우는지가 매우 중요하기 때문이다.
예를 들어 2x2 바닥을 깔 때 시작점이 왼쪽 아래 모서리였는데, 계산은 오른쪽 위를 기준으로 해버리면 플레이어가 클릭한 지점과 실제 배치 정렬이 어긋나게 된다.
그래서 이 전략은 시작점이 박스의 어느 코너인지에 따라 선택 순서를 달리하여, 항상 플레이어가 처음 클릭한 위치가 배치 기준으로 유지되게 만든다.
즉, 이 분기는 단순한 방향 판별이 아니라, 대형 구조물에서도 배치 일관성을 보장하기 위한 핵심 규칙이다.
만약 startposition이 없다면, 아직 시작점이 없는 상태이므로 현재 감지된 한 셀만 선택 데이터에 넣는다.
이 분기는 주석에도 있듯이 프리뷰를 위해 필요하다.
StartSelection 전에 ModifySelection이 먼저 호출될 수도 있으므로, 시작점이 비어 있는 경우를 안전하게 처리해야 한다.
이 코드는 전략이 입력 순서 변화에도 망가지지 않도록 하는 방어 코드라고 볼 수 있다.
gridManager.GetWorldPosition을 함께 호출하는 이유는, Grid 좌표만으로는 Preview 시스템이 바로 오브젝트를 옮길 수 없기 때문이다.
선택 전략은 실행 계층과 시각화 계층이 모두 사용할 수 있도록 Grid 좌표와 월드 좌표를 함께 준비한다.
이후 selectionData.SetGridCheckRotation(...)가 호출된다.
여기서는 모든 선택 위치에 대해 Quaternion.identity를 넣고 있다.
바닥 선택은 별도의 방향성이 없는 박스 선택이므로, Grid 검증 시 회전값은 기본 회전(항등 회전)으로 처리하면 된다.
LINQ의 Select(...).ToList()를 사용한 이유는, 현재 선택된 Grid 위치 개수만큼 동일한 회전값 리스트를 빠르게 만들기 위해서다.
Quaternion.identity는 Unity에서 '회전 없음' 을 뜻하는 기본 회전값이다.
장점은 코드가 간결하고 명확하다는 점이고, 단점은 LINQ가 약간의 GC를 유발할 수 있다는 점이다.
하지만 선택 크기가 크지 않고, 가독성이 중요하므로 현재 구조에서는 합리적인 선택이다.
마지막으로 ValidatePlacement(selectionData)를 호출해 현재 선택 결과가 실제로 유효한지 검사하고, 그 값을 PlacementValidity에 저장한다.
이때 ModifySelection이 true를 반환하면 PlacementSelector는 그 결과를 상위로 전달하고 Preview를 갱신하게 된다.
즉, 이 메서드는 마우스 위치가 바뀌었을 때 선택 결과를 처음부터 끝까지 재구성하는 핵심 루프다.
3.4. 왼쪽 아래 기준 선택
private void SelectFromBottomLeftCorner(SelectionData selectionData, Vector3Int tempPos, Vector2Int minPoint, Vector2Int maxPoint)
{
foreach (int x in GridSelectionHelper.MoveMinToMaxInclusive(minPoint.x,maxPoint.x,selectionData.PlacedItemData.size.x))
{
foreach (int y in GridSelectionHelper.MoveMinToMaxInclusive(minPoint.y, maxPoint.y, selectionData.PlacedItemData.size.y))
{
Vector3Int pos = new Vector3Int(x, tempPos.y, y);
AddToSelection(pos, selectionData);
}
}
}이 함수는 시작점이 선택 영역의 왼쪽 아래 모서리에 있을 때 사용된다.
이 경우에는 x축과 z축 모두 최소값에서 최대값 방향으로 증가시키면서 셀을 순회해야 한다.
여기서 단순히 for (x = min; x <= max; x++)를 쓰지 않고 GridSelectionHelper.MoveMinToMaxInclusive를 쓰는 이유는, 구조물 크기에 따라 step이 달라질 수 있기 때문이다.
예를 들어 2x2 바닥이면 한 칸씩이 아니라 구조물 크기만큼 건너뛰며 선택해야 일관된 격자 패턴이 유지된다.
즉, 이 helper는 단순 루프 유틸리티가 아니라, 객체 크기를 반영한 선택 간격을 보장하는 역할을 한다.
반복문 안에서 Vector3Int pos = new Vector3Int(x, tempPos.y, y);를 만드는 것은 선택된 각 셀의 Grid 좌표를 구성하는 작업이다.
y값으로 tempPos.y를 사용한 이유는 현재 Grid 평면의 높이를 그대로 유지하기 위해서다.
2D 타일처럼 x-z 평면에서 선택하되, 현재 Grid의 y층은 유지해야 하므로 tempPos의 y를 그대로 가져온다.
이후 AddToSelection을 호출해 Grid 좌표와 월드 좌표를 동시에 SelectionData에 추가한다.
즉, 이 함수는 왼쪽 아래 기준으로 선택 범위를 “채우는 방법”을 정의한 모서리 전용 전략이다.
3.5. 오른쪽 아래 기준 선택
private void SelectFromBottomRightCorner(SelectionData selectionData, Vector3Int tempPos, Vector2Int minPoint, Vector2Int maxPoint)
{
foreach (int x in GridSelectionHelper.MoveMaxToMinInclusive(minPoint.x, maxPoint.x, selectionData.PlacedItemData.size.x))
{
foreach (int y in GridSelectionHelper.MoveMinToMaxInclusive(minPoint.y, maxPoint.y, selectionData.PlacedItemData.size.y))
{
Vector3Int pos = new Vector3Int(x, tempPos.y, y);
AddToSelection(pos, selectionData);
}
}
}이 함수는 시작점이 오른쪽 아래 모서리에 있을 때 사용된다. 왼쪽 아래 기준과 차이는 x축 순회 방향이다.
여기서는 x를 최대값에서 최소값으로 감소시키고, z축은 여전히 최소값에서 최대값으로 증가시킨다.
이 차이가 중요한 이유는 플레이어가 처음 클릭한 오른쪽 아래 셀이 항상 첫 기준점이 되어야 하기 때문이다.
객체 크기가 1보다 클 경우 선택 기준점이 바뀌면 전체 배치 정렬이 어긋날 수 있으므로, 단순히 박스 범위를 한 방식으로만 순회할 수 없다.
MoveMaxToMinInclusive는 GridSelectionHelper가 제공하는 역방향 순회 함수다.
이 함수를 쓰면 선택 범위가 뒤집힌 경우에도 동일한 규칙으로 순회할 수 있다.
코드상으로는 작은 차이지만, 실제 플레이어 경험에서는 처음 찍은 위치를 기준으로 배치가 정렬된다는 일관성을 만드는 데 매우 중요하다.
3.6. 왼쪽 위 기준 선택
private void SelectFromTopLeftCorner(SelectionData selectionData, Vector3Int tempPos, Vector2Int minPoint, Vector2Int maxPoint)
{
foreach (int x in GridSelectionHelper.MoveMinToMaxInclusive(minPoint.x, maxPoint.x, selectionData.PlacedItemData.size.x))
{
foreach (int y in GridSelectionHelper.MoveMaxToMinInclusive(minPoint.y, maxPoint.y, selectionData.PlacedItemData.size.y))
{
Vector3Int pos = new Vector3Int(x, tempPos.y, y);
AddToSelection(pos, selectionData);
}
}
}이 함수는 시작점이 왼쪽 위 모서리에 있을 때 사용된다.
이번에는 x는 '최소→최대', z는 '최대→최소' 방향으로 순회한다.
즉, 앞선 두 함수와 완전히 같은 구조를 가지되, 어느 축을 어느 방향으로 순회할 것인가만 달라진다.
이 패턴이 중요한 이유는 네 방향을 모두 별도 함수로 분리해 두었기 때문에, 코드가 길어지더라도 각 모서리 전략의 의도가 명확해진다는 점이다.
만약 이걸 하나의 함수 안에서 여러 방향 플래그로 처리했다면 더 짧을 수는 있지만, 읽기 어려워지고 디버깅이 힘들어졌을 것이다.
현재 구조는 중복이 조금 있더라도 가독성과 의도를 우선한 설계라고 볼 수 있다.
3.7. 오른쪽 위 기준 선택
private void SelectFromTopRightCorner(SelectionData selectionData, Vector3Int tempPos, Vector2Int minPoint, Vector2Int maxPoint)
{
foreach (int x in GridSelectionHelper.MoveMaxToMinInclusive(minPoint.x, maxPoint.x, selectionData.PlacedItemData.size.x))
{
foreach (int y in GridSelectionHelper.MoveMaxToMinInclusive(minPoint.y, maxPoint.y, selectionData.PlacedItemData.size.y))
{
Vector3Int pos = new Vector3Int(x, tempPos.y, y);
AddToSelection(pos, selectionData);
}
}
}이 함수는 시작점이 오른쪽 위 모서리에 있을 때 사용된다.
x와 z 모두 '최대→최소' 방향으로 순회한다.
네 개의 모서리 함수 중 마지막 케이스이며, 결과적으로 BoxSelection은 시작점이 어느 코너에 있든 항상 그 위치를 기준으로 선택 범위를 일관되게 채울 수 있다.
이 구조의 장점은 큰 구조물을 박스 형태로 선택할 때도 정렬 규칙이 유지된다는 점이다.
즉, 플레이어가 어느 방향으로 드래그하든 처음 클릭한 셀을 기준으로 타일이 깔리는 느낌을 보장한다.
이런 디테일이 Grid 건축 시스템에서 매우 중요하다.
3.8. 선택 데이터 추가 헬퍼
private void AddToSelection(Vector3Int position, SelectionData selectionData)
{
selectionData.AddToGridPositions(position);
selectionData.AddToWorldPositions(gridManager.GetWorldPosition(position));
}이 함수는 선택된 Grid 좌표 하나를 SelectionData에 추가하는 공통 헬퍼다.
BoxSelection의 네 가지 코너 함수는 모두 결국 특정 Grid 좌표들을 선택 리스트에 추가해야 하는데, 그때마다 Grid 위치와 World 위치를 각각 넣는 코드를 반복하면 중복이 많아진다.
그래서 이 메서드가 그 반복을 캡슐화한다.
첫 줄은 Grid 좌표 리스트에 현재 위치를 넣고, 둘째 줄은 같은 위치를 gridManager.GetWorldPosition(position)을 통해 월드 좌표로 변환해 저장한다.
여기서 Grid 좌표와 월드 좌표를 동시에 저장하는 이유는 이후 사용하는 계층이 다르기 때문이다.
Validator나 Grid 기반 로직은 Grid 좌표를 사용하고, Preview나 실제 배치 위치 계산은 월드 좌표를 사용한다.
이 작은 헬퍼는 하나의 선택 결과를 여러 하위 계층이 동시에 사용할 수 있게 준비하는 역할을 한다.
겉보기엔 단순하지만 SelectionData의 일관성을 유지하는 데 중요하다.
3.9. 선택 시작 처리
public override void StartSelection(Vector3 mousePosition, SelectionData selectionData)
{
selectionData.Clear();
startposition = gridManager.GetCellPosition(mousePosition, selectionData.PlacedItemData.objectPlacementType);
selectionData.AddToGridPositions(startposition.Value);
selectionData.AddToWorldPositions(gridManager.GetWorldPosition(startposition.Value));
selectionData.SetGridCheckRotation(selectionData.GetSelectedGridPositions().Select(rotation => Quaternion.identity).ToList());
selectionData.PlacementValidity = ValidatePlacement(selectionData);
lastDetectedPosition.TryUpdatingPositon(startposition.Value);
if (selectionData.PlacementValidity == false)
startposition = null;
}이 메서드는 플레이어가 마우스를 눌러 박스 선택을 시작하는 순간 호출된다.
가장 먼저 selectionData.Clear()를 호출하는 것은 이전 선택 상태를 완전히 비우기 위해서다.
새로운 박스 선택은 항상 깨끗한 상태에서 시작해야 하므로, 기존 선택 결과가 남아 있으면 안 된다.
그 다음 startposition = gridManager.GetCellPosition(...)에서 현재 마우스 위치를 Grid 좌표로 변환해 시작점으로 저장한다.
BoxSelection에서는 이 시작점이 이후 모든 방향 계산의 기준이 되므로, 사실상 이 클래스의 가장 중요한 상태값이다.
여기서 PlacementType을 함께 넘기는 이유는 GridManager가 좌표 변환 시 구조물 배치 타입에 따른 보정을 적용할 수 있기 때문이다.
즉, 시작점도 단순 클릭 위치가 아니라 현재 배치 타입에 맞게 해석된 Grid 기준점이다.
그 다음부터는 선택을 막 시작한 순간의 최소 선택 결과를 구성한다.
시작점 하나를 Grid 위치와 월드 위치에 넣고, 회전 검사용 리스트는 identity 값으로 채운다.
아직 박스가 확장되지 않았으므로 선택은 한 칸뿐이지만, 그래도 SelectionData는 이후 시스템이 동일하게 사용할 수 있는 완전한 형태를 갖춰야 한다.
그래서 첫 순간부터 Grid 위치, World 위치, 회전, PlacementValidity를 모두 채운다.
lastDetectedPosition.TryUpdatingPositon(startposition.Value)를 바로 호출하는 것도 중요하다.
이렇게 해야 이후 ModifySelection이 시작 직후 같은 셀 위에서 다시 호출되더라도 변화 없음을 올바르게 판단할 수 있다.
즉, LastDetectedPosition은 단순 캐시가 아니라, StartSelection과 ModifySelection을 연결하는 상태 유지 장치 역할을 한다.
마지막의 if (selectionData.PlacementValidity == false) startposition = null;은 방어 코드다.
시작점 하나만 선택한 상태에서도 이미 유효하지 않을 수 있기 때문에, 그런 경우에는 시작점 자체를 무효화해 이후 박스 확장을 진행하지 않도록 막는다.
이 구조는 잘못된 시작점에서 선택이 계속 확장되는 것을 방지해 준다.
3.10. 배치 유효성 검증
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());
}
return validity;
}이 메서드는 BoxSelection이 계산한 선택 결과가 실제로 배치 가능한지 판단하는 핵심 검증 함수다.
SelectionStrategy의 protected 추상 메서드를 구현한 것으로, 외부에서 직접 호출되지 않고 전략 내부 흐름에서만 사용된다.
첫 번째 검증은 CheckIfPositionsAreValid다.
이 함수는 선택된 모든 Grid 위치가 현재 Grid 범위 안에서 유효한지를 검사한다.
여기서 중요한 점은 구조물 크기와 회전값까지 함께 넘긴다는 것이다.
단순히 시작 좌표만 범위 안에 있는지를 보는 것이 아니라, 구조물 전체 점유 범위가 Grid 바깥으로 나가지 않는지까지 검사한다.
BoxSelection은 바닥처럼 여러 셀을 동시에 선택하므로, 이 기본 유효성 검사는 반드시 먼저 수행되어야 한다.
그 다음 조건문 안에서 CheckIfPositionsAreFree를 호출한다.
이 두 번째 검사는 첫 번째 검사가 true일 때만 실행된다.
이런 구조를 사용하는 이유는 불필요한 연산을 줄이기 위해서다.
이미 범위 밖이라면 비어 있는지까지 검사할 필요가 없기 때문이다.
즉 여기서는 단계별 검증을 통해 비용을 줄이고, 동시에 검증 순서도 논리적으로 유지한다.
먼저 놓을 수 있는 범위인지를 보고, 그 다음 비어 있는지를 확인하는 순서다.
마지막으로 이 값을 반환하면, 호출부는 그 결과를 selectionData.PlacementValidity에 넣고 프리뷰 색상이나 실행 가능 여부 판단에 사용하게 된다.
BoxSelection에서 ValidatePlacement는 단순 bool 계산이 아니라, 현재 박스 선택 결과가 실제 배치 단계로 넘어갈 수 있는지를 결정하는 최종 관문이다.
3.11. 선택 종료 처리
public override void FinishSelection(SelectionData selectionData)
{
startposition = null;
lastDetectedPosition.Reset();
}이 메서드는 선택이 완료되었을 때 내부 상태를 초기화하는 역할을 한다.
먼저 startposition = null;로 박스 선택 시작점을 제거한다.
이렇게 해야 다음 선택이 새롭게 시작될 때 이전 시작점이 남아 있지 않는다. 그 다음 lastDetectedPosition.Reset()을 호출해 마지막 감지 위치 캐시도 초기 상태로 돌린다.
이 초기화가 없으면 다음 선택이 시작될 때 이전 마지막 위치와 비교되어 첫 입력이 잘못 무시될 수 있다.
즉, 이 메서드는 단순한 클린업이 아니라, BoxSelection이 다음 선택 사이클을 올바르게 받을 수 있게 만드는 생명주기 종료 처리다.
4. 개발 의도
BoxSelection의 핵심 설계 의도는, 다중 셀 선택이라는 행동을 단순한 사각형 계산으로 끝내지 않고 플레이어가 처음 클릭한 위치를 기준으로 일관된 배치 결과를 만드는 것에 있다.
구조물 크기가 1보다 큰 경우 단순히 최소·최대 범위만 사용하면 선택 결과가 시작점과 어긋날 수 있기 때문에, 이 전략은 시작점이 어느 코너인지에 따라 네 방향의 순회 방식을 분리해 둔다.
이 구조 덕분에 플레이어가 어느 방향으로 드래그하든 처음 클릭한 위치가 항상 배치 기준점으로 유지된다.
또한 LastDetectedPosition을 이용해 같은 셀 위에서의 불필요한 재계산을 막고, 선택 결과를 매번 처음부터 다시 구성하는 방식으로 SelectionData의 일관성을 유지한다.
ValidatePlacement를 박스 선택 전략 내부에 포함시킨 것도 중요한 의도다.
선택 결과를 단순 좌표 목록으로만 끝내지 않고, 그 즉시 유효성까지 계산함으로써 Preview와 PlacementManager가 같은 데이터를 공유할 수 있게 만들었다.
결과적으로 BoxSelection은 단순한 박스 드래그 기능이 아니라, Grid 기반 다중 선택, 배치 일관성, 실시간 유효성 검증을 하나의 전략 안에 묶어낸 핵심 박스 선택 시스템으로 설계되었다.
