구조물 교체 명령 시스템
목차
1. 시스템 요구 사항
건축 시스템에서 특정 구조물은 단순히 배치나 제거로 처리할 수 없는 경우가 존재한다.
대표적으로 벽 내부 구조물(InWall)과 같은 경우, 이미 존재하는 구조물을 제거하고 새로운 구조물을 같은 위치에 배치하는 교체 동작이 필요하다.
이 동작은 사용자 입장에서는 하나의 행동처럼 보이지만, 내부적으로는 제거와 배치가 동시에 일어나는 복합 작업이다.
이때 중요한 점은 교체 동작 역시 Undo가 가능해야 한다는 것이다.
교체가 수행되면 이전 구조물은 제거되고 새로운 구조물이 배치되지만, Undo가 발생하면 새 구조물은 제거되고 이전 구조물이 정확히 복원되어야 한다.
이 과정에서 Grid 데이터와 씬 오브젝트 상태가 항상 일관성을 유지해야 한다.
또한 교체 대상이 되는 구조물은 단일 셀이 아닌 Edge 기반 구조물일 수 있으며, 회전 값이나 구조물 ID 역시 정확히 복원되어야 한다.
따라서 교체 명령은 단순히 '기존 제거 → 새 배치'를 순차적으로 실행하는 것이 아니라, 이전 상태를 복원 가능한 형태로 보존하면서 새로운 상태로 전환하는 원자적 작업 단위로 설계되어야 한다.
StructureSwapCommand는 이러한 요구를 만족하기 위해, 제거와 배치를 하나의 Command 안에 통합하고, Undo 시 이전 상태를 정확히 복원할 수 있도록 설계된 명령 객체이다.
2. 흐름도
PlacementManager
↓
StructureSwapCommand 생성
↓
CommandManager.Invoke()
↓
CanExecute()
↓
이전 구조물 정보 저장
↓
Execute()
↓
기존 구조물 제거
↓
새 구조물 배치
Undo 요청
↓
CommandManager.Undo()
↓
Undo()
↓
새 구조물 제거
↓
이전 구조물 복원
이 흐름에서 핵심은 교체가 한 번 제거하고 한 번 배치하는 작업처럼 보이지만, 실제로는 그 전에 이미 이전 구조를 복원할 수 있는 데이터가 준비된다는 점이다.
Execute가 호출되면 가장 먼저 현재 선택 기준으로 기존 벽이 점유하던 전체 Edge를 계산하고, 이를 다시 배치 가능한 SelectionResult 형태로 구성한다.
그 다음에야 제거와 배치가 일어난다.
그래서 Undo는 별도의 복잡한 계산을 다시 하지 않고, Execute 시점에 만들어 둔 이전 구조용 SelectionResult를 이용해 복원할 수 있다.
이 명령의 본질은 교체 실행보다도 교체 전 상태를 복원 가능한 입력으로 바꾸는 과정에 더 가깝다.
3. 구현
3.1. 클래스 구조와 교체 명령의 의미
이 클래스는 ICommand를 구현하고 있으며, 단일 작업이 아닌 상태 전환을 표현하는 Command다.
일반적인 배치 명령이나 제거 명령이 하나의 상태 변화만 다루는 것과 달리, 이 클래스는 기존 상태와 새로운 상태를 동시에 관리한다.
내부적으로는 두 개의 SelectionResult를 다루고 있으며, 하나는 새로 배치할 대상이고 하나는 기존에 존재하던 대상이다.
이 구조는 매우 중요하다.
단순히 기존 구조물을 제거하고 새로운 구조물을 배치하는 방식으로 구현하면 Undo 시 원래 상태를 복원할 수 없다.
왜냐하면 제거 시점 이후에는 기존 구조물 정보가 사라지기 때문이다.
따라서 이 클래스는 제거 전에 반드시 기존 구조물 정보를 SelectionResult 형태로 저장해야 한다.
3.2. 생성자와 상태 고정
public StructureSwapCommand(PlacementManager placementManager, PlacementGridData placementData, PlacementGridData
previousPlacementData, GridManager gridManager,
ItemData itemData, SelectionResult selectionResult, ItemData previousItemData)
{
this.placementManager = placementManager;
this.selectionResult = selectionResult;
this.placementData = placementData;
this.previousPlacementData = previousPlacementData;
this.gridManager = gridManager;
this.itemData = itemData;
this.previousItemData = previousItemData;
}이 생성자는 교체 명령 실행에 필요한 모든 문맥을 내부 필드로 고정하는 단계다.
여기서 selectionResult는 새 구조물을 배치하기 위해 이미 계산된 선택 결과다.
플레이어가 현재 배치하려는 InWall 구조물의 Grid 위치, 월드 좌표, 회전, 배치 가능 여부 등이 여기에 담겨 있다.
반면 previousItemData는 원래 구조물이 무엇이었는지를 나타낸다.
보통은 기존 벽 구조물 정보라고 볼 수 있다.
중요한 점은 이 시점에 previousStructuresResult는 아직 만들어지지 않는다는 것이다.
생성자는 이전 구조물의 복원 데이터를 다 계산하지 않는다.
왜냐하면 그 데이터는 현재 selectionResult를 기반으로 실제 Grid 상태를 다시 읽어서 만들어야 하기 때문이다.
생성자는 교체 명령의 입력 상태만 저장하고, 실제 복원용 데이터 생성은 Execute 안에서 한다.
이 구조는 합리적이다.
이전 구조를 어떤 방식으로 복원할지는 선택 결과와 현재 Grid 상태를 함께 봐야 하기 때문에, 생성 시점보다 실행 시점에 더 자연스럽기 때문이다.
3.3. 실행 가능 여부 판단
public bool CanExecute()
{
return selectionResult.placementValidity;
}이 함수는 이 교체 명령이 실제로 실행 가능한지 판단하는 역할을 한다.
겉으로 보면 매우 단순한데, 그 단순함 자체가 설계 의도를 보여준다.
StructureSwapCommand는 교체 가능 여부를 스스로 계산하지 않는다.
이미 선택 전략과 PlacementValidator를 통해 계산된 selectionResult.placementValidity를 그대로 사용한다.
검증 로직과 실행 로직을 분리한 것이다.
이 구조의 장점은 Command가 가벼워진다는 점이다.
Command는 검증을 다시 반복하지 않고, 앞 단계에서 이미 유효하다고 판단된 선택 결과를 신뢰한다.
또한 CommandManager 입장에서도 이 함수가 false면 이 명령을 실행하지 않고 스택에도 넣지 않기 때문에, 잘못된 교체 명령이 Undo 히스토리에 들어가지 않는다.
CanExecute는 단순 boolean 반환 함수처럼 보이지만, 실제로는 이 복합 교체 작업을 명령으로 기록해도 되는지를 결정하는 최소 필터다.
3.4. Execute의 전체 의미
public void Execute()
{
List<Edge> edgesOccupied = this.previousPlacementData.GetEdgePositions(selectionResult.selectedGridPositions[0], itemData.size, Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[0].eulerAngles.y));
int objectsEulerRotationY = Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[0].eulerAngles.y);
int objectToRemoveRotation = objectsEulerRotationY == 90 || objectsEulerRotationY == 270 ? 270 : 0;
Quaternion newRotation = Quaternion.Euler(0, objectToRemoveRotation, 0);
List<Vector3Int> positionsToClear = new();
List<Vector3> placementPositions = new();
foreach (Edge edge in edgesOccupied)
{
positionsToClear.Add(edge.smallerPoint);
placementPositions.Add(gridManager.GetWorldPosition(edge.smallerPoint));
}
List<Quaternion> newRotationValues = positionsToClear.Select(x => newRotation).ToList();
previousStructuresResult = new SelectionResult
{
selectedGridPositions = positionsToClear,
selectedPositions = placementPositions,
selectedPositionsObjectRotation = newRotationValues,
selectedPositionGridCheckRotation = newRotationValues,
isEdgeStructure = selectionResult.isEdgeStructure,
placementValidity = true,
size = this.previousItemData.size
};
placementManager.RemoveStructureAt(previousStructuresResult, this.previousPlacementData);
placementManager.PlaceStructureAt(selectionResult, placementData, this.itemData);
}이 함수는 교체 명령의 실제 본체다.
하지만 이 함수의 핵심은 마지막 두 줄의 제거와 배치가 아니라, 그 전에 수행되는 previousStructuresResult 생성 과정이다.
왜냐하면 교체를 Undo하려면 원래 있던 구조물을 다시 복원할 수 있어야 하는데, 이 데이터를 제거 이후에 만들 수는 없기 때문이다.
따라서 Execute는 실제 교체 전에 먼저 이전 구조를 복원 가능한 SelectionResult로 재구성하는 역할을 한다.
List<Edge> edgesOccupied = this.previousPlacementData.GetEdgePositions(
selectionResult.selectedGridPositions[0],
itemData.size,
Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[0].eulerAngles.y));이 줄은 현재 새 구조물을 배치하려는 선택 결과를 기준으로, 기존 구조물 레이어에서 어떤 Edge들이 영향을 받는지를 계산하는 부분이다.
previousPlacementData를 사용하는 이유는 원래 구조물이 저장된 레이어를 기준으로 계산해야 하기 때문이다.
새 구조물이 들어갈 위치만 보는 것이 아니라, 그 자리에 원래 있던 벽 구조가 어떤 Edge들을 점유하고 있었는지를 알아내는 것이다.
여기서 GetEdgePositions는 선택된 기준점, 구조물 크기, 회전을 바탕으로 점유 Edge 목록을 반환한다.
selectionResult.selectedGridPositions[0]을 쓰는 이유는 현재 교체 대상이 되는 구조가 하나의 기준점에서 시작하는 Edge 구조라고 보기 때문이다.
Mathf.RoundToInt(...eulerAngles.y)는 Quaternion 회전을 정수 Y 회전값으로 바꾸는 부분이다.
Unity의 Quaternion은 내부 계산에는 유리하지만, 방향 비교나 분기에는 직접 쓰기 어렵다.
그래서 eulerAngles.y를 꺼내고 Mathf.RoundToInt로 반올림해 0, 90, 180, 270 같은 값으로 보정한다.
이 과정을 통해 이후 로직에서 회전 방향을 안정적으로 비교할 수 있게 된다.
int objectsEulerRotationY = Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[0].eulerAngles.y);
int objectToRemoveRotation = objectsEulerRotationY == 90 || objectsEulerRotationY == 270 ? 270 : 0;
Quaternion newRotation = Quaternion.Euler(0, objectToRemoveRotation, 0);이 부분은 겉보기에는 단순 회전 계산처럼 보이지만, 실제로는 Undo 시 이전 벽을 어떤 방향으로 복원해야 하는지를 다시 해석하는 단계다.
주석에 적혀 있듯이 Edge 데이터는 내부적으로 왼쪽 아래에서 오른쪽 위 방향으로 저장되는 규칙을 가지고 있기 때문에, 선택된 구조물의 회전값을 그대로 쓰는 것이 아니라 제거/복원에 사용할 기준 회전으로 한 번 변환해야 한다.
objectsEulerRotationY는 현재 선택 결과의 GridCheckRotation에서 Y 회전값을 꺼낸 것이다.
여기서 다시 Mathf.RoundToInt를 사용하는 이유는 부동소수점 오차를 제거하고 비교를 안정화하기 위해서다.
그 다음 줄에서 90도와 270도는 270으로, 그 외는 0으로 매핑한다.
이 구조는 회전값 전체를 보존하려는 것이 아니라, 기존 Edge 구조를 제거하고 다시 복원할 때 필요한 두 가지 기준 회전으로 단순화하는 것이다.
마지막 Quaternion.Euler(0, objectToRemoveRotation, 0)는 다시 이 정수 회전값을 Quaternion으로 바꾸는 부분이다.
Unity는 실제 오브젝트 회전과 데이터 회전을 Quaternion으로 다루기 때문에, Undo용 SelectionResult를 만들 때도 결국 Quaternion 형태가 필요하다.
이 코드는 현재 선택 회전을 그대로 복사하는 것이 아니라, Edge 저장 규칙에 맞는 복원 회전으로 재해석하는 변환 단계라고 보는 것이 맞다.
List<Vector3Int> positionsToClear = new();
List<Vector3> placementPositions = new();
foreach (Edge edge in edgesOccupied)
{
positionsToClear.Add(edge.smallerPoint);
placementPositions.Add(gridManager.GetWorldPosition(edge.smallerPoint));
}
List<Quaternion> newRotationValues = positionsToClear.Select(x => newRotation).ToList();이 부분은 이전 구조를 다시 배치하기 위해 필요한 위치 리스트를 만드는 단계다.
positionsToClear는 Undo 시 기준이 될 Grid 좌표 목록이고, placementPositions는 그에 대응하는 월드 좌표 목록이다.
여기서 각 Edge에 대해 edge.smallerPoint를 선택하는 이유는 Edge 구조를 다시 배치할 때 기준점으로 사용할 수 있는 일관된 시작점이 필요하기 때문이다.
Edge는 두 점으로 구성되지만, smallerPoint를 기준으로 사용하면 저장과 복원이 항상 같은 규칙으로 이루어진다.
gridManager.GetWorldPosition(edge.smallerPoint)를 통해 월드 좌표를 같이 만드는 것도 중요하다.
SelectionResult는 Grid 좌표만으로는 충분하지 않고, 실제 배치 시스템에서 사용할 월드 좌표 리스트도 함께 요구하기 때문이다.
이 루프는 기존 벽 구조를 다시 배치 가능한 형태로 바꾸기 위해, 각 Edge의 기준점을 Grid/World 좌표 쌍으로 다시 구성하는 과정이다.
그 다음 줄의 positionsToClear.Select(x => newRotation).ToList()는 모든 위치에 동일한 회전값을 채운 리스트를 만드는 부분이다.
LINQ의 Select는 시퀀스의 각 원소를 다른 값으로 변환할 때 쓰는 함수다.
여기서는 실제 위치 값 x는 사용하지 않고, positionsToClear의 길이만큼 같은 newRotation을 반복해서 리스트를 만든다.
이런 방식은 코드가 짧고 의도가 분명하다는 장점이 있다.
현재 위치 개수만큼 같은 회전을 가진 리스트를 만든다는 뜻이 바로 드러난다.
단점은 직접 루프를 도는 것보다 약간의 오버헤드가 있을 수 있다는 점이지만, Execute는 매 프레임이 아니라 교체 명령 실행 시점에만 호출되므로 가독성이 더 중요하다.
previousStructuresResult = new SelectionResult
{
selectedGridPositions = positionsToClear,
selectedPositions = placementPositions,
selectedPositionsObjectRotation = newRotationValues,
selectedPositionGridCheckRotation = newRotationValues,
isEdgeStructure = selectionResult.isEdgeStructure,
placementValidity = true,
size = this.previousItemData.size
};이 블록은 Execute의 핵심 결과물이다.
지금까지 계산한 Grid 좌표, 월드 좌표, 회전 정보를 하나의 SelectionResult로 묶어 previousStructuresResult를 만든다.
이 구조물은 Undo 시 다시 배치될 이전 상태의 입력 데이터다.
여기서 중요한 것은 이 객체가 단순한 기록용 스냅샷이 아니라, 나중에 PlaceStructureAt에 그대로 넘길 수 있는 완전한 배치 입력이라는 점이다.
isEdgeStructure를 현재 selectionResult.isEdgeStructure로 설정하고, size는 previousItemData.size를 쓰고 있다.
이는 Undo 시 복원되는 대상이 현재 새 구조물이 아니라 이전 구조물이기 때문이다.
placementValidity = true로 두는 것도 의미가 있다.
이 SelectionResult는 Undo 시 실제 복원 입력으로 사용될 것이므로, 별도의 검증을 다시 통과하지 않아도 되는 실행 가능한 복원 데이터로 취급한다는 뜻이다.
결과적으로 이 블록은 교체되기 전 벽 구조를 다시 설치할 수 있도록, 이전 상태를 배치 시스템이 이해하는 언어로 번역해 둔 것이라 볼 수 있다.
placementManager.RemoveStructureAt(previousStructuresResult, this.previousPlacementData);
placementManager.PlaceStructureAt(selectionResult, placementData, this.itemData);이 두 줄에서 비로소 실제 교체가 일어난다. 먼저 이전 구조를 제거하고, 그 다음 새 구조를 배치한다.
이 순서가 중요하다. 새 구조를 먼저 배치하면 기존 구조와 위치가 겹칠 수 있고, Grid 데이터 충돌이 발생할 수 있다. 따라서 항상 “기존 제거 → 새 배치” 순서를 따라야 한다.
첫 번째 줄은 방금 생성한 previousStructuresResult를 사용해서 원래 있던 벽 구조를 제거한다. 여기서 previousPlacementData를 사용하는 이유는 제거 대상이 저장된 원래 레이어를 기준으로 삭제해야 하기 때문이다. 두 번째 줄은 원래 selectionResult, placementData, itemData를 사용해 새 구조를 배치한다. 즉 이 구조는 “이전 상태를 복원 가능한 입력으로 만든 후, 그것을 기준으로 제거하고, 새 상태를 기존 배치 시스템에 전달해 적용하는 방식”이다. StructureSwapCommand는 직접 Instantiate나 Grid 데이터 조작을 하지 않고, PlacementManager에 모두 위임한다는 점에서 다른 Command들과 동일한 책임 분리 구조를 유지하고 있다.
3.5. 상태 되돌리기
public void Undo()
{
placementManager.RemoveStructureAt(selectionResult, placementData);
placementManager.PlaceStructureAt(previousStructuresResult, previousPlacementData,
this.previousItemData);
}Undo는 Execute의 완전한 반대 순서를 따른다.
먼저 새 구조물을 제거하고, 그 다음 이전 구조물을 복원한다.
여기서도 순서가 중요하다.
원래 구조를 먼저 복원해 버리면 새 구조물이 아직 존재하는 상태에서 같은 위치를 차지하게 되어 충돌이 날 수 있다.
그래서 Undo에서도 '현재 상태 제거 → 이전 상태 복원' 순서를 반드시 지켜야 한다.
첫 번째 줄은 새로 배치했던 InWall 구조를 제거한다.
두 번째 줄은 Execute 시 만들어 두었던 previousStructuresResult와 previousItemData를 사용해 원래 벽 구조를 다시 배치한다.
중요한 점은 Undo가 별도의 복원 알고리즘을 새로 계산하지 않는다는 것이다.
Execute 시 만들어 둔 이전 상태용 SelectionResult를 그대로 재사용한다.
이게 바로 Command 패턴의 장점이다.
실행 당시의 문맥을 객체 안에 저장해 두었기 때문에, Undo는 그 문맥을 그대로 이용해서 정확한 반대 작업을 수행할 수 있다.
4. 개발 의도
StructureSwapCommand의 핵심 설계 의도는 교체를 단순히 제거 후 배치로 처리하지 않고, 이전 상태와 새로운 상태를 모두 가진 전환 명령으로 만드는 것이다.
일반 배치 명령은 새 상태만 알면 되고, 일반 제거 명령은 현재 상태를 제거하기 전에 복원 데이터를 만들면 된다.
하지만 교체는 둘 다 필요하다.
원래 있던 구조를 정확히 복원할 수 있어야 하고, 동시에 새 구조도 정상적으로 배치되어야 한다.
그래서 이 클래스는 두 개의 상태를 모두 들고 있으며, Execute에서 이전 상태를 복원 가능한 형태로 재구성한 뒤 실제 교체를 수행한다.
특히 Edge 기반 벽 구조를 다시 SelectionResult로 변환하는 과정은 이 클래스의 핵심 설계 포인트다.
기존 구조를 단순히 '있었다' 는 수준으로 저장하는 것이 아니라, 다시 PlaceStructureAt에 넣을 수 있는 형식으로 조립해 두기 때문에 Undo는 배치 시스템을 그대로 재사용할 수 있다.
결과적으로 StructureSwapCommand는 단순한 교체 기능이 아니라, 건축 시스템 안에서 가장 복잡한 상태 전환을 안전하게 수행하고, 그 전환을 정확히 되돌릴 수 있게 만드는 복합 명령 객체라고 볼 수 있다.
