건축 객체 제거 시스템 ( PlacementManager (3) )
1. 시스템 요구 사항
Grid 기반 건축 시스템에서는 구조물을 설치하는 기능만큼이나 중요한 기능이 구조물 제거 기능이다.
플레이어는 이미 설치된 구조물을 삭제하거나 교체하면서 건축 레이아웃을 지속적으로 수정하게 되며, 이러한 동작은 건축 시스템의 핵심 상호작용 중 하나이다.
구조물을 제거하는 과정은 단순히 씬에서 GameObject를 삭제하는 것으로 끝나지 않는다.
건축 시스템에서는 구조물이 Grid 데이터를 기반으로 관리되기 때문에, 구조물이 제거될 때는 반드시 Grid 데이터에서도 해당 구조물의 점유 정보를 함께 제거해야 한다.
만약 실제 GameObject만 제거되고 Grid 데이터가 그대로 남아 있다면 이후 공간 검사 과정에서 해당 셀이 여전히 점유된 것으로 인식되어 새로운 구조물을 설치할 수 없게 되는 문제가 발생할 수 있다.
반대로 Grid 데이터만 제거되고 실제 오브젝트가 남아 있는 경우에도 시스템 상태가 불일치하게 된다.
또한 건축 시스템에서는 구조물의 배치 방식이 셀 기반 배치와 경계 기반 배치로 나뉘기 때문에 제거 로직 역시 두 가지 경우를 모두 처리할 수 있어야 한다.
바닥이나 가구와 같은 구조물은 Grid 셀을 기준으로 제거되지만, 벽과 같은 구조물은 Grid 셀의 경계 정보를 기반으로 제거된다.
이와 함께 플레이어가 Delete 키를 누르고 있는 동안 시스템은 제거 모드로 전환되어야 하며, 해당 모드에서는 프리뷰 시스템을 통해 제거 대상이 되는 구조물을 시각적으로 표시해야 한다.
또한 제거 동작 역시 Undo 시스템과 연결되기 때문에, 단순한 삭제가 아니라 기록 가능한 명령(Command) 형태로 관리되어야 한다.
따라서 건축 객체 제거 시스템은 다음과 같은 기능을 수행해야 한다.
플레이어 입력을 기반으로 제거 모드를 활성화하거나 비활성화한다.
선택된 위치에 구조물이 존재하는지를 검사한다.
구조물의 배치 방식에 따라 셀 기반 제거와 경계 기반 제거를 구분한다.
씬에 존재하는 실제 GameObject를 제거한다.
Grid 데이터 시스템에서 해당 구조물의 점유 정보를 제거한다.
Command 시스템과 연결하여 제거 동작을 기록한다.
프리뷰 시스템을 통해 제거 대상 구조물을 시각적으로 표시한다.
2. 흐름도
플레이어 Delete 입력
↓
InputManager
↓
PlacementManager.HandleDeleteAction
↓
RemovingState 활성화
↓
SelectionResult 생성
↓
PlacementManager.TryRemovingObject
↓
CommandManager.Invoke
↓
StructureRemoveCommand.Execute
↓
PlacementManager.RemoveStructureAt
↓
StructurePlacer.RemoveObjectAt
↓
PlacementGridData 업데이트
↓
BuildingState.RefreshSelection
건축 객체 제거 시스템은 입력 시스템과 상태 시스템을 기반으로 동작한다.
플레이어가 Delete 키를 누르면 InputManager가 입력을 감지하고 PlacementManager에 전달한다.
PlacementManager는 현재 상태를 제거 모드(RemovingState)로 전환하고, 프리뷰 시스템을 통해 제거 대상이 될 수 있는 구조물을 시각적으로 표시한다.
이후 플레이어가 마우스를 통해 선택을 확정하면 BuildingState 시스템이 SelectionResult를 생성한다.
SelectionResult는 제거 대상이 되는 Grid 위치, 회전 정보, 구조물 타입 정보를 포함하고 있으며, 제거 시스템에서도 배치 시스템과 동일한 방식으로 사용된다.
PlacementManager는 이 정보를 기반으로 TryRemovingObject를 호출하고, 실제 제거 동작은 Command 시스템을 통해 실행된다.
3. 구현
3.1. 제거 모드 전환
private void HandleDeleteAction(bool val)
{
if (buildingState == null)
return;
removeStateFrame.SetActive(val);
Quaternion previousRotation = buildingState.SelectionData.Rotation;
CancelState();
if (val)
{
buildingState = new RemovingState(gridManager, gridData, itemData);
buildingState.SelectionData.Rotation = previousRotation;
buildingState.OnFinished += TryRemovingObject;
buildingState.OnSelectionChanged += MovePreview;
gridManager.ToggleGrid(true);
if (itemData.objectPlacementType.IsObjectPlacement())
pp.StartShowingPreview(destoryPreview, true);
else
pp.StartShowingPreview(itemData.previewObject);
ConnectInputToBuildingState();
}
else
{
StartPlacingObject(itemData.ID);
buildingState.SelectionData.Rotation = previousRotation;
}
}HandleDeleteAction 함수는 Delete 키 입력에 따라 건축 시스템을 제거 모드로 전환하는 역할을 한다.
InputManager에서 Delete 키가 눌리거나 해제될 때 이 함수가 호출되며, val 값은 현재 Delete 키가 눌린 상태인지 여부를 나타낸다.
함수의 시작 부분에서는 먼저 현재 buildingState가 존재하는지를 검사한다.
if (buildingState == null)
return;이 코드는 건축 시스템이 활성화되지 않은 상태에서 제거 로직이 실행되는 것을 방지하기 위한 보호 코드이다.
건축 상태가 없는 상황에서 제거 로직이 실행되면 시스템 흐름이 깨질 수 있기 때문에, 이를 사전에 차단하는 역할을 한다.
이후 removeStateFrame의 활성화 상태를 val 값에 맞게 변경한다.
이 코드는 단순한 UI 제어가 아니라, 현재 시스템이 제거 모드에 들어갔음을 플레이어에게 시각적으로 전달하기 위함이다.
건축 시스템은 상태 기반으로 동작하기 때문에, 사용자에게 현재 상태를 명확히 보여주는 것이 중요하다.
그 다음으로 현재 선택 상태의 회전 값을 저장한다.
이 부분은 상태 전환 시에도 기존 회전 상태를 유지하기 위한 설계이다.
배치 상태에서 제거 상태로 전환되었다가 다시 배치 상태로 돌아갈 때, 이전 회전 값이 유지되지 않으면 플레이어가 설정한 방향 정보가 사라지게 된다.
따라서 상태 전환 전 회전 값을 저장해 두고, 이후 복원할 수 있도록 한다.
이후 CancelState를 호출하여 현재 건축 상태를 종료한다.
이 코드는 상태 패턴에서 매우 중요한 역할을 한다.
건축 시스템에서는 동시에 두 개 이상의 상태가 존재하면 안 되기 때문에, 새로운 상태로 전환하기 전에 반드시 기존 상태를 제거해야 한다.
val 값이 true인 경우, 즉 Delete 키가 눌린 상태라면 RemovingState를 생성하여 제거 모드로 진입한다.
RemovingState는 BuildingState를 상속받은 상태 클래스이며, 제거 대상 선택을 처리하는 역할을 한다.
이 구조를 통해 제거 기능 역시 배치 기능과 동일한 상태 패턴 구조 안에서 동작하게 된다.
이후 상태의 회전 값을 이전 값으로 복원한다.
buildingState.SelectionData.Rotation = previousRotation;이 코드는 상태가 바뀌더라도 기존 선택 방향을 유지하기 위한 처리이다.
그 다음으로 상태 이벤트를 연결한다.
buildingState.OnFinished += TryRemovingObject;
buildingState.OnSelectionChanged += MovePreview;OnFinished는 선택이 완료되었을 때 호출되며, 실제 제거 로직을 실행하는 TryRemovingObject와 연결된다.
OnSelectionChanged는 마우스 이동에 따라 선택 영역이 바뀔 때 호출되며, 프리뷰 위치를 갱신하는 데 사용된다.
이후 Grid를 활성화하고 프리뷰를 설정한다.
Grid는 제거 대상 위치를 시각적으로 확인할 수 있도록 도와주는 기준선 역할을 한다.
if (itemData.objectPlacementType.IsObjectPlacement())
pp.StartShowingPreview(destoryPreview, true);
else
pp.StartShowingPreview(itemData.previewObject);이 부분은 제거 대상의 종류에 따라 다른 프리뷰를 사용하는 로직이다.
일반 오브젝트의 경우 제거 전용 프리뷰를 사용하고, 구조물의 경우 기존 프리뷰를 활용하여 제거 대상을 표시한다.
마지막으로 입력을 현재 상태와 연결한다.
ConnectInputToBuildingState 함수를 통해 InputManager의 입력 이벤트가 현재 RemovingState와 연결되어, 마우스 입력이 제거 로직으로 전달될 수 있게 된다.
3.2. 제거 명령 실행
public void TryRemovingObject(SelectionResult selectionResult)
{
commandManager.Invoke(
new StructureRemoveCommand(
gridManager,
this,
buildingState.CurrentPlacementData,
itemData,
selectionResult
)
);
OnRemoveObject?.Invoke();
}TryRemovingObject 함수는 실제 구조물 제거 명령을 생성하고 실행하는 역할을 한다.
여기서 중요한 점은 PlacementManager가 직접 제거를 수행하지 않는다는 것이다.
대신 StructureRemoveCommand라는 명령 객체를 생성하고, 이를 CommandManager를 통해 실행한다.
CommandManager는 Command Pattern을 기반으로 동작하며, 모든 건축 동작을 명령 객체 형태로 관리한다.
이 구조를 사용하면 실행된 동작을 기록할 수 있고, 이후 Undo 요청이 들어왔을 때 해당 명령을 되돌릴 수 있다.
StructureRemoveCommand는 내부적으로 PlacementManager의 RemoveStructureAt 함수를 호출하여 실제 제거 작업을 수행한다.
즉, 이 함수는 실행이 아니라 명령 생성 단계라고 볼 수 있다.
이후 OnRemoveObject 이벤트를 호출하여 UI나 사운드 시스템과 같은 외부 시스템에 제거가 발생했음을 알린다.
3.3. 실제 구조물 제거
public void RemoveStructureAt( SelectionResult selectionResult, PlacementGridData placementData)
{
for (int i = 0; i < selectionResult.selectedGridPositions.Count; i++)
{
if (selectionResult.isEdgeStructure)
{
int index = placementData.GetIndexForEdgeObject(selectionResult.selectedGridPositions[i],
Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[i].eulerAngles.y));
if (index > -1)
{
placementData.RemoveEdgeObject(selectionResult.selectedGridPositions[i], selectionResult.size,
Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[i].eulerAngles.y));
structurePlacer.RemoveObjectAt(index);
}
}
else
{
int index = placementData.GetIndexForCellObject(selectionResult.selectedGridPositions[i]);
if (index > -1)
{
placementData.RemoveCellObject(selectionResult.selectedGridPositions[i]);
structurePlacer.RemoveObjectAt(index);
}
}
}
buildingState.RefreshSelection();
OnToggleUndo?.Invoke(true);
}RemoveStructureAt 함수는 실제 GameObject와 Grid 데이터를 제거하는 핵심 함수이다.
먼저 SelectionResult에 포함된 Grid 위치들을 for문을 통해 순회한다.
SelectionResult는 단일 선택뿐 아니라 여러 위치를 동시에 선택하는 경우도 지원하기 때문에 리스트 형태의 데이터를 사용하였다.
구조물이 경계 기반인지 여부는 다음 코드로 판단한다.
if (selectionResult.isEdgeStructure)경계 기반 구조물은 벽과 같이 방향을 가지는 구조물이기 때문에, 단순한 좌표만으로는 식별할 수 없다.
이 값이 true인 경우 해당 구조물은 벽과 같은 Edge 기반 구조물이므로 Edge 데이터 구조를 사용하여 제거해야 한다.
int index = placementData.GetIndexForEdgeObject(
selectionResult.selectedGridPositions[i],
Mathf.RoundToInt(selectionResult.selectedPositionGridCheckRotation[i].eulerAngles.y)
);이 코드는 특정 Grid 위치와 회전 값을 기준으로 해당 위치에 존재하는 구조물의 인덱스를 찾는다.
여기서 회전 값이 함께 사용되는 이유는 동일한 Grid 위치에서도 방향에 따라 서로 다른 구조물이 존재할 수 있기 때문이다.
반환된 index 값은 StructurePlacer 내부에서 관리하는 GameObject 리스트의 인덱스를 의미하며, 실제 오브젝트를 식별하는 데 사용된다.
구조물이 존재하는 경우에는 먼저 Grid 데이터에서 제거한다.
placementData.RemoveEdgeObject(...)이후 실제 GameObject를 제거한다.
structurePlacer.RemoveObjectAt(index);이 순서는 매우 중요하다.
먼저 데이터 상태를 갱신하고, 이후 실제 오브젝트를 제거함으로써 시스템의 논리 상태가 항상 먼저 정리되도록 설계되어 있다.
셀 기반 구조물도 동일한 방식으로 처리된다.
int index = placementData.GetIndexForCellObject(...)
placementData.RemoveCellObject(...)
structurePlacer.RemoveObjectAt(index);차이점은 회전 정보를 사용하지 않는다는 점이다.
셀 기반 구조물은 방향과 무관하게 셀 하나에 종속되기 때문에 좌표만으로 충분히 식별할 수 있다.
이 함수의 핵심은 단순하다.
Grid 데이터 제거와 GameObject 제거가 항상 동시에 수행된다는 점이다.
이 구조를 통해 시스템 상태의 일관성이 유지된다.
buildingState.RefreshSelection();
OnToggleUndo?.Invoke(true);RefreshSelection 함수는 제거 이후 반드시 수행되어야 하는 처리이다.
구조물이 제거되면 Grid의 점유 상태가 변경되기 때문에, 이전에 계산된 선택 결과는 더 이상 유효하지 않을 수 있다.
따라서 현재 마우스 위치를 기준으로 선택 상태를 다시 계산하여 프리뷰와 배치 가능 여부를 갱신해야 한다.
Invoke는 Undo 기능을 활성화하는 역할을 한다.
구조물 제거 역시 하나의 명령으로 기록되기 때문에, 제거가 발생한 시점부터 Undo가 가능해야 한다.
4. 개발 의도
건축 객체 제거 시스템의 핵심 설계 의도는 실제 오브젝트 상태와 Grid 데이터 상태를 항상 동기화하는 것이다.
건축 시스템에서는 구조물의 점유 상태를 기반으로 배치 가능 여부를 판단하기 때문에, 데이터와 실제 오브젝트 상태가 일치하지 않으면 시스템 전체가 불안정해질 수 있다.
이를 해결하기 위해 제거 로직은 반드시 데이터 제거와 오브젝트 제거를 동시에 수행하도록 설계하였다.
또한 제거 기능을 Command Pattern 기반으로 구성하여 Undo 시스템과 자연스럽게 연결되도록 하였으며, RemovingState를 통해 상태 패턴 안에서 동작하도록 구성하였다.
이 구조를 통해 건축 시스템은 배치, 제거, 이동과 같은 다양한 기능을 하나의 일관된 흐름 안에서 관리할 수 있게 되었으며, 기능 확장 시에도 구조를 유지하면서 안정적으로 개발을 진행할 수 있도록 설계하였다.
