건축 객체 이동 시스템 ( PlacementManager (5) )

목차

1. 요구 사항

2. 흐름도

3. 구현

        3.1. 이동 상태 활성화

       3.2. 이동 대상 구조물 제거 및 이동 준비

       3.3. 이동 완료 처리

       3.4. 이동 취소 및 원위치 복원

       3.5. 원위치 복원 처리

4. 개발 의도

1. 시스템 요구 사항

Grid 기반 건축 시스템에서는 플레이어가 이미 배치된 구조물을 제거하고 다시 설치하는 방식으로 위치를 변경할 수도 있지만, 이러한 방식은 사용자 경험 측면에서 매우 불편하다.

대부분의 건축 시스템에서는 기존 구조물을 직접 선택한 뒤 새로운 위치로 이동시키는 기능을 제공한다.

구조물 이동 기능은 단순히 오브젝트의 Transform 위치를 변경하는 것처럼 보일 수 있지만 실제로는 훨씬 복잡한 과정이 필요하다.

건축 시스템에서는 구조물이 Grid 데이터를 기반으로 관리되기 때문에 구조물을 이동할 때는 씬에 존재하는 GameObject의 위치뿐만 아니라 Grid 데이터 시스템의 점유 정보도 함께 업데이트되어야 한다.

또한 구조물을 이동하는 과정에서는 기존 위치의 데이터를 제거한 뒤 새로운 위치에 구조물을 다시 배치하는 과정이 필요하다.

이때 기존 구조물의 회전 값이나 크기 정보와 같은 데이터를 유지해야 하며, 이동이 취소될 경우에는 구조물을 원래 위치로 복원할 수 있어야 한다.

이와 함께 건축 시스템에서는 구조물 이동 과정이 일반적인 배치 모드와 충돌하지 않도록 별도의 이동 상태를 관리해야 한다.

플레이어가 이동 기능을 실행하면 시스템은 이동 상태로 전환되고, 이후 플레이어가 구조물을 선택하면 해당 구조물을 임시로 제거한 뒤 새로운 위치에 배치할 수 있는 상태로 전환된다.

따라서 건축 객체 이동 시스템은 플레이어 입력을 기반으로 이동 모드를 활성화해야 하며, 이동 대상이 되는 구조물을 선택한다.

또한, 선택된 구조물을 기존 위치에서 제거하며, 새로운 위치를 선택하여 구조물을 다시 배치하고, 이동이 취소될 경우 기존 위치로 구조물을 복원한다.

Grid 데이터와 실제 GameObject 상태를 동시에 업데이트한다.

2. 흐름도

플레이어 이동 명령

       ↓

PlacementManager.HandleMoveObject

       ↓

이동 상태 활성화

       ↓

구조물 선택

       ↓

기존 위치 구조물 제거

       ↓

PlacingObjectsState로 전환

       ↓

새 위치 선택

       ↓

구조물 재배치

       ↓

이동 완료

건축 객체 이동 시스템은 기존 배치 시스템을 재사용하는 구조로 설계되어 있다.

플레이어가 이동 기능을 실행하면 PlacementManager는 시스템을 이동 상태로 전환한다.

이후 플레이어가 마우스를 클릭하면 해당 위치의 Grid 데이터를 검사하여 이동 가능한 구조물이 존재하는지 확인한다.

유효한 구조물이 선택되면 해당 구조물을 씬과 Grid 데이터에서 제거한 뒤 PlacingObjectsState 상태로 전환하여 새로운 위치에 배치할 수 있도록 한다.

플레이어가 새로운 위치를 선택하면 기존 배치 시스템을 사용하여 구조물을 다시 배치한다.

이동 과정이 취소될 경우에는 기존 위치에 구조물을 다시 배치하여 시스템 상태를 복원한다.

3. 구현

3.1. 이동 상태 활성화
public void HandleMoveObject()
{
    if (movement || buildingState is RemovingState)
        return;

    OnMovementStateEntered?.Invoke();
    CancelState();
    
    previousPosition = null;
    previousRotation = Quaternion.identity;
    
    gridManager.ToggleGrid(true);
    movement = true;
    
    input.OnCancle += CancelMovement;
    input.OnMousePressed += TrySelectingObjectToMove;
}

