건축 상태 시스템의 공통 추상화
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서는 플레이어의 입력이 항상 같은 의미를 가지지 않는다.
같은 마우스 클릭이라도 현재 상태가 배치 모드인지, 제거 모드인지, 이동 모드인지에 따라 전혀 다른 결과로 해석된다.
배치 모드에서는 선택된 위치를 기준으로 구조물을 설치해야 하고, 제거 모드에서는 그 위치에 존재하는 구조물을 찾아 제거 대상으로 지정해야 하며, 이동 모드에서는 기존 구조물을 선택해 재배치 흐름으로 연결해야 한다.
이런 차이를 단순히 if 문으로 계속 분기해서 처리하기 시작하면 PlacementManager가 모든 상태를 직접 알고 있어야 하고, 입력 처리와 실행 로직이 한 클래스 안에 과도하게 몰리게 된다.
또한 건축 시스템은 단순히 입력을 받아서 즉시 실행하는 구조가 아니라, 선택의 시작, 선택의 변경, 선택의 완료, 회전, 선택 새로고침처럼 여러 단계의 흐름을 가진다.
이 흐름은 배치 상태와 제거 상태에서 거의 동일하게 존재하지만, 실제로 사용하는 선택 전략과 데이터 해석 방식은 달라진다.
따라서 시스템은 공통 흐름을 하나의 추상 계층으로 묶고, 구체적인 차이는 하위 상태 클래스가 담당하도록 설계될 필요가 있다.
즉, 상태마다 완전히 다른 클래스를 만드는 것이 아니라, 건축 상태라면 반드시 제공해야 하는 공통 인터페이스를 먼저 정의하고, 그 위에 배치 상태와 제거 상태가 올라가는 구조가 필요하다.
BuildingState는 바로 이 요구를 만족시키기 위해 존재하는 추상 클래스이다.
이 클래스는 GridManager, GridData, ItemData, SelectionData, PlacementSelector 같은 공통 구성요소를 상태 내부에 묶어 두고, HandleSelectionStarted, HandleSelectionChanged, HandleSelectionFinished, HandleRotation, RefreshSelection 같은 공통 흐름을 정의한다.
즉, BuildingState는 실제 배치를 수행하는 클래스가 아니라, 건축 상태라면 어떤 입력을 어떻게 받아야 하는지를 공통 인터페이스와 공통 데이터 구조 차원에서 정리하는 기반 계층이라고 볼 수 있다.
2. 흐름도
PlacementManager
↓
현재 BuildingState
↓
HandleSelectionStarted / Changed / Finished
↓
PlacementSelector
↓
SelectionStrategy
↓
SelectionData 갱신
↓
SelectionResult 생성
↓
OnSelectionChanged / OnFinished 이벤트 전달
↓
PlacementManager
BuildingState의 흐름은 입력을 직접 처리한다기보다 입력을 선택 시스템으로 전달하고, 그 결과를 다시 외부로 돌려보낸다는 구조에 가깝다.
PlacementManager는 현재 활성화된 상태 객체를 들고 있다가, 마우스 눌림, 이동, 놓기, 회전 입력이 발생하면 그 입력을 현재 BuildingState로 넘긴다.
BuildingState는 내부적으로 PlacementSelector를 사용해 SelectionStrategy를 실행하고, SelectionData를 갱신하게 만든다.
이후 PlacementSelector가 SelectionResult를 생성하여 OnSelectionChanged 또는 OnFinished 이벤트로 결과를 내보내면, BuildingState는 그 이벤트를 다시 PlacementManager 쪽으로 전달한다.
이 구조의 핵심은 BuildingState가 선택 계산을 직접 하지 않는다는 점이다.
선택 계산은 SelectionStrategy가 담당하고, BuildingState는 상태 공통 흐름을 조정하는 역할만 맡는다.
이렇게 해야 배치 상태든 제거 상태든 동일한 입력 흐름을 유지하면서도, 실제 선택 규칙은 상태에 따라 달라질 수 있다.
다시 말해 BuildingState는 상태 패턴의 기반 계층이면서 동시에 선택 시스템을 외부에 노출하는 어댑터 역할도 수행한다.
3. 구현
3.1. 추상 클래스 구조와 공통 필드
public abstract class BuildingState
{
protected PlacementGridData currentPlacementData;
public PlacementGridData CurrentPlacementData => currentPlacementData;
protected GridData gridData;
protected GridManager gridManager;
protected ItemData ItemData;
protected PlacementSelector placementSelection;
protected SelectionData selectionData;
public SelectionData SelectionData => selectionData;
public Action<SelectionResult> OnFinished, OnSelectionChanged;
...
}이 클래스가 abstract로 선언되어 있다는 점이 가장 먼저 중요하다.
C#의 추상 클래스는 직접 인스턴스를 만들 수 없고, 반드시 하위 클래스가 상속해서 사용해야 한다.
즉, BuildingState는 배치 상태나 제거 상태 같은 구체적인 상태를 직접 표현하지 않고, 그 상태들이 공통으로 가져야 할 필드와 동작을 미리 정리해 두는 기반 클래스 역할을 한다.
인터페이스로도 비슷한 역할을 할 수 있지만, BuildingState는 단순한 규약만 필요한 것이 아니라 공통 필드와 공통 구현까지 함께 가지고 있어야 하므로 추상 클래스가 더 적합하다.
인터페이스는 계약만 강제하는 데 유리하지만 상태 데이터나 기본 동작을 넣기에는 불리하고, 추상 클래스는 공통 코드 재사용이 가능하다는 장점이 있다.
반면 단점은 단일 상속 제약이 있다는 점인데, 현재 구조에서는 상태 클래스가 다른 기반 클래스를 상속할 필요가 없기 때문에 문제가 되지 않는다.
필드 구성을 보면 이 클래스의 책임이 분명하게 드러난다.
gridManager는 월드 좌표와 Grid 좌표 사이의 변환을 담당하는 시스템이고, gridData는 현재 맵에 배치된 구조물의 논리 상태를 저장하는 데이터 계층이다.
ItemData는 현재 다루는 구조물의 타입, 크기, 배치 타입 같은 메타데이터를 제공하고, placementSelection은 선택 전략과 선택 데이터를 연결하는 중간 계층이다.
selectionData는 진행 중인 선택의 가변 상태를 담고 있으며, 외부에는 SelectionData 프로퍼티로 읽기만 가능하게 노출된다.
이때 protected를 사용한 이유는 하위 상태 클래스에서 직접 접근할 수 있도록 하기 위해서다.
private로 감추면 하위 클래스가 상태별 전략 연결을 수행하기 어렵고, public으로 열어두면 외부 시스템이 상태 내부를 직접 수정할 위험이 커진다.
따라서 상속 구조에서는 protected가 가장 자연스러운 선택이다.
CurrentPlacementData와 SelectionData를 프로퍼티로 노출한 것도 의미가 있다.
필드를 그대로 public으로 노출하는 대신 읽기 전용 접근 지점을 만들면, 외부는 값을 가져갈 수는 있어도 함부로 다른 객체를 대입할 수 없다.
특히 CurrentPlacementData는 PlacementManager가 현재 상태가 어떤 PlacementGridData를 사용하고 있는지 알아야 할 때 필요하고, SelectionData는 이동 시스템이나 회전 유지 처리처럼 현재 선택 상태를 읽어야 할 때 사용된다.
이런 프로퍼티 구조는 캡슐화를 유지하면서도 시스템 간 협력을 가능하게 해 주는 절충점이다.
마지막의 Action<SelectionResult> OnFinished, OnSelectionChanged는 상태가 외부와 통신하는 통로다.
C#의 Action<T>는 반환값이 없는 delegate 타입으로, 특정 타입의 인자를 전달하는 콜백을 저장할 수 있다.
여기서는 SelectionResult를 인자로 전달하므로, 상태 내부에서 선택이 바뀌거나 선택이 완료되었을 때 외부 시스템이 즉시 반응할 수 있다.
이벤트 키워드 대신 Action 필드 형태를 사용했기 때문에 외부에서 직접 대입할 가능성이 있다는 점은 단점일 수 있지만, 현재 구조에서는 PlacementManager가 상태 생성 직후 연결하고 관리하는 형태라서 실용적으로 사용하고 있다.
이 필드들이 중요한 이유는 BuildingState가 실행 계층이 아니라 결과를 전달하는 중간 계층이라는 사실을 보여주기 때문이다.
3.2. 생성자와 상태 의존성 주입
public BuildingState(GridManager gridManager, GridData gridData, ItemData itemData)
{
this.gridManager = gridManager;
this.gridData = gridData;
this.ItemData = itemData;
}이 생성자는 BuildingState가 동작하기 위해 반드시 필요한 세 가지 의존성을 주입받는다.
GridManager는 좌표 변환과 Grid 기준 계산에 필요하고, GridData는 현재 맵 상태를 읽거나 갱신할 때 필요하며, ItemData는 현재 상태가 다루는 구조물의 속성을 제공한다.
이 셋은 어떤 하위 상태 클래스이든 공통으로 필요하기 때문에 상위 추상 클래스 생성자에서 먼저 받도록 한 것이다.
이 방식은 의존성 주입 관점에서 좋은 설계다. BuildingState가 내부에서 직접 new GridData(...) 같은 식으로 객체를 만들지 않기 때문에, 상위 컨트롤러가 어떤 Grid 시스템을 연결할지 명확하게 결정할 수 있다.
장점은 테스트와 재사용성이 높아진다는 점이고, 단점은 생성자 인자가 많아질수록 객체 생성이 복잡해질 수 있다는 점이다.
하지만 현재는 상태가 반드시 알아야 하는 최소 구성만 받고 있어, 오히려 어떤 데이터 위에서 상태가 동작하는지 명확하게 드러난다.
하위 클래스인 PlacingObjectsState와 RemovingState도 모두 이 생성자를 호출한 뒤 자신의 상태 전용 전략을 설정하는 구조를 갖고 있다.
즉, BuildingState 생성자는 상태 계층 전체의 공통 초기화 단계라고 볼 수 있다.
3.3. PlacementSelector 이벤트 연결
protected void ConnectToPlacementSelection()
{
placementSelection.OnSelectionChanged += (selectionResult)=> OnSelectionChanged?.Invoke(selectionResult);
placementSelection.OnSelectionFinished += (selectionResult) => OnFinished?.Invoke(selectionResult);
}이 함수는 BuildingState와 PlacementSelector를 연결하는 핵심 메서드다.
PlacementSelector는 내부적으로 SelectionStrategy를 실행하고 SelectionResult를 이벤트로 내보내는데, BuildingState는 이 결과를 그대로 외부로 전달해 준다.
즉, BuildingState는 선택 시스템의 이벤트를 다시 상위 계층으로 중계하는 허브 역할을 수행한다.
코드를 보면 lambda 식을 사용하고 있다.
(selectionResult) => OnSelectionChanged?.Invoke(selectionResult)C#의 lambda 식은 짧은 익명 함수를 만드는 문법이다.
별도 메서드를 만들지 않고도 이 값이 들어오면 저 값을 호출한다는 단순 연결 로직을 매우 간결하게 표현할 수 있다.
여기서는 PlacementSelector의 결과를 BuildingState의 콜백으로 그대로 전달하는 역할만 필요하기 때문에 lambda가 적절하다.
장점은 코드가 짧고 의도가 명확하다는 점이고, 단점은 로직이 길어지면 가독성이 떨어진다는 점이다.
하지만 현재처럼 단순 전달일 때는 가장 자연스럽다.
또한 ?.Invoke가 사용되고 있는데, 이는 null 조건부 연산자다.
C#에서 delegate나 Action을 호출할 때 구독자가 없으면 null일 수 있으므로, 그대로 호출하면 예외가 발생한다.
?.Invoke를 사용하면 구독자가 있을 때만 안전하게 호출하고, 없으면 아무 일도 하지 않는다.
이벤트 기반 구조에서는 거의 필수적인 안전장치라고 볼 수 있다.
이 함수의 설계적 의미는 더욱 중요하다.
하위 상태 클래스들은 자신에게 맞는 SelectionStrategy를 가진 PlacementSelector를 생성한 뒤, 마지막에 ConnectToPlacementSelection 함수를 호출한다.
즉, 상태마다 전략은 다르지만, 결과 전달 방식은 모두 동일한 것이다.
이 덕분에 PlacementManager는 현재 상태가 배치 상태인지 제거 상태인지 몰라도 같은 방식으로 OnSelectionChanged, OnFinished를 받을 수 있다.
이게 바로 공통 추상화의 힘이다.
3.4. 선택 완료 처리
public virtual void HandleSelectionFinished()
=> placementSelection.HandleSelectionFinished();이 메서드는 현재 상태에서 선택 완료 입력이 들어왔을 때 호출된다.
구현은 매우 짧지만 의미는 작지 않다.
선택 완료 로직을 BuildingState가 직접 계산하지 않고 PlacementSelector에 위임하고 있다는 사실을 보여주기 때문이다.
PlacementSelector는 내부적으로 SelectionData를 읽고 SelectionResult를 만들며, 유효한 선택일 때만 OnSelectionFinished를 발생시킨다.
BuildingState는 이 메서드를 통해 그 과정을 트리거하는 역할만 맡는다.
여기서 virtual을 사용한 점도 중요하다.
C#의 virtual 메서드는 기본 구현을 제공하면서도 하위 클래스가 필요하면 재정의할 수 있게 해준다.
현재 BuildingState는 공통 흐름으로 PlacementSelector 호출만 수행하지만, 이후 어떤 특수 상태에서 선택 완료 전후에 추가 로직이 필요해진다면 override를 통해 확장할 수 있다.
장점은 기본 동작 재사용과 유연한 확장성을 동시에 얻을 수 있다는 점이고, 단점은 하위 클래스가 너무 많이 재정의하면 공통 흐름이 흐려질 수 있다는 점이다.
그러나 상태 패턴에서는 이런 유연성이 필요하므로 합리적인 선택이다.
또한 표현식 본문 메서드(=>)를 사용한 것도 눈에 띈다.
이 문법은 메서드 본문이 단일 표현식일 때 코드를 간결하게 표현할 수 있다.
코드 길이를 줄여주고 이 메서드는 단순 위임만 한다는 의도를 바로 보여주는 장점이 있다.
반면 복잡한 로직에는 오히려 읽기 불편할 수 있으므로, 현재처럼 한 줄 위임일 때만 쓰는 것이 적절하다.
여기서는 바로 그 전형적인 사용 사례다.
3.5. 선택 시작 처리
public virtual void HandleSelectionStarted(Vector3 selectedMapPosition)
=> placementSelection.HandleSelectionStarted(selectedMapPosition);이 메서드는 마우스를 누르는 순간과 같이 선택의 시작점이 결정되는 상황에서 호출된다.
외부에서 전달받는 selectedMapPosition은 이미 InputManager와 GridManager를 거쳐 선택용 월드 좌표로 해석된 값이다.
BuildingState는 이 좌표를 직접 Grid로 변환하거나 선택 영역을 계산하지 않고, PlacementSelector에 전달하기만 한다.
즉, 상태 객체는 입력 흐름의 중간 계층이지, 좌표 계산 알고리즘의 본체가 아니다.
그 본체는 SelectionStrategy에 있다.
이 메서드가 중요한 이유는 시작점 처리의 공통 인터페이스를 통일하기 때문이다.
배치 상태든 제거 상태든, 모두 선택을 시작한다는 행위는 존재한다.
하지만 실제로 무엇을 선택할지는 상태에 따라 다르다.
BuildingState는 그 공통 입구만 제공하고, 실제 의미 해석은 상태에 맞는 전략에 맡긴다.
구조적으로 보면 매우 간단하지만, 설계적으로는 상태 패턴과 전략 패턴이 만나는 지점을 만드는 핵심 메서드다.
여기서도 virtual과 표현식 본문 메서드가 사용되었다.
선택 시작 전에 추가 검사나 부수 효과가 필요한 특수 상태가 생기면 override가 가능하고, 현재 기본 구현은 단순 위임이라는 사실이 코드 길이 자체로 드러난다.
즉, 짧지만 의도가 아주 선명한 메서드라고 볼 수 있다.
3.6. 회전 처리
public virtual void HandleRotation(int modifier)
{
placementSelection.HandleRotation(Quaternion.Euler(0, 90 * modifier, 0));
placementSelection.Refresh();
}이 메서드는 건축 시스템에서 회전 입력이 들어왔을 때 현재 선택 상태의 회전 방향을 갱신하는 역할을 한다.
이 시스템은 회전을 0도, 90도, 180도, 270도 네 방향만 허용한다.
그래서 modifier를 -1 또는 1 같은 정수로 받아, 여기에 90을 곱해 회전량을 만든다.
Quaternion.Euler(0, 90 * modifier, 0)는 Unity에서 오일러 각도를 Quaternion으로 바꾸는 대표적인 함수다.
Unity 내부 회전 계산은 Quaternion을 기본으로 하기 때문에, Grid 시스템처럼 직교 회전만 필요하더라도 최종 전달은 Quaternion으로 하는 것이 자연스럽다.
Quaternion의 장점은 연속 회전 계산에서 안정적이고 짐벌락 문제를 줄인다는 점이지만, 단점은 사람이 각도를 직관적으로 읽기 어렵다는 점이다.
그래서 이 코드처럼 사람이 이해하기 쉬운 90도 단위를 먼저 만들고, 그 값을 Quaternion으로 변환하는 방식이 자주 쓰인다.
그 다음 placementSelection.HandleRotation(...)을 호출한다.
PlacementSelector는 내부적으로 SelectionData의 Rotation을 누적하고, 현재 SelectionStrategy가 필요하다면 회전을 스냅하거나 보정할 수 있다.
즉, BuildingState는 회전 입력을 발생시키는 역할만 하고, 실제 회전 반영은 선택 계층에 위임한다.
이후 placementSelection.Refresh()를 바로 호출하는 것도 매우 중요하다.
회전은 단순히 시각적 방향만 바꾸는 것이 아니라, 구조물이 차지하는 Grid 셀과 배치 가능 여부 자체를 바꿀 수 있다.
예를 들어 2x3 구조물은 90도 회전하면 점유 셀이 3x2가 된다.
따라서 회전 직후에는 현재 선택 결과를 다시 계산해야 한다.
이 코드는 바로 그 재계산을 강제하는 처리다.
만약 이 Refresh가 없다면 화면상 회전은 바뀌었는데, 유효성 판단이나 프리뷰 위치는 이전 값으로 남는 불일치가 발생할 수 있다.
즉, 이 메서드는 회전 입력과 선택 결과 재계산을 하나로 묶어 처리하는 중요한 연결 지점이다.
3.7. 선택 진행 처리
public virtual void HandleSelectionChanged(Vector3 selectedMapPosition)
=> placementSelection.HandleMouseMovement(selectedMapPosition);이 메서드는 선택이 진행되는 동안, 예를 들어 마우스를 누른 채로 움직이거나 프리뷰가 계속 갱신되어야 할 때 호출된다.
Update 루프 안에서 PlacementManager가 현재 마우스 위치를 상태 객체에 전달하면, BuildingState는 이를 PlacementSelector의 HandleMouseMovement로 넘긴다.
PlacementSelector는 현재 SelectionStrategy의 ModifySelection을 사용해 SelectionData를 수정하고, 실제 변화가 있었을 때만 OnSelectionChanged를 외부에 전달한다.
즉, 이 메서드는 선택 진행이라는 상태 공통 행위를 선택 계층으로 연결하는 통로다.
이 메서드가 따로 존재하는 이유는 선택 시스템이 단일 클릭만 처리하는 것이 아니라 진행 중인 선택을 실시간으로 추적해야 하기 때문이다.
배치 시스템에서는 프리뷰 위치가 마우스 이동에 따라 계속 변하고, 제거 시스템에서도 현재 제거 대상으로 선택될 수 있는 구조물이 계속 바뀔 수 있다.
따라서 시작과 완료 사이의 중간 단계가 독립적인 메서드로 존재해야 한다.
코드 길이는 짧지만, 역할은 굉장히 크다.
이 메서드가 있어야 상태 객체가 선택 중이라는 흐름을 갖게 된다
3.8. 선택 새로고침 처리
public void RefreshSelection()
=> placementSelection.Refresh();이 메서드는 외부 요인으로 인해 현재 선택 결과를 다시 계산해야 할 때 사용된다.
대표적인 예가 구조물 배치 또는 제거 직후다.
방금 오브젝트가 하나 추가되거나 사라졌다면 Grid 점유 상태가 바뀌었기 때문에, 같은 마우스 위치라도 더 이상 이전 선택 결과가 유효하지 않을 수 있다.
이때 BuildingState는 RefreshSelection 함수를 호출해 현재 선택을 재평가한다.
PlacementSelector의 Refresh 함수는 내부적으로 현재 SelectionData를 기준으로 SelectionStrategy의 RefreshSelection을 호출하고, 그 결과를 다시 OnSelectionChanged로 전달한다.
즉, 이 메서드는 단순 재호출이 아니라 현재 상태를 다시 해석하라는 의미를 가진다.
PlacementManager가 이 메서드를 호출하는 이유도 여기에 있다.
배치나 제거가 끝난 직후 프리뷰와 유효성 표시가 즉시 최신 상태를 반영해야 하기 때문이다.
여기서는 virtual이 아니라 일반 public 메서드로 선언되어 있는데, 이것도 의미가 있다.
회전이나 선택 시작/변경/완료는 상태별로 달라질 가능성이 있지만, 현재 선택을 다시 계산한다는 행위는 모든 상태에서 동일한 흐름으로 동작하는 것이 바람직하다.
그래서 재정의 포인트로 열어두지 않고, 공통 동작으로 고정해 둔 것이다.
이는 설계상 일관성을 강하게 유지하려는 의도라고 볼 수 있다.
4. 개발 의도
BuildingState의 핵심 설계 의도는 건축 시스템에서 상태별로 달라지는 선택 해석을 하나의 공통 틀 위에 올리는 것이다.
배치 상태, 제거 상태, 이동 상태는 모두 입력을 받고 선택 결과를 만든다는 점에서는 같지만, 실제로 사용하는 전략과 데이터 해석 방식은 다르다.
이 차이를 PlacementManager 안에서 직접 분기 처리하기 시작하면, 상위 컨트롤러가 모든 상태의 세부 구현을 알아야 하고 시스템 복잡도가 빠르게 증가한다.
BuildingState는 바로 그 문제를 줄이기 위해 만들어진 추상 계층이다.
이 클래스는 공통으로 필요한 GridManager, GridData, ItemData, SelectionData, PlacementSelector를 묶고, 선택 시작, 진행, 완료, 회전, 새로고침이라는 공통 흐름을 기본 구현 형태로 제공한다.
그 결과 하위 상태 클래스는 무슨 전략을 사용할지만 결정하면 되고, 선택 흐름 자체는 BuildingState가 일관되게 유지해 준다.
또한 PlacementSelector와의 이벤트 연결을 공통 메서드로 제공함으로써, 상태마다 달라지는 전략 구조를 유지하면서도 외부에서는 동일한 OnSelectionChanged, OnFinished 인터페이스만 바라보게 만들었다.
결과적으로 BuildingState는 단순한 부모 클래스가 아니라, 상태 패턴과 선택 전략 시스템을 연결하는 공통 기반 계층이다.
