선택 전략 시스템의 공통 추상화
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서는 플레이어의 입력이 항상 같은 방식으로 해석되지 않는다.
같은 마우스 이동이라도 현재 상태가 바닥 배치인지, 벽 배치인지, 벽 내부 오브젝트 배치인지, 혹은 제거 상태인지에 따라 선택 계산 방식과 검증 규칙이 모두 달라진다.
따라서 건축 시스템에는 선택 처리의 공통 흐름과 상태별로 달라지는 선택 규칙을 분리할 수 있는 구조가 필요하다.
이때 상위 컨트롤러가 모든 선택 규칙을 직접 알고 있으면, 새로운 배치 타입이 추가될 때마다 조건문이 계속 늘어나고 PlacementManager 같은 상위 계층이 비대해진다.
반대로 각 상태가 아무 공통 규약 없이 제각각 선택을 처리하면, 선택 시작과 진행, 종료, 회전, 유효성 갱신 같은 흐름이 통일되지 않아 유지보수가 어려워진다.
그래서 선택 시스템은 반드시 공통 인터페이스를 먼저 정의하고, 구체 전략들이 그 틀 안에서만 동작하도록 구성되어야 한다.
SelectionStrategy는 바로 이 요구를 만족시키기 위한 추상 클래스다.
이 클래스는 GridManager, PlacementGridData, LastDetectedPositon 같은 공통 의존성을 보유하고, 선택 시작, 선택 진행, 선택 종료, 회전 처리, 배치 유효성 검증이라는 핵심 흐름을 하나의 추상 계층 위에 모아 둔다.
공통 흐름은 유지하되, 실제 선택 규칙은 하위 전략이 구현하게 만드는 구조다.
다시 말해 SelectionStrategy는 선택을 어떻게 계산할 것인가를 통일된 틀 안에서 정의하는 기반 계층이다.
2. 흐름도
PlacementSelector
↓
SelectionStrategy.StartSelection()
↓
SelectionData 초기화 및 시작 상태 기록
↓
SelectionStrategy.ModifySelection()
↓
현재 마우스 위치 → Grid 좌표 해석
↓
선택 결과 계산
↓
ValidatePlacement()
↓
SelectionData.PlacementValidity 갱신
↓
PlacementSelector.OnSelectionChanged
↓
SelectionStrategy.FinishSelection()
↓
내부 상태 초기화
SelectionStrategy의 흐름은 단순히 좌표를 반환하는 것이 아니다.
PlacementSelector가 선택 시작, 마우스 이동, 선택 종료 시점마다 이 전략 객체를 호출하면, 전략 객체는 현재 마우스 위치를 해석하고 SelectionData를 갱신한다.
그리고 그 과정에서 해당 선택이 실제로 유효한지 ValidatePlacement를 통해 판단한다.
이렇게 갱신된 결과는 다시 PlacementSelector를 통해 SelectionResult 형태로 외부에 전달된다.
즉, SelectionStrategy는 입력을 직접 받는 계층이 아니라, 입력으로부터 만들어진 선택 위치를 상태에 맞는 선택 결과로 바꿔 주는 해석 계층이다.
3. 구현
3.1. 추상 클래스 구조와 공통 필드
public abstract class SelectionStrategy
{
protected GridManager gridManager;
protected LastDetectedPositon lastDetectedPosition;
protected PlacementGridData placementData;
...
}이 클래스가 abstract class로 선언되어 있다는 점이 가장 먼저 중요하다.
C#의 추상 클래스는 직접 인스턴스를 생성할 수 없고, 반드시 하위 클래스가 상속해서 구체 구현을 제공해야 한다.
이 선택은 SelectionStrategy의 역할과 정확히 맞는다.
이 클래스는 바닥 선택, 벽 선택, 자유 배치 선택 같은 구체 전략을 직접 구현하는 것이 아니라, 그런 전략들이 공통으로 따라야 할 구조를 정의하는 역할만 수행하기 때문이다.
인터페이스를 사용할 수도 있었겠지만, 이 코드는 단순 규약만 필요한 것이 아니라 공통 필드와 기본 구현까지 함께 가져야 하므로 추상 클래스가 더 적절하다.
인터페이스는 메서드 시그니처 통일에는 좋지만, 공통 필드나 기본 동작을 가질 수 없기 때문에 현재 구조에서는 추상 클래스가 더 자연스럽다.
클래스의 첫 세 필드는 모든 선택 전략이 공통으로 사용하는 핵심 의존성이다.
gridManager는 월드 좌표와 Grid 좌표 사이의 변환을 담당하고, placementData는 현재 선택이 어떤 배치 레이어 위에서 검증되어야 하는지를 나타낸다.
lastDetectedPosition은 이전에 처리한 Grid 좌표를 저장하여, 마우스가 같은 셀 위에 머무르는 동안 불필요한 재계산을 막는 데 사용된다.
특히 이 클래스가 LastDetectedPositon을 공통 필드로 들고 있다는 점은 중요하다.
바닥 선택이든 벽 선택이든, 이전 위치와 동일하면 굳이 다시 계산하지 않는다는 최적화 규칙은 모두에게 유효하기 때문이다.
이 필드는 단순 편의 기능이 아니라 선택 전략 계층 전체의 공통 성능 최적화 장치다.
3.2. 생성자와 의존성 주입 구조
public SelectionStrategy(PlacementGridData placementData, GridManager gridManager)
{
this.lastDetectedPosition = new LastDetectedPositon();
this.placementData = placementData;
this.gridManager = gridManager;
}이 생성자는 두 가지 외부 의존성, 즉 placementData와 gridManager를 주입받고, 동시에 lastDetectedPosition을 내부에서 생성한다.
여기서 placementData와 gridManager를 외부에서 받는 이유는 전략마다 어떤 Grid 레이어를 사용할지, 어떤 좌표 변환기를 사용할지가 외부 상태에 따라 달라지기 때문이다.
반대로 LastDetectedPositon은 모든 전략이 독립적으로 자기 마지막 위치를 저장해야 하므로 내부에서 바로 생성한다.
이 구조는 의존성 주입과 내부 상태 생성을 구분한 설계라고 볼 수 있다.
외부 시스템이 결정해야 할 것은 외부에서 받고, 전략 객체가 스스로 관리해야 할 것은 내부에서 만든다.
장점은 어떤 데이터 위에서 동작할지가 명확해진다는 점이고, 단점은 생성 시점에 필요한 객체를 반드시 모두 알고 있어야 한다는 점이다.
하지만 상태 객체가 전략을 생성하는 현재 구조에서는 오히려 그 명확성이 더 큰 장점이다.
3.3. 선택 상태 갱신 진입점
public abstract bool ModifySelection(Vector3 mousePosition, SelectionData selectionData);ModifySelection은 선택 전략의 핵심 메서드이며, 추상 메서드로 선언되어 있다.
이 메서드는 PlacementSelector가 마우스 이동 시점마다 호출하는 함수다.
여기서 중요한 점은 반환형이 void가 아니라 bool이라는 것이다.
이 설계는 매우 실용적이다.
단순히 선택 상태를 수정하는 것으로 끝나지 않고, 실제로 선택 결과가 변경되었는지까지 호출부에 알려주기 때문이다.
PlacementSelector 코드를 보면 ModifySelection이 true를 반환할 때만 OnSelectionChanged를 발생시킨다.
즉, 이 bool 값은 선택 계산 그 자체보다, 이후 프리뷰 갱신이나 상위 시스템 이벤트를 실행해도 되는지를 판단하는 트리거 역할을 한다.
이런 구조 덕분에 위치가 바뀌지 않았는데도 매번 이벤트를 다시 보내는 비효율을 막을 수 있다.
mousePosition은 월드 좌표 기준 입력이고, selectionData는 현재 선택 상태를 저장하는 가변 컨테이너다.
결국 이 메서드는 현재 마우스 위치를 기준으로 selectionData를 어떻게 수정할 것인가를 전략별로 정의하는 핵심 진입점이다.
3.4. 배치 유효성 재검증 구조
public void RefreshSelection(SelectionData selectionData)
{
selectionData.PlacementValidity = ValidatePlacement(selectionData);
}RefreshSelection은 이 추상 클래스 안에서 기본 구현을 제공하는 몇 안 되는 메서드다.
이 메서드의 역할은 현재 SelectionData를 기준으로 다시 유효성을 계산하는 것이다.
이 코드가 중요한 이유는 선택 위치가 바뀌지 않아도 배치 가능 여부가 바뀌는 상황이 있기 때문이다.
예를 들어 방금 어떤 구조물을 배치해서 Grid 상태가 바뀌었거나, 회전만 바뀌어서 같은 위치라도 더 이상 유효하지 않을 수 있다.
그럴 때 전략은 기존 선택 좌표를 다시 만들 필요 없이, 단순히 현재 선택 데이터의 유효성만 재검사하면 된다.
여기서 ValidatePlacement를 직접 호출해 selectionData.PlacementValidity에 결과를 넣는데, 이런 구조 덕분에 유효성 계산은 추상화된 채 유지되면서도 갱신 흐름은 공통 메서드 하나로 통일된다.
이 함수가 추상이 아니라 구체 구현이라는 점은, 유효성 갱신이라는 행위 자체는 모든 전략이 동일하다는 설계 의도를 보여준다.
차이는 어디까지나 ValidatePlacement 안에 있다.
3.5. 선택 종료 처리 및 상태 초기화
public abstract void FinishSelection(SelectionData selectionData);FinishSelection은 선택 완료 시점의 정리 작업을 강제하는 추상 메서드다.
선택 전략마다 종료 시점에 정리해야 할 내부 상태가 다르기 때문에 이 메서드는 공통 구현이 아니라 추상으로 남겨져 있다.
예를 들어 BoxSelection은 시작점 startposition을 null로 돌리고 lastDetectedPosition.Reset()을 호출해야 하고, 벽 배치 전략도 비슷하게 시작점과 마지막 위치를 초기화해야 한다.
선택 완료는 단순히 결과를 반환하는 것으로 끝나는 것이 아니라, 다음 선택을 받을 준비를 위해 내부 상태를 초기 상태로 되돌리는 단계까지 포함한다.
이 메서드를 추상화해 둔 덕분에 모든 전략은 반드시 종료 시 어떻게 자신을 정리할 것인지를 직접 정의해야 한다.
3.6. 선택 시작 처리
public abstract void StartSelection(Vector3 mousePosition, SelectionData selectionData);StartSelection 역시 추상 메서드다.
이 메서드는 마우스 버튼을 눌러 선택을 시작하는 순간 호출된다.
그런데 전략마다 시작 의미가 다르기 때문에 공통 구현을 만들기 어렵다.
BoxSelection은 시작점 셀을 따로 저장해야 하고, FreeObjectPlacementStrategy는 시작과 동시에 사실상 한 번의 선택을 완료하는 구조를 갖고 있으며, WallPlacementStrategy는 벽 경로의 시작점을 기록해야 한다.
즉, 선택 시작이라는 행위는 같지만 내부 상태 준비는 전략마다 다르다. 그래서 추상 메서드로 강제하고 있다.
이 설계는 매우 자연스럽다.
추상 계층은 공통 흐름만 정의하고, 시작 지점 처리 같은 전략별 차이는 하위 클래스에 맡긴다.
3.7. 회전 처리 전략
public virtual Quaternion HandleRotation(Quaternion rotation, SelectionData selectionData)
{
return rotation;
}HandleRotation은 가상 메서드로 제공된다.
여기서 virtual이 중요한 이유는, 회전 처리 규칙이 전략마다 다르기 때문이다.
어떤 구조물은 90도 단위로 자유롭게 회전할 수 있지만, 어떤 구조물은 길쭉한 크기 때문에 특정 방향만 허용해야 할 수 있다.
예를 들어 FreeObjectPlacementStrategy는 정사각형이 아닌 오브젝트에 대해 0도와 90도만 허용하도록 회전을 보정한다.
하지만 모든 전략이 그런 보정이 필요한 것은 아니므로, 기본 구현은 그냥 입력된 회전을 그대로 돌려주게 해 둔다.
이 구조의 장점은 공통 인터페이스를 유지하면서도 필요할 때만 특정 전략이 회전 규칙을 재정의할 수 있다는 점이다.
단점은 하위 클래스가 많아질수록 어떤 전략이 회전을 override하는지 계속 따라가야 한다는 점인데, 현재처럼 회전 규칙이 중요한 도메인에서는 충분히 감수할 가치가 있다.
3.8. 배치 검증 규칙 추상화
protected abstract bool ValidatePlacement(SelectionData selectionData);ValidatePlacement는 보호 수준이 protected인 추상 메서드다.
이 메서드는 외부에서 직접 호출되는 API가 아니라, 전략 내부에서 선택 결과가 유효한지를 계산하는 핵심 함수다.
protected로 선언한 이유는 하위 클래스는 반드시 구현해야 하지만, 외부 시스템이 이 검증 함수를 직접 호출할 필요는 없기 때문이다.
외부는 PlacementSelector나 BuildingState를 통해 결과만 받으면 되고, 검증 세부 구현은 전략 내부에 숨겨져 있어야 캡슐화가 유지된다.
또한 이 함수가 bool을 반환하는 것도 중요하다.
검증 결과는 단순히 성공/실패 한 가지 값으로 표현되며, 그 결과가 selectionData.PlacementValidity에 저장되어 이후 Preview 색상이나 PlacementManager 실행 여부 판단에 사용된다.
예를 들어 NearWall 객체는 벽 근처인가까지 확인해야 하고, InWall 객체는 기존 벽 위에 있는지를 검사해야 하며, 제거 전략들은 대부분 이 함수를 return true;로 바꿔서 선택만 허용한다.
같은 인터페이스를 유지하면서도, 상태와 타입에 따라 완전히 다른 검증 규칙을 유연하게 넣을 수 있게 해 주는 메서드다.
4. 개발 의도
SelectionStrategy의 핵심 설계 의도는, 건축 시스템에서 가장 복잡해지기 쉬운 선택 해석 규칙을 공통 흐름과 구체 구현으로 분리하는 것이다.
선택의 시작, 진행, 종료, 회전, 유효성 검증은 모든 배치/제거 시스템에서 공통으로 존재하지만, 실제 규칙은 배치 타입마다 완전히 다르다.
그래서 이 클래스는 공통 의존성과 공통 흐름만 정의하고, 구체적인 계산은 하위 전략에게 맡기도록 설계되었다.
특히 ModifySelection의 bool 반환, RefreshSelection의 공통 유효성 갱신, HandleRotation의 기본 구현과 override 가능 구조, ValidatePlacement의 protected 추상화는 모두 전략은 달라도 시스템 인터페이스는 하나로 유지한다는 의도를 반영한다.
이 덕분에 PlacementSelector와 BuildingState는 현재 전략이 바닥 선택인지 벽 선택인지 몰라도 같은 방식으로 호출할 수 있고, 새로운 배치 타입이 추가되더라도 상위 계층을 거의 건드리지 않고 확장할 수 있다.
결과적으로 SelectionStrategy는 단순한 추상 클래스가 아니라, 건축 선택 시스템 전체를 확장 가능하게 만드는 핵심 기반 계층으로 설계되었다.