HandleMoveObject 함수는 건축 시스템을 이동 상태로 전환하는 역할을 한다.

플레이어가 이동 기능을 실행하면 가장 먼저 이 함수가 호출된다.

함수의 시작에서는 현재 상태를 검사한다.

if (movement || buildingState is RemovingState) return;

이 조건문은 이동 기능이 중복 실행되는 것을 방지하기 위한 보호 로직이다.

이미 이동 중인 상태이거나 제거 상태일 경우에는 이동 기능을 실행하지 않도록 한다.

이 처리가 없으면 상태가 겹치면서 입력 이벤트가 중복 연결되는 문제가 발생할 수 있다.

이후 이동 상태 진입 이벤트를 호출한다.

OnMovementStateEntered?.Invoke();

이 이벤트는 UI 시스템이나 사운드 시스템과 연결되어 있으며, 이동 모드 진입 시각화나 효과를 처리하는 데 사용된다.

다음으로 CancelState함수를 호출하여, 기존 건축 상태를 제거한다.

이 코드는 상태 패턴에서 매우 중요한 역할을 한다.

이동 상태는 배치 상태나 제거 상태와 동시에 존재할 수 없기 때문에, 기존 상태를 반드시 종료해야 한다.

이후 이전 위치와 회전 정보를 초기화한다.

previousPosition = null;
previousRotation = Quaternion.identity;

이 변수들은 이후 이동 취소 시 원래 상태를 복원하기 위해 사용된다.

즉, 이동 시스템은 항상 되돌릴 수 있는 상태를 전제로 동작한다.

다음으로 ToggleGrid 함수를 통해 Grid 표시를 활성화한다.

Grid는 이동 대상과 위치를 시각적으로 확인하기 위한 기준이 된다.

마지막으로 입력 이벤트를 연결한다.

input.OnCancle += CancelMovement;
input.OnMousePressed += TrySelectingObjectToMove;

이 코드를 통해 플레이어의 입력이 이동 시스템으로 전달된다.

특히 OnMousePressed는 구조물 선택 로직과 연결되어 이동의 다음 단계로 이어진다.

3.2. 이동 대상 구조물 제거 및 이동 준비
private void TrySelectingObjectToMove()
{
    // 1) 이동 대상 구조물 선택
    if (input.IsInteractingWithUI())
        return;

    Vector3 selectedPosition = input.GetSelectedMapPosition();
    previousPosition = gridManager.GetCellPosition(selectedPosition, PlacementType.FreePlacedObject);
        
    if (previousPosition.HasValue == false)
    {
        Debug.Log("선택 그리드 외부를 클릭함");
        return;
    }

    // 올바른 배치 위츠를 얻음 (X 방향 크기가 클 경우 중요)
    previousPosition = gridData.ObjectPlacementData.GetOriginForCellObject(previousPosition.Value);
    if (previousPosition.HasValue == false || gridData.ObjectPlacementData.IsCellObjectAt(previousPosition.Value) == false)
    {
        previousPosition = null;
        Debug.Log("아무것도 없음");
    }
    else
    {          
        Debug.Log("Selected a ");
        input.OnMousePressed -= TrySelectingObjectToMove;
     
        SelectionResult result = new SelectionResult
        {
            isEdgeStructure = false,
            placementValidity = true,
    
            selectedGridPositions = new List<Vector3Int> { previousPosition.Value },
            selectedPositions = new List<Vector3> { gridManager.GetWorldPosition(previousPosition.Value)},
            selectedPositionGridCheckRotation = new List<Quaternion> { Quaternion.identity },
            selectedPositionsObjectRotation = new List<Quaternion> { Quaternion.identity }
        };
        itemData = structuresData.GetItemWithID(
        				gridData.ObjectPlacementData.GetStructureIDForCellObject(previousPosition.Value));

        int gameObjectIndex = gridData.ObjectPlacementData.GetIndexForCellObject(previousPosition.Value);
        previousRotation = structurePlacer.GetObjectsRotation(gameObjectIndex);
        
        // 2) 구조물 제거 및 이동 준비
        Debug.Log($"{itemData.name} 선택");
        buildingState = new PlacingObjectsState(gridManager, gridData, itemData);
        buildingState.SelectionData.Rotation = previousRotation;
        TryRemovingObject(result);

        // MovementState를 준비
        buildingState.OnFinished += (selectionResult) =>
        {
            TryPlacingObjects(selectionResult);
            previousPosition = null;
        };
        pp.StartShowingPreview(itemData.previewObject);
        buildingState.OnSelectionChanged += MovePreview;
        input.OnMouseReleased += TryMovingObject;
    }
}

