입력을 SelectionResult로 변환하는 구조
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서 플레이어의 입력은 단순히 마우스 위치를 읽는 것으로 끝나지 않는다.
플레이어가 마우스를 누르고, 움직이고, 놓는 과정은 각각 선택의 시작, 선택의 수정, 선택의 완료라는 다른 의미를 가진다.
그런데 이 의미는 항상 동일하지 않다.
같은 마우스 입력이라도 현재 상태가 바닥 배치인지, 벽 배치인지, 제거 모드인지, 혹은 이동 모드인지에 따라 해석 방식이 달라진다.
이 때문에 선택 처리 시스템은 입력 좌표를 그대로 사용하는 구조가 아니라, 현재 선택 전략이 무엇인지에 따라 입력을 다르게 해석하는 구조를 가져야 한다.
또한 이 선택 과정에서 생성되는 데이터는 단순 좌표 하나가 아니라, 선택된 Grid 좌표들, 월드 좌표들, 프리뷰 좌표들, 회전 정보, 배치 가능 여부처럼 이후 실행 계층이 바로 사용할 수 있는 형태로 정리되어야 한다.
여기서 중요한 점은 PlacementManager가 이러한 세부적인 선택 계산을 직접 담당하면 안 된다는 것이다.
PlacementManager는 전체 흐름을 관리하는 상위 컨트롤러이기 때문에, 선택 계산까지 직접 맡게 되면 책임이 과도하게 커지고 상태별 선택 방식이 뒤섞이게 된다.
따라서 선택 계산은 SelectionStrategy가 담당하고, PlacementSelector는 그 전략과 SelectionData를 연결하여 결과를 외부로 전달하는 중간 계층으로 동작해야 한다.
즉 PlacementSelector는 단순히 마우스 입력을 받아주는 클래스가 아니라, 선택 전략을 캡슐화한 구조를 유지하면서도 그 결과를 SelectionResult 형태로 외부 시스템에 안전하게 전달하는 연결 계층이어야 한다.
이 시스템은 선택의 시작, 진행, 회전, 완료, 초기화 흐름을 통합적으로 관리하고, 전략 객체와 PlacementManager 사이의 결합도를 낮추는 역할을 수행해야 한다.
2. 흐름도
마우스 입력
↓
PlacementManager
↓
PlacementSelector
↓
SelectionStrategy.StartSelection / ModifySelection / FinishSelection
↓
SelectionData 갱신
↓
SelectionResult 생성
↓
OnSelectionChanged / OnSelectionFinished 이벤트 전달
↓
PlacementManager
이 시스템의 핵심 흐름은 선택 전략을 직접 실행하고, 그 결과를 외부로 전달한다는 점이다.
PlacementManager는 입력이 발생했다는 사실만 알고 PlacementSelector를 호출한다.
PlacementSelector는 현재 연결된 SelectionStrategy에 선택 시작, 선택 변경, 선택 종료를 알리고, 그 전략이 SelectionData를 갱신하도록 만든다.
그리고 갱신된 SelectionData를 SelectionResult로 변환해 OnSelectionChanged 또는 OnSelectionFinished 이벤트로 외부에 전달한다.
이 구조의 장점은 매우 분명하다.
선택 계산은 전략 객체가 담당하고, 결과 전달은 PlacementSelector가 담당하므로, 선택 계산 로직과 시스템 연결 로직이 서로 분리된다.
결과적으로 PlacementManager는 선택 계산 내부를 몰라도 되고, SelectionStrategy는 PlacementManager를 몰라도 된다.
PlacementSelector는 이 둘 사이를 이어주는 어댑터이자 중간 조정 계층처럼 동작한다.
3. 구현
3.1. 기본 구조와 책임 분리
public class PlacementSelector
{
SelectionData selectionData;
SelectionStrategy selectionStrategy;
public SelectionStrategy CurrentSelectionStrategy => selectionStrategy;
public event Action<SelectionResult> OnSelectionChanged, OnSelectionFinished;
}PlacementSelector의 필드 구성만 봐도 이 클래스의 책임이 드러난다.
selectionData는 현재 선택 상태를 저장하는 데이터 컨테이너이고, selectionStrategy는 입력을 실제 선택 규칙으로 해석하는 전략 객체이다.
그리고 OnSelectionChanged, OnSelectionFinished 이벤트는 계산된 결과를 외부 시스템으로 전달하는 출구 역할을 한다.
이 구조에서 중요한 것은 PlacementSelector가 직접 선택 좌표를 계산하지 않는다는 점이다.
실제 계산은 SelectionStrategy가 담당하고, PlacementSelector는 그 전략이 사용할 데이터와 외부 알림 구조를 묶어주는 역할을 한다.
즉 이 클래스는 계산기라기보다 연결기다.
또한 CurrentSelectionStrategy를 프로퍼티 형태로 외부에 노출하고 있는데, 이는 현재 어떤 전략이 붙어 있는지를 외부에서 조회할 수 있도록 하기 위한 읽기 전용 인터페이스다.
C#의 프로퍼티는 필드를 직접 public으로 노출하는 것보다 캡슐화에 유리하고, 이후 구현을 바꿔도 외부 사용 코드를 덜 깨뜨리는 장점이 있다.
여기서는 단순 getter이지만, 현재 선택 전략을 확인해야 하는 디버깅이나 상태 확인 코드에서 유용하게 쓰일 수 있다.
3.2. 생성자와 의존성 주입
public PlacementSelector(SelectionStrategy placementStrategy, SelectionData selectionData)
{
this.selectionStrategy = placementStrategy;
this.selectionData = selectionData;
}이 생성자는 PlacementSelector가 어떤 전략과 어떤 데이터 컨테이너를 사용할지 외부에서 주입받도록 설계되어 있다.
이 방식은 매우 중요하다.
만약 PlacementSelector 내부에서 직접 new를 사용해 SelectionStrategy나 SelectionData를 생성해버리면, 이 클래스는 특정 구현에 강하게 고정되고 재사용성이 떨어진다.
반면 생성자 주입 방식을 사용하면, PlacementSelector는 무슨 전략인지를 몰라도 된다.
단지 SelectionStrategy 인터페이스 또는 추상 타입을 만족하는 객체를 받아서 호출만 하면 된다.
이것은 의존성 역전 관점에서도 좋은 구조다.
상위 로직은 구체 구현이 아니라 추상화에 의존하게 되며, 상태별로 다른 전략을 갈아 끼우기 쉬워진다.
C# 생성자를 통한 의존성 주입의 장점은 객체 생성 시점에 필수 의존성을 명확히 보장할 수 있다는 점이다.
단점은 생성자 인자가 많아질수록 객체 생성 코드가 복잡해질 수 있다는 점인데, 현재 클래스는 전략과 데이터 두 개만 받기 때문에 오히려 명확성이 더 크다.
3.3. 선택 시작 처리
public void HandleSelectionStarted(Vector3 mousePosition)
{
selectionData.Clear();
selectionStrategy.StartSelection(mousePosition, selectionData);
SelectionResult data = selectionData.GetData();
if(data.selectedGridPositions.Count > 0)
OnSelectionChanged?.Invoke(data);
}이 함수는 선택의 시작점을 처리한다.
플레이어가 마우스를 눌렀을 때 PlacementManager가 이 메서드를 호출하게 되고, PlacementSelector는 먼저 기존 선택 데이터를 초기화한다.
selectionData.Clear()를 가장 먼저 호출하는 이유는 이전 선택 상태를 완전히 비운 뒤 새 선택을 시작하기 위해서다.
선택 시스템은 현재 진행 중인 선택을 기준으로 동작해야 하는데, 이전 프레임이나 이전 입력의 흔적이 남아 있으면 새 선택이 오염될 수 있다.
따라서 시작 시점에서 상태를 리셋하는 것은 필수적이다.
그 다음 selectionStrategy.StartSelection(mousePosition, selectionData)를 호출한다.
이 호출은 현재 마우스 위치를 기준으로 선택을 시작하라는 명령을 전략 객체에 전달하는 것이다.
여기서 PlacementSelector는 좌표를 어떻게 Grid로 바꿀지, 어떤 셀을 선택할지 전혀 모른다.
이런 로직은 전략 객체 안에 있어야 하므로, PlacementSelector는 그저 입력과 데이터를 넘겨주는 역할만 수행한다.
이후 selectionData.GetData()를 통해 SelectionResult를 얻는다.
여기서 중요한 점은 내부 상태를 그대로 밖으로 넘기지 않고, 정리된 결과 구조를 생성해 전달한다는 것이다.
이미 네가 정리한 SelectionData 구조를 보면, 이 객체는 내부 리스트들의 복사본을 만들어 SelectionResult를 생성하도록 되어 있다.
이런 방식은 외부 시스템이 내부 리스트를 직접 수정해 선택 상태를 망가뜨리는 일을 막아준다.
즉, PlacementSelector는 선택 중간 상태를 직접 노출하지 않고, 결과 형태로만 바깥에 내보낸다.
마지막으로 selectedGridPositions.Count > 0일 때만 OnSelectionChanged를 호출한다.
이 조건은 빈 선택을 외부에 알리지 않기 위한 필터다.
특히 벽 배치처럼 특정 상황에서는 전략이 아직 유효한 선택 결과를 만들지 못할 수 있는데, 그런 상태까지 무조건 외부에 전달하면 PlacementManager나 Preview 시스템이 불필요하게 반응할 수 있다.
따라서 이 조건은 이벤트 노이즈를 줄이는 역할을 한다. ?.Invoke는 C#의 null 조건부 호출 문법으로, 구독자가 없을 때 예외 없이 호출을 건너뛸 수 있게 해준다.
이벤트 시스템에서 매우 자주 쓰이는 안전한 호출 방식이다.
3.4. 선택 완료 처리
public void HandleSelectionFinished()
{
SelectionResult data = selectionData.GetData();
if(data.selectedGridPositions.Count>0)
OnSelectionFinished?.Invoke(data);
ResetSelection();
}이 함수는 선택을 완료하고 결과를 확정하는 역할을 한다. 플레이어가 마우스를 놓는 순간 호출되는 메서드라고 보면 된다.
여기서는 먼저 selectionData.GetData()를 통해 현재까지 누적된 선택 상태를 SelectionResult로 정리한다.
선택 완료 시점에서 이 결과는 이후 배치 실행이나 제거 실행으로 넘어가는 최종 입력 데이터가 된다.
그 다음 selectedGridPositions.Count > 0일 때만 OnSelectionFinished를 호출한다.
이 조건은 앞선 시작 처리와 비슷하지만, 의미가 더 중요하다.
완료 이벤트는 실제 실행 계층으로 이어지는 신호이기 때문에, 잘못된 빈 데이터가 전달되면 배치 명령이 생성되거나 제거 로직이 호출되는 등 더 큰 문제로 이어질 수 있다.
따라서 유효한 Grid 선택이 존재할 때만 완료 이벤트를 보내도록 막아두었다.
완료 이벤트를 보낸 뒤에는 ResetSelection()을 호출한다.
이 함수는 선택 데이터를 비우고, 전략 객체에도 선택 종료를 알린다.
즉 선택 완료는 단순히 결과를 보내는 것으로 끝나지 않고, 다음 입력을 받을 준비까지 포함한다.
이 흐름이 있어야 같은 시스템이 연속해서 여러 번 선택을 받아도 이전 상태가 남지 않는다.
3.5. 마우스 이동 처리
public void HandleMouseMovement(Vector3 mousePosition)
{
if (selectionStrategy.ModifySelection(mousePosition, selectionData))
{
SelectionResult data = selectionData.GetData();
OnSelectionChanged?.Invoke(data);
}
}이 함수는 선택이 진행되는 동안 마우스 이동에 따라 선택 상태를 수정하는 역할을 한다.
PlacementManager나 상태 객체가 마우스 이동을 감지하면 현재 위치를 이 함수에 전달하게 된다.
핵심은 ModifySelection의 반환값을 사용한다는 점이다.
이 메서드는 단순히 선택을 수정하는 것뿐 아니라, 실제로 선택 결과가 바뀌었는지 여부를 bool로 반환한다.
즉, 변화가 있었을 때만 true를 반환하고, 의미 있는 변경이 없으면 false를 반환한다.
이 구조를 쓴 이유는 성능과 이벤트 노이즈 때문이다.
마우스 이동은 프레임마다 발생할 수 있으므로, 변화가 없는데도 매번 OnSelectionChanged를 쏘면 프리뷰 업데이트나 검증 로직이 불필요하게 반복된다.
특히 건축 시스템에서는 프리뷰 오브젝트 이동, 배치 가능 여부 검사, 색상 변경 같은 작업이 연결되어 있을 가능성이 높기 때문에, 변화가 있을 때만 이벤트를 보내는 구조가 효율적이다.
그 다음 선택 결과를 다시 꺼내서 OnSelectionChanged로 보낸다.
여기서도 PlacementSelector는 직접 프리뷰를 움직이지 않고, 결과만 외부로 전달한다.
즉, 이 클래스는 선택의 해석과 전달까지만 맡고, 시각적 반영은 다른 시스템이 맡는다.
이런 분리가 유지보수에 매우 유리하다.
3.6. 회전 처리
public void HandleRotation(Quaternion rotationAmount)
{
selectionData.Rotation *= rotationAmount;
selectionData.Rotation = selectionStrategy.HandleRotation(selectionData.Rotation, selectionData);
Refresh();
Debug.Log(selectionData.Rotation.eulerAngles);
}이 함수는 플레이어가 회전 입력을 했을 때 선택 데이터의 회전 상태를 갱신하는 역할을 한다.
예를 들어 , 또는 . 키 입력에 따라 PlacementManager가 이 메서드를 호출할 수 있다.
먼저 selectionData.Rotation *= rotationAmount;가 실행된다.
여기서 Quaternion은 Unity에서 회전을 표현하는 기본 타입이다.
오일러 각도보다 기하학적으로 안정적이고 짐벌락 문제를 줄일 수 있어서, Unity 내부 회전 계산에서는 일반적으로 Quaternion을 사용한다.
이 줄은 기존 회전에 새로운 회전량을 곱해 누적 회전을 만드는 처리다.
그 다음 줄은 더 중요하다.
selectionData.Rotation = selectionStrategy.HandleRotation(selectionData.Rotation, selectionData);이 호출은 회전값을 단순히 누적하는 데서 끝나지 않고, 현재 전략에 맞는 방식으로 보정하도록 한다.
예를 들어 어떤 전략은 90도 단위 회전만 허용해야 할 수도 있고, 어떤 전략은 벽 방향에 맞게 특정 각도로 스냅되어야 할 수 있다.
이런 제약은 전략 객체가 가장 잘 알고 있기 때문에, 최종 회전 해석은 전략에 맡기는 것이 맞다.
이후 Refresh 함수를 호출한다.
회전은 단순히 시각적 방향만 바꾸는 것이 아니라, 선택된 Grid 범위나 배치 가능 여부까지 바꿀 수 있다.
특히 크기가 1x1이 아닌 구조물의 경우, 회전에 따라 점유 셀이 달라지므로 현재 선택 상태를 다시 계산해야 한다.
그래서 회전 처리 직후에는 무조건 새로고침이 필요하다.
3.7. 선택 새로고침 처리
internal void Refresh()
{
if (selectionData.GetSelectedGridPositions().Count <= 0)
return;
selectionStrategy.RefreshSelection(selectionData);
OnSelectionChanged?.Invoke(selectionData.GetData());
}이 함수는 현재 선택 상태를 강제로 다시 계산해야 할 때 사용된다.
가장 대표적인 경우가 회전 처리 이후다.
먼저 현재 선택된 Grid가 하나도 없으면 아무 작업도 하지 않는다.
선택이 없는 상태에서는 새로고침 자체가 의미 없기 때문이다.
그 다음 selectionStrategy.RefreshSelection(selectionData)를 호출한다.
이 메서드는 현재 저장된 선택 데이터와 회전 상태를 바탕으로, 전략 객체가 선택 결과를 다시 계산하도록 하는 역할을 한다.
즉 마우스를 다시 움직이지 않아도, 현재 상태 변화만으로 선택 결과를 재산출할 수 있게 만드는 것이다.
마지막으로 갱신된 결과를 OnSelectionChanged로 내보낸다.
이 흐름 덕분에 회전이나 검증 상태 변화처럼 입력 좌표는 그대로지만 선택 의미만 바뀌는 상황도 외부 시스템이 즉시 반영할 수 있다.
예를 들어 프리뷰 방향이 바뀌거나 배치 가능 여부 색상이 달라져야 하는 경우에 이 함수가 핵심 역할을 한다.
3.8. 선택 초기화 처리
public void ResetSelection()
{
selectionData.Clear();
selectionStrategy.FinishSelection(selectionData);
}이 함수는 선택을 완전히 종료하고 상태를 비우는 역할을 한다. HandleSelectionFinished 마지막에서 호출되기도 하고, 외부에서 강제로 선택을 취소해야 할 때도 사용할 수 있다.
먼저 selectionData.Clear()를 호출해 내부에 저장된 Grid 좌표, 월드 좌표, 프리뷰 좌표, 회전 정보, 유효성 정보 등을 모두 지운다.
이것은 단순한 데이터 삭제가 아니라, 다음 선택을 받을 준비를 하는 리셋 작업이다.
그 다음 selectionStrategy.FinishSelection(selectionData)를 호출한다.
이 호출은 전략 객체에도 선택이 끝났다는 사실을 알려주는 역할을 한다.
전략에 따라 내부 임시 상태를 정리하거나 선택 종료 시 필요한 후처리를 수행할 수 있기 때문에, 단순히 데이터만 비우는 것으로 끝내지 않고 전략 종료까지 함께 호출한다.
이 구조는 책임 분리 측면에서 중요하다.
선택 데이터는 PlacementSelector가 지우지만, 전략 자체의 종료 동작은 전략이 알아서 처리하게 만든다.
이렇게 해야 전략별 부가 상태가 있더라도 PlacementSelector가 구체 구현을 알 필요가 없다.
4. 개발 의도
PlacementSelector의 핵심 설계 의도는 선택 계산 자체보다도, 선택 전략과 선택 결과 전달을 분리하는 것에 있다.
건축 시스템에서는 배치, 제거, 이동처럼 상태마다 같은 입력을 다르게 해석해야 한다.
이 로직을 PlacementManager 같은 상위 컨트롤러에 직접 넣으면 선택 계산이 상태별로 뒤섞이고, 클래스 책임이 급격히 커진다.
반대로 전략 객체만 두고 외부 전달 구조를 따로 만들지 않으면, 각 전략이 PlacementManager와 직접 연결되어 결합도가 높아진다.
그래서 PlacementSelector는 전략 객체가 선택을 계산하도록 두고, 자신은 그 결과를 SelectionResult로 변환해 외부 이벤트로 내보내는 중간 계층으로 설계되었다.
이 구조 덕분에 선택 계산 로직은 전략 안에 남고, 시스템 연결 로직은 PlacementSelector가 맡게 된다.
또한 회전, 새로고침, 초기화 같은 흐름을 이 클래스에 모아둠으로써 선택 시스템 전체의 생명주기를 한 곳에서 관리할 수 있도록 했다.
결과적으로 PlacementSelector는 단순한 헬퍼 클래스가 아니라, 선택 전략을 시스템 흐름 속에 안전하게 연결하는 핵심 조정 계층으로 설계되었다.
