구조물 배치 명령
목차
1. 시스템 요구 사항
건축 시스템에서 구조물 배치는 단순히 프리팹을 생성하는 작업으로 끝나지 않는다.
플레이어가 마우스로 선택한 위치에는 이미 Grid 좌표, 월드 좌표, 회전 정보, 배치 가능 여부 같은 데이터가 계산되어 있고, 실제 배치가 일어날 때는 이 정보를 기반으로 씬 오브젝트와 Grid 데이터가 동시에 갱신되어야 한다.
또한 이 작업은 한 번 실행되고 끝나는 것이 아니라, 이후 Undo를 통해 정확히 되돌릴 수 있어야 한다.
이때 중요한 점은 배치를 그냥 함수 호출로 처리하면, 실행은 쉽지만 되돌리기 기준이 흐려진다는 것이다.
예를 들어 PlacementManager가 직접 배치를 수행해 버리면, 나중에 Undo를 할 때 어떤 위치에 어떤 회전으로 어떤 구조물을 배치했는지를 다시 추적해야 한다.
특히 Grid 기반 건축 시스템에서는 한 번의 입력이 여러 셀을 동시에 점유할 수 있기 때문에, 이 정보를 명령 단위로 묶어 두지 않으면 되돌리기 구조가 불안정해질 수 있다.
따라서 구조물 배치 시스템에는 실행 당시의 배치 정보를 그대로 가지고 있는 명령 객체가 필요하다.
이 명령 객체는 배치를 실제로 수행할 수 있어야 하고, 동일한 데이터를 사용해 그 배치를 반대로 제거할 수도 있어야 한다.
StructurePlacementCommand는 단순한 배치 함수가 아니라, 하나의 배치 동작을 실행과 복원까지 포함한 단위 작업으로 캡슐화하는 명령 객체여야 한다.
2. 흐름도
PlacementManager
↓
StructurePlacementCommand 생성
↓
CommandManager.Invoke()
↓
CanExecute()
├─ false → 종료
└─ true
↓
Execute()
↓
PlacementManager.PlaceStructureAt()
↓
씬 오브젝트 생성 + Grid 데이터 기록
Undo 요청
↓
CommandManager.Undo()
↓
StructurePlacementCommand.Undo()
↓
PlacementManager.RemoveStructureAt()
↓
씬 오브젝트 제거 + Grid 데이터 제거
이 흐름의 핵심은 PlacementManager가 배치를 직접 끝까지 처리하지 않고, 배치에 필요한 데이터를 가진 Command 객체를 생성한 뒤 CommandManager에 넘긴다는 점이다.
CommandManager는 이 명령이 실행 가능한지 먼저 묻고, 가능하다면 실행한다.
이후 Undo 요청이 들어오면, 같은 명령 객체가 저장하고 있던 동일한 배치 정보를 사용해 반대 작업을 수행한다.
StructurePlacementCommand는 단순히 배치를 하라는 요청을 전달하는 객체가 아니라, 배치 실행과 Undo를 연결하는 중심 단위다.
이 구조 덕분에 배치라는 행위는 실행 시점과 복원 시점 모두에서 동일한 기준 데이터를 사용할 수 있게 된다.
3. 구현
3.1. 클래스 구조와 ICommand 구현 의미
public class StructurePlacementCommand : ICommand이 클래스는 ICommand를 구현하는 구조로 선언되어 있다.
여기서 중요한 것은 StructurePlacementCommand가 단순한 일반 클래스가 아니라, CommandManager가 실행 가능한 명령 객체라는 점이다.
C#의 인터페이스는 클래스가 반드시 구현해야 하는 함수 형식을 강제하는 장치이다.
ICommand를 구현한다는 것은 이 클래스가 Execute, CanExecute, Undo를 반드시 제공해야 하며, CommandManager는 이 클래스가 구조물 배치 명령인지, 제거 명령인지, 교체 명령인지 몰라도 같은 방식으로 다룰 수 있다는 뜻이다.
이 설계의 장점은 명확하다.
CommandManager는 구체 클래스에 의존하지 않고 ICommand라는 추상 규약만 보면 된다.
그래서 새로운 명령 클래스가 추가되더라도 CommandManager 코드를 수정할 필요가 없다.
반대로 단점은 인터페이스 자체는 공통 상태나 기본 구현을 가질 수 없다는 점이다.
하지만 현재 구조에서는 공통 상태보다 모든 명령이 실행과 Undo를 반드시 제공해야 한다는 규약이 더 중요하므로, 인터페이스가 적절하다.
3.2. 필드 구성과 명령 객체의 상태 캡슐화
PlacementManager placementManager;
PlacementGridData placementData;
ItemData itemData;
SelectionResult selectionResult;겉으로 보기에는 단순한 참조 변수처럼 보이지만, 이 네 개는 이 명령이 실행되기 위해 필요한 모든 핵심 상태를 담고 있다.
먼저 placementManager는 실제 배치와 제거를 수행하는 실행 계층이다.
중요한 점은 StructurePlacementCommand가 직접 GameObject를 생성하거나 Grid 데이터를 수정하지 않는다는 것이다.
이 클래스는 직접 Instantiate나 Destroy 같은 작업을 하지 않고, PlacementManager에게 실행을 위임한다.
이 구조는 책임 분리 관점에서 매우 중요하다.
Command는 언제 어떤 작업을 실행할지를 책임지고, PlacementManager는 그 작업을 실제로 어떻게 수행할지를 책임진다.
이렇게 분리하면 배치 로직이 여러 Command 클래스에 중복되지 않는다.
placementData는 현재 명령이 어떤 Grid 데이터 레이어를 수정해야 하는지를 나타낸다.
예를 들어 바닥, 벽, 자유 배치 오브젝트, 벽 내부 구조물은 서로 다른 PlacementGridData 위에서 관리될 수 있다.
이 값을 명령이 직접 들고 있어야 하는 이유는 Undo 때문이다.
같은 SelectionResult라도 어떤 Grid 레이어에 기록했는지 모르면, 나중에 RemoveStructureAt을 호출할 때 올바른 데이터를 제거할 수 없다.
placementData는 단순한 보조 참조가 아니라, 이 배치가 어느 데이터 계층 위에서 일어났는가를 기억하는 핵심 정보다.
itemData는 무엇을 배치할지를 나타내는 구조물 정보다.
여기에는 프리팹, 크기, 배치 타입 같은 정보가 포함되어 있다.
Execute 시에는 어떤 구조물을 만들어야 하는지를 알려주고, Undo 시에는 selectionResult와 placementData만으로 충분히 제거가 가능하더라도, 명령 객체 차원에서는 이 명령이 무엇을 배치하려 했는가를 보존하고 있다는 점에서 의미가 있다.
이 필드는 단순 전달용을 넘어서, 명령 자체의 정체성을 구성하는 데이터라고 볼 수 있다.
마지막으로 selectionResult는 이 Command에서 가장 중요한 필드다.
SelectionResult는 플레이어가 선택한 결과를 하나의 데이터 구조로 묶은 것이다.
여기에는 선택된 Grid 좌표, 월드 좌표, 회전, 배치 가능 여부 등이 포함되어 있다.
다시 말해 StructurePlacementCommand는 하나의 오브젝트를 배치하는 클래스가 아니라, SelectionResult에 담긴 선택 결과 전체를 하나의 배치 동작으로 실행하는 클래스다.
그래서 다중 셀 배치나 여러 위치 동시 배치도 같은 구조로 처리할 수 있다.
3.3. 생성자와 실행 시점 데이터 고정
public StructurePlacementCommand(
PlacementManager placementManager,
PlacementGridData placementData,
ItemData itemData,
SelectionResult selectionResult)
{
this.placementManager = placementManager;
this.selectionResult = selectionResult;
this.placementData = placementData;
this.itemData = itemData;
}이 생성자는 이 명령 객체가 실행되기 위해 필요한 상태를 모두 받아서 내부 필드에 고정한다.
이 과정은 단순한 값 대입처럼 보이지만, 실제로는 Command 패턴에서 가장 중요한 순간이다.
왜냐하면 이 시점에 실행 당시의 배치 정보가 명령 객체 안에 저장되기 때문이다.
예를 들어 PlacementManager가 현재 선택 상태를 가지고 바로 배치를 수행해 버리면, 그 시점에는 문제없이 동작할 수 있다.
하지만 나중에 Undo를 하려고 할 때, 원래 어떤 위치에 어떤 회전으로 배치했는지, 어느 placementData를 사용했는지를 다시 찾는 과정이 필요해진다.
반면 StructurePlacementCommand는 생성될 때 이미 그 정보를 모두 내부에 저장한다.
이 클래스는 실행에 필요한 정보뿐 아니라 나중에 복원을 위해 다시 참조할 정보까지 함께 들고 있는 셈이다.
이 구조의 장점은 명령 객체가 생성된 시점의 상태를 그대로 보존한다는 점이다.
외부 상태가 나중에 바뀌어도, Undo는 이 객체 안에 저장된 selectionResult를 기준으로 수행된다.
반대로 단점은 selectionResult나 itemData 같은 객체가 참조형일 경우, 외부에서 해당 객체를 수정하면 명령 안의 의미도 달라질 수 있다는 점이다.
하지만 현재 구조에서는 selectionResult가 명령 생성 직후 바로 사용되고, 이후 Undo도 같은 흐름 안에서 이어지므로 실용적으로는 충분히 안정적이다.
3.4. 실행 가능 여부 판단
public bool CanExecute()
{
return selectionResult.placementValidity;
}이 함수는 이 명령이 실제로 실행 가능한지를 판단할 때 호출된다.
CommandManager는 명령을 실행하기 전에 먼저 CanExecute 함수를 호출하고, false가 나오면 Execute를 호출하지 않을 뿐 아니라 스택에도 넣지 않는다.
따라서 이 함수는 단순한 보조 검사가 아니라, “이 명령이 히스토리에 기록될 자격이 있는가”를 결정하는 필터다.
이 코드에서 반환하는 값은 selectionResult.placementValidity다.
중요한 점은 StructurePlacementCommand가 배치 가능 여부를 스스로 계산하지 않는다는 것이다.
이미 선택 전략과 PlacementValidator를 거쳐 계산된 결과를 그대로 사용한다.
검증 로직과 실행 로직을 분리한 구조다.
이 설계는 좋다.
배치 가능 여부 판단은 선택 전략과 Validator가 담당하고, Command는 그 결과를 보고 실행 여부만 결정한다.
이렇게 하면 Command가 가벼워지고, 검증 규칙이 바뀌어도 Command 코드를 수정할 필요가 없다.
또한 이 함수가 매우 짧다는 점도 의미가 있다.
짧다고 해서 역할이 작은 것이 아니라, 오히려 Command는 검증을 다시 하지 않는다는 설계 원칙을 분명하게 드러낸다.
이 Command는 자기 앞 단계에서 이미 준비된 selectionResult를 신뢰하고, 실행 여부만 묻는 구조다.
결과적으로 CanExecute는 이 명령을 시스템에 기록해도 되는가를 판단하는 최소한의 관문이다.
3.5. 실제 배치 실행
public void Execute()
{
placementManager.PlaceStructureAt(selectionResult,placementData, this.itemData);
}이 함수는 이 명령의 실제 실행 함수다.
CommandManager가 Invoke() 안에서 CanExecute()를 통과한 명령에 대해 호출하게 된다.
이 함수가 호출된다는 것은 이미 이 배치가 유효한 위치라고 판단되었고, 이제 실제 씬과 Grid 데이터에 반영해야 한다는 뜻이다.
코드를 보면 Execute 안에는 직접적인 배치 로직이 없다.
Instantiate, AddCellObject, AddEdgeObject 같은 작업을 여기서 수행하지 않고, 모두 placementManager.PlaceStructureAt(...)에 맡긴다.
이 점이 매우 중요하다.
StructurePlacementCommand는 배치를 직접 구현하는 클래스가 아니라, 배치라는 작업을 명령 단위로 실행하도록 위임하는 클래스다.
이렇게 해야 배치 로직이 PlacementManager 안에 한 번만 존재하게 되고, Command는 그 로직을 언제 호출할지만 결정하면 된다.
인자로 넘기는 값도 의미가 크다.
selectionResult는 어디에 어떻게 배치할지, placementData는 어느 레이어에 기록할지, itemData는 무엇을 배치할지를 나타낸다.
이 한 줄은 배치에 필요한 모든 정보를 PlacementManager에 전달해 실제 배치를 수행한다는 의미를 가진다.
짧은 코드지만, 이 명령이 가진 모든 상태를 한 번에 실행 계층으로 넘기는 핵심 지점이다.
결과적으로 Execute는 배치 동작의 실제 시작 버튼 역할을 하는 함수라고 볼 수 있다.
3.6. Undo를 통한 배치 반대 작업
public void Undo()
{
placementManager.RemoveStructureAt(selectionResult, placementData);
}이 함수는 Execute의 반대 작업을 수행하는 함수다.
CommandManager가 Undo를 실행할 때, 스택에서 가장 최근 명령을 꺼내 이 함수를 호출한다.
StructurePlacementCommand에서 Undo는 배치했던 것을 제거하는 것이다.
여기서 중요한 점은 Execute에서 사용한 동일한 selectionResult와 placementData를 그대로 사용한다는 것이다.
Execute에서는 이 데이터를 기준으로 배치했고, Undo에서는 같은 데이터를 기준으로 제거한다.
이게 바로 Command 패턴에서 중요한 실행과 복원의 대칭성이다.
Undo가 별도의 계산을 다시 하지 않고, 실행 당시의 정보를 그대로 사용하기 때문에 더 안정적이다.
예를 들어 나중에 현재 선택 상태가 달라졌더라도, Undo는 그와 상관없이 그때 배치했던 바로 그 결과를 되돌리게 된다.
또한 이 함수 역시 직접 삭제 로직을 구현하지 않고 placementManager.RemoveStructureAt(...)에 위임한다.
Execute와 똑같이, 명령 객체는 작업의 흐름을 관리하고 실제 처리 로직은 PlacementManager가 담당한다.
이런 구조 덕분에 배치 로직과 제거 로직이 여러 Command 클래스에 복사되지 않고, 한곳에 유지된다.
결국 Undo는 단순한 보조 기능이 아니라, Execute와 짝을 이루는 완전한 반대 작업이며, StructurePlacementCommand가 명령 객체로서 완성되는 핵심 요소다.
4. 개발 의도
이 함수는 Execute의 반대 작업을 수행하는 함수다.
CommandManager가 Undo를 실행할 때, 스택에서 가장 최근 명령을 꺼내 이 함수를 호출한다.
StructurePlacementCommand에서 Undo는 배치했던 것을 제거하는 것이다.
여기서 중요한 점은 Execute에서 사용한 동일한 selectionResult와 placementData를 그대로 사용한다는 것이다.
Execute에서는 이 데이터를 기준으로 배치했고, Undo에서는 같은 데이터를 기준으로 제거한다.
이게 바로 Command 패턴에서 중요한 실행과 복원의 대칭성이다.
Undo가 별도의 계산을 다시 하지 않고, 실행 당시의 정보를 그대로 사용하기 때문에 더 안정적이다.
예를 들어 나중에 현재 선택 상태가 달라졌더라도, Undo는 그와 상관없이 그때 배치했던 바로 그 결과를 되돌리게 된다.
또한 이 함수 역시 직접 삭제 로직을 구현하지 않고 placementManager.RemoveStructureAt(...)에 위임한다.
Execute와 똑같이, 명령 객체는 작업의 흐름을 관리하고 실제 처리 로직은 PlacementManager가 담당한다.
이런 구조 덕분에 배치 로직과 제거 로직이 여러 Command 클래스에 복사되지 않고, 한곳에 유지된다.
결국 Undo는 단순한 보조 기능이 아니라, Execute와 짝을 이루는 완전한 반대 작업이며, StructurePlacementCommand가 명령 객체로서 완성되는 핵심 요소다.