TrySelectingObjectToMove 함수는 플레이어가 클릭한 위치에 이동 가능한 구조물이 존재하는지를 검사하는 역할을 한다.

1) 이동 대상 구조물 선택

먼저 UI 위 입력인지 검사한다.

if (input.IsInteractingWithUI()) return;

이 코드는 UI 클릭과 게임 입력을 분리하기 위한 처리이다.

UI 위에서 클릭했을 경우 이동 로직이 실행되지 않도록 한다.

이후 마우스 위치를 기반으로 월드 좌표를 얻는다.

Vector3 selectedPosition = input.GetSelectedMapPosition();

이 함수는 내부적으로 Raycast를 사용하여 실제 맵 위의 좌표를 반환한다.

그 다음 해당 위치를 Grid 좌표로 변환한다.

previousPosition = gridManager.GetCellPosition(selectedPosition, PlacementType.FreePlacedObject);

건축 시스템은 모든 구조물을 Grid 기준으로 관리하기 때문에, 월드 좌표가 아니라 Grid 좌표로 변환하는 과정이 필요하다.

이후 해당 위치가 유효한지 검사한다.

if (previousPosition.HasValue == false)

Grid 밖을 클릭한 경우 이동 대상이 존재할 수 없기 때문에 여기서 종료된다.

다음으로 구조물의 기준 위치를 계산한다.

previousPosition = gridData.ObjectPlacementData.GetOriginForCellObject(previousPosition.Value);

이 코드는 매우 중요하다.

구조물이 여러 셀을 차지하는 경우, 클릭된 위치는 구조물의 일부일 뿐이다.

따라서 구조물 전체를 대표하는 origin 위치를 찾아야 한다.

이후 해당 위치에 실제 구조물이 존재하는지를 검사한다.

gridData.ObjectPlacementData.IsCellObjectAt(previousPosition.Value)

구조물이 없다면 이동은 불가능하므로 처리하지 않는다.

구조물이 존재하는 경우, 본격적인 이동 준비가 시작된다.

먼저 해당 구조물의 데이터를 가져온다.

itemData = structuresData.GetItemWithID(...)

이 데이터는 이후 구조물을 다시 배치할 때 사용된다.

다음으로 GameObject의 인덱스를 가져온다.

int gameObjectIndex = gridData.ObjectPlacementData.GetIndexForCellObject(...)

이 인덱스는 StructurePlacer 내부에서 실제 GameObject를 식별하기 위한 값이다.

이후 현재 구조물의 회전 값을 저장한다.

previousRotation = structurePlacer.GetObjectsRotation(gameObjectIndex);

이 값은 이동 후에도 동일한 방향을 유지하기 위해 필요하며, 취소 시 복원에도 사용된다.

2) 구조물 제거 및 이동 준비

이후 시스템은 PlacingObjectsState로 전환된다.

buildingState = new PlacingObjectsState(...)

이 부분이 이동 시스템의 핵심 설계이다.

이동은 별도의 로직이 아니라, '제거 → 배치'의 재사용 구조로 처리된다.

이후 선택 상태의 회전을 유지한다.

buildingState.SelectionData.Rotation = previousRotation;

이 코드를 통해 이동 중에도 기존 방향이 유지된다.

구조물이 선택되면, 기존 위치의 구조물을 제거한다.

TryRemovingObject(result);

이 호출은 단순 제거가 아니라 Command 시스템을 통해 제거가 수행된다.

즉 Undo가 가능한 상태로 제거가 이루어진다.

그 다음 배치 완료 시 실행될 로직을 연결한다.

buildingState.OnFinished += (selectionResult) =>
{
    TryPlacingObjects(selectionResult);
    previousPosition = null;
};

이 이벤트는 새로운 위치에 구조물이 배치되는 시점에 호출된다.

즉 이동 완료 시 실제 구조물 재배치가 수행된다.

마지막으로 프리뷰와 입력을 연결한다.

pp.StartShowingPreview(itemData.previewObject);
input.OnMouseReleased += TryMovingObject;

이 구조를 통해 플레이어는 이동 중인 구조물을 시각적으로 확인할 수 있다.

3.3. 이동 완료 처리
private void TryMovingObject()
{
    input.OnMouseReleased -= TryMovingObject;
    ConnectInputToBuildingState();
    buildingState.OnFinished += (_) => CancelMovement();
}

TryMovingObject 함수는 플레이어가 마우스 버튼을 놓았을 때 호출되며, 이동이 완료되는 시점을 처리한다.

먼저 이동 입력을 해제한다.

input.OnMouseReleased -= TryMovingObject;

이 코드는 중복 호출을 방지하기 위한 처리이다.

이후 ConnectInputToBuildingState 함수를 호출하여, 일반 배치 입력을 다시 연결한다.

이 과정을 통해 이동 상태에서 배치 상태로 자연스럽게 전환된다.

마지막으로 배치 완료 시 이동 상태를 종료하도록 연결한다.

buildingState.OnFinished += (_) => CancelMovement();

즉, 구조물이 새로운 위치에 정상적으로 배치되면 이동 상태는 자동으로 종료된다.

3.4. 이동 취소 및 원위치 복원
private void CancelMovement()
{
    Debug.Log("cancel");
    if (movement == false)
        return;

    if (previousPosition != null)
        PlaceMovedObjectBackinPlace();
    
    CancelState();
    
    gridManager.ToggleGrid(false);
    OnExitMovement?.Invoke();
    movement = false;
    
    input.OnCancle -= CancelMovement;
    input.OnMousePressed -= TrySelectingObjectToMove;
}

CancelMovement 함수는 이동 도중 취소가 발생했을 때 호출된다.

먼저 이동 상태 여부를 확인한다.

if (movement == false) return;

이동 중이 아닐 경우에는 아무 작업도 수행하지 않는다.

이후 이전 위치 정보가 존재하는 경우 원래 위치로 복원한다.

if (previousPosition != null) PlaceMovedObjectBackinPlace();

이 함수는 저장해둔 Grid 좌표와 회전 값을 기반으로 구조물을 다시 배치한다.

즉, 이동 취소는 단순히 되돌림이 아니라, 명시적으로 재배치를 수행하는 과정이다.

이후 상태를 정리한다.

CancelState();
gridManager.ToggleGrid(false);
movement = false;

마지막으로 입력 이벤트를 해제한다.

input.OnCancle -= CancelMovement;
input.OnMousePressed -= TrySelectingObjectToMove;

이 과정을 통해 이동 상태와 관련된 모든 입력 연결이 제거된다.

3.5. 원위치 복원 처리
private void PlaceMovedObjectBackinPlace()
{
    SelectionResult result = new SelectionResult
    {
        isEdgeStructure = false,
        placementValidity = true,
        selectedGridPositions = new List<Vector3Int> { previousPosition.Value },
        selectedPositions = new List<Vector3> { gridManager.GetWorldPosition(previousPosition.Value) },
        selectedPositionGridCheckRotation = new List<Quaternion> { previousRotation },
        selectedPositionsObjectRotation = new List<Quaternion> { previousRotation }
    };
    TryPlacingObjects(result);
    previousPosition = null;
}

이 함수는 이동 도중 작업이 취소되었을 때, 제거된 구조물을 원래 위치에 다시 배치하는 역할을 한다.

즉, 이동 시스템에서 발생할 수 있는 상태 손실을 복구하는 롤백 처리 함수이다.

이 함수의 핵심은 단순히 Transform을 되돌리는 것이 아니라, 기존 배치 시스템을 그대로 재사용하여 구조물을 복원한다는 점이다.

먼저 SelectionResult 구조체를 새롭게 생성한다.

SelectionResult는 건축 시스템 전반에서 선택 결과를 표현하는 데이터 구조이며, 구조물 배치와 제거 모두에서 동일하게 사용된다.

여기서 중요한 점은 이동 취소 역시 배치로 처리된다는 것이다.

이 함수에서는 이전에 저장해둔 Grid 위치를 사용하여 selectedGridPositions를 구성한다.

selectedGridPositions = new List<Vector3Int> { previousPosition.Value }

이 값은 구조물이 원래 위치했던 Grid 좌표이며, 이동 이전 상태를 복원하는 기준이 된다.

다음으로 해당 Grid 좌표를 월드 좌표로 변환한다.

selectedPositions = new List<Vector3> { gridManager.GetWorldPosition(previousPosition.Value) }

Grid 기반 시스템에서는 내부적으로 Grid 좌표를 사용하지만, 실제 GameObject 배치는 월드 좌표를 기반으로 이루어지기 때문에 이 변환 과정이 필요하다.

그 다음 회전 정보를 설정한다.

selectedPositionGridCheckRotation = new List<Quaternion> { previousRotation },
selectedPositionsObjectRotation = new List<Quaternion> { previousRotation }

이 값은 이동 이전에 저장해둔 회전 값이며, 구조물이 동일한 방향으로 복원되도록 하기 위한 처리이다.

특히 Grid 검사 회전과 실제 오브젝트 회전을 동일하게 설정하는 이유는, 배치 가능 여부 검사와 실제 배치 결과가 일치하도록 하기 위함이다.

이후 TryPlacingObjects 함수를 호출한다.

TryPlacingObjects(result);

이 호출이 중요한 이유는, 구조물 복원이 단순한 Instantiate가 아니라, 기존 배치 시스템과 Command 시스템을 그대로 사용하는 방식으로 이루어지기 때문이다.

즉, 이동 취소는 별도의 복원 로직을 사용하는 것이 아니라, 기존 배치 시스템을 다시 실행하는 방식이다.

이 설계는 배치 로직을 재사용하여 코드 중복을 방지할 수 있으며, Grid 데이터와 GameObject 상태를 동일한 방식으로 복원할 수 있다.

또한, Command 시스템과 자연스럽게 연결되어 Undo 흐름을 유지할 수 있다.

마지막으로 previousPosition을 null로 초기화한다.

이는 복원이 완료된 이후 이전 상태 정보가 다시 사용되지 않도록 하기 위한 처리이다.

이를 통해 다음 이동 작업에서 이전 값이 잘못 사용되는 것을 방지한다.

4. 개발 의도

건축 객체 이동 시스템의 핵심 설계 의도는 이동 기능을 별도의 독립적인 로직으로 구현하는 것이 아니라, 기존 배치 시스템을 재사용하여 구성하는 것이다.

이 시스템에서 이동은 단순한 Transform 변경이 아니라, 기존 구조물을 제거하고 새로운 위치에 다시 배치하는 과정으로 정의된다.

이를 통해 Grid 데이터와 실제 GameObject 상태를 항상 일관되게 유지할 수 있다.

또한 이동 상태를 별도의 상태로 분리하여 배치 상태, 제거 상태와 충돌하지 않도록 설계하였다.

이 구조는 상태 패턴을 기반으로 하며, 각 상태가 명확한 책임을 가지도록 한다.

특히 이동 취소 기능은 시스템 안정성 측면에서 중요한 역할을 한다.

이동 도중 언제든지 원래 상태로 복원할 수 있도록 이전 위치와 회전 정보를 저장하고, 이를 기반으로 재배치하는 방식으로 구현하였다.