선택 데이터 시스템
목차
1. 요구 사항
2. 흐름도
3. 구현
3.1. SelectionData의 전체 역할과 가변 데이터 컨테이너 구조
3.2. 생성자와 초기 상태 설정
3.3. Clear와 선택 상태 초기화
3.4. Add 계열 메서드와 선택 데이터 누적 구조
3.5. Get 계열 메서드와 복사본 반환 구조
3.6. Preview 좌표 fallback 구조
3.7. 회전 데이터 조회와 기본 회전 fallback
3.8. GetData와 SelectionResult 변환
3.9. 회전 설정 메서드
3.10. SelectionResult의 의미와 struct 선택 이유
4. 개발 의도
1. 시스템 요구 사항
건축 시스템에서 플레이어의 입력은 단순한 마우스 좌표로 끝나지 않는다.
플레이어가 마우스를 움직이고, 클릭하고, 드래그하는 동안 시스템 내부에서는 현재 선택된 Grid 좌표, 월드 좌표, 프리뷰 위치, 회전 정보, 배치 가능 여부 같은 여러 데이터가 계속 갱신된다.
이 데이터는 선택 중에는 계속 바뀌는 상태여야 하지만, 어떤 시점에서는 더 이상 바뀌지 않는 결과 데이터 형태로 고정되어 다른 시스템에 전달되어야 한다.
예를 들어 SelectionStrategy는 선택이 진행되는 동안 현재 선택 범위를 계속 수정해야 하고, PlacementPreview는 그 값을 받아 프리뷰를 움직여야 하며, PlacementManager와 Command 시스템은 최종 선택 결과를 기반으로 실제 배치를 실행해야 한다.
이때 하나의 객체를 계속 공유하면서 수정하게 되면, 선택이 바뀔 때 이미 전달된 데이터까지 함께 변할 위험이 있다.
특히 Command 시스템처럼 Undo를 위해 과거 상태를 저장해야 하는 구조에서는, 전달된 데이터가 나중에 바뀌어 버리면 심각한 문제가 된다.
따라서 이 시스템에는 두 가지 데이터 계층이 필요하다.
하나는 선택 과정에서 계속 수정되고 누적되는 작업용 선택 데이터이고, 다른 하나는 그 작업용 데이터를 복사해서 만든 전달용 결과 데이터이다.
SelectionData는 선택이 진행되는 동안 내부 상태를 계속 변경할 수 있는 가변 컨테이너 역할을 해야 하고, SelectionResult는 이 데이터를 외부 시스템에 안전하게 넘기기 위한 고정된 결과 구조여야 한다.
2. 흐름도
플레이어 입력
↓
SelectionStrategy
↓
SelectionData 생성
↓
Grid 좌표 / 월드 좌표 / 프리뷰 위치 / 회전값 계속 갱신
↓
배치 가능 여부 계산
↓
SelectionData.GetData()
↓
SelectionResult 생성
↓
PlacementManager / Command / Preview 시스템으로 전달
이 구조의 핵심은 SelectionData가 처음부터 끝까지 그대로 전달되지 않는다는 점이다.
선택이 진행되는 동안에는 SelectionData 안에서 좌표와 회전, 프리뷰 위치가 계속 수정된다.
그런데 이 상태를 그대로 외부 시스템에 넘기면, 이후 선택 변경이 발생했을 때 이미 전달된 데이터까지 함께 바뀔 수 있다.
그래서 최종 전달 시점에는 GetData를 호출하여 SelectionResult를 새로 만든다.
흐름상 SelectionData는 선택 중간 상태를 관리하는 작업 버퍼에 가깝고, SelectionResult는 외부 시스템이 소비하는 최종 결과에 가깝다.
이 두 단계를 분리해 두었기 때문에 입력 처리와 실행 처리 사이가 훨씬 안정적으로 분리된다.
3. 구현
3.1. SelectionData의 전체 역할과 가변 데이터 컨테이너 구조
public class SelectionData
{
private List<Vector3Int> SelectedGridPositions;
private List<Vector3> PreviewPositions;
private List<Vector3> selectedWorldPositions;
private List<Quaternion> selectedPositionsRotation, selectedPositionGridCheckRotation;
public ItemData PlacedItemData { get; private set; }
public bool PlacementValidity { set; get; }
public Quaternion Rotation { set; get; } = Quaternion.identity;
...
}SelectionData는 선택이 진행되는 동안 사용되는 가변 데이터 컨테이너다.
이 클래스의 핵심은 선택 중간 상태를 계속 수정할 수 있다는 점이다.
예를 들어 SelectionStrategy가 마우스 이동에 따라 Grid 좌표를 계속 추가하거나 다시 계산하고, PlacementValidator가 배치 가능 여부를 바꾸고, 회전 전략이 현재 회전값을 갱신하는 동안 이 클래스는 그 모든 데이터를 임시 저장하는 역할을 한다.
코드를 보면 내부 데이터는 모두 List<T>로 저장된다.
SelectedGridPositions, PreviewPositions, selectedWorldPositions, selectedPositionsRotation, selectedPositionGridCheckRotation이 각각 독립적으로 존재한다.
이 구조는 중요한 의미를 가진다.
단순히 하나의 좌표만 저장하는 시스템이었다면 Vector3 하나로도 충분했겠지만, 현재 건축 시스템은 박스 선택, 벽 경로 선택, 다중 셀 배치 등 여러 위치를 동시에 다뤄야 한다.
그래서 단일 값이 아니라 리스트 기반 구조가 필요하다. C#의 List<T>는 동적으로 크기가 변할 수 있는 컬렉션이기 때문에, 선택 범위 크기가 고정되지 않는 현재 상황에 잘 맞는다.
장점은 삽입과 순회가 직관적이라는 점이고, 단점은 참조형 컬렉션이라 그대로 외부에 노출하면 의도치 않은 수정이 일어날 수 있다는 점이다.
이 단점은 뒤에서 설명할 복사해서 반환하는 구조로 보완하고 있다.
PlacedItemData는 현재 선택 중인 구조물의 정의 자체를 들고 있는 속성이다.
이 값은 생성자에서 한 번 설정된 뒤 외부에서는 수정할 수 없도록 private set으로 제한되어 있다.
선택 도중에 갑자기 구조물 종류가 바뀌어 버리면 size, 배치 타입, 회전 규칙이 모두 달라지기 때문에 데이터 일관성이 무너질 수 있다.
그래서 생성 시점에 어떤 ItemData를 대상으로 하는지 결정하고, 이후에는 읽기만 가능하게 만든 것이다.
PlacementValidity는 현재 선택이 실제로 배치 가능한지 여부를 나타내며, SelectionStrategy와 PlacementValidator가 계속 갱신하는 값이다.
Rotation은 현재 선택 전체에 적용할 기본 회전값이고, Quaternion.identity로 초기화되어 있다.
Unity에서 Quaternion은 회전을 표현하는 표준 구조이며, identity는 회전이 전혀 없는 기본 상태를 의미한다.
오일러 각도 대신 Quaternion을 기본 저장 형식으로 쓰는 이유는 Unity 내부 회전 시스템과 바로 호환되기 때문이다.
3.2. 생성자와 초기 상태 설정
public SelectionData(ItemData placedItemData)
{
this.PlacedItemData = placedItemData;
SelectedGridPositions = new();
selectedWorldPositions = new();
selectedPositionsRotation = new();
selectedPositionGridCheckRotation = new();
PreviewPositions = new();
this.PlacementValidity = false;
}이 생성자는 선택 데이터 시스템의 시작 상태를 만든다.
먼저 PlacedItemData를 저장한다.
이 값은 이후 size, placement type, edge 여부 같은 계산의 기준이 되므로, SelectionData는 생성 순간부터 어떤 구조물을 위한 선택 데이터인지 분명히 알고 있게 된다.
그 다음 다섯 개의 리스트를 모두 새로 생성한다.
여기서 new() 문법은 C#의 target-typed new 문법으로, 왼쪽 변수 타입이 이미 명확할 때 생성자 타입명을 생략할 수 있게 해 준다.
장점은 코드가 짧고 반복이 줄어든다는 점이다.
단점은 코드 문맥을 놓치면 초심자에게는 어떤 타입의 리스트가 생성되는지 한눈에 덜 보일 수 있다는 점인데, 현재처럼 바로 필드 선언과 이어지는 구조에서는 크게 문제되지 않는다.
마지막으로 PlacementValidity를 false로 둔다.
선택 데이터는 생성 직후에는 아직 아무 위치도 계산되지 않았으므로, 기본 상태를 배치 불가로 두는 것이 맞다.
만약 true로 시작하면, 아직 검증이 끝나지 않은 선택 상태가 잘못 배치 가능한 것으로 해석될 수 있다.
3.3. Clear와 선택 상태 초기화
public void Clear()
{
SelectedGridPositions.Clear();
selectedWorldPositions.Clear();
selectedPositionsRotation.Clear();
selectedPositionGridCheckRotation.Clear();
PreviewPositions.Clear();
this.PlacementValidity = false;
}이 함수는 현재 선택 상태를 완전히 비우는 역할을 한다.
선택 시스템은 마우스 이동이나 새로운 선택 시작 시점마다 이전 데이터를 지우고 다시 구성해야 할 때가 많다.
이때 SelectionData 자체를 새로 생성하는 방식도 가능하지만, 같은 객체를 재사용하면서 내부 리스트만 비우는 것이 메모리 할당 측면에서 더 효율적일 수 있다.
그래서 이 클래스는 Clear를 제공한다.
각 리스트에 대해 Clear 함수를 호출하는 것은 C# 컬렉션에서 내부 원소만 제거하고, 리스트 객체 자체는 재사용하는 방식이다.
장점은 불필요한 새 할당을 줄일 수 있다는 점이고, 단점은 리스트 용량(capacity)은 남아 있을 수 있다는 점이다.
하지만 선택 시스템처럼 프레임 단위로 자주 갱신되는 구조에서는 새 리스트를 계속 만드는 것보다 이 방식이 더 적절하다.
마지막에 PlacementValidity = false로 다시 되돌리는 것도 중요하다.
좌표와 회전은 지웠지만 유효성 값이 남아 있으면 이후 로직에서 이전 상태가 잘못 유지될 수 있기 때문이다.
3.4. Add 계열 메서드와 선택 데이터 누적 구조
public void AddToGridPositions(Vector3Int pos) => SelectedGridPositions.Add(pos);
public void AddToWorldPositions(Vector3 pos) => selectedWorldPositions.Add(pos);
public void AddToPreviewPositions(Vector3 pos) => PreviewPositions.Add(pos);이 세 함수는 선택 데이터를 단계적으로 쌓아 가기 위한 메서드다.
SelectionStrategy나 각종 전략 클래스는 선택 위치를 계산할 때마다 SelectionData 내부 리스트에 값을 추가하게 된다.
여기서 중요한 점은 외부 코드가 리스트 필드를 직접 건드리지 않고, 메서드를 통해서만 값을 추가한다는 것이다.
이 클래스는 내부 리스트를 캡슐화하고 있다.
표현식 본문 메서드(=>)를 사용한 이유는 각 함수가 한 줄짜리 단순 동작만 수행하기 때문이다.
이 문법은 짧고 읽기 쉽다는 장점이 있지만, 내부 로직이 복잡해지면 오히려 가독성이 떨어질 수 있다.
현재처럼 Add 하나만 호출하는 경우에는 적절하다.
이 함수들이 따로 분리되어 있다는 점도 중요하다.
Grid 좌표, 월드 좌표, 프리뷰 좌표는 서로 다른 목적을 가진다.
Grid 좌표는 검증과 데이터 저장에 쓰이고, 월드 좌표는 실제 배치나 오브젝트 위치 계산에 쓰이며, 프리뷰 좌표는 배치 전에 시각적으로 보여주기 위한 위치다.
이 세 종류를 모두 같은 리스트에 섞지 않고 분리함으로써, 이후 시스템이 자신에게 필요한 좌표만 명확하게 가져다 쓸 수 있게 된다.
3.5. Get 계열 메서드와 복사본 반환 구조
public List<Vector3Int> GetSelectedGridPositions() => new(this.SelectedGridPositions);
public List<Vector3> GetSelectedWorldPositions() => new(this.selectedWorldPositions);이 메서드들의 핵심은 단순 조회가 아니라 복사본을 반환한다는 점이다.
new(this.SelectedGridPositions)는 기존 리스트를 기반으로 새 리스트를 만드는 C# 생성자 호출이다.
외부 시스템은 SelectionData 내부 리스트의 원본이 아니라 복사본을 받게 된다.
만약 내부 리스트를 그대로 반환하면, 외부에서 그 리스트를 수정했을 때 SelectionData 내부 상태까지 함께 바뀌게 된다.
특히 Command 시스템처럼 과거 선택 결과를 저장해야 하는 경우에는 이게 큰 문제가 된다.
그래서 이 클래스는 조회조차도 복사본으로 제한한다.
장점은 데이터 안정성이 매우 높아진다는 점이고, 단점은 복사 비용이 든다는 점이다.
그러나 선택 결과는 건축 시스템의 정확성과 Undo 안정성에 직결되므로, 여기서는 안전성이 더 중요하다.
3.6. Preview 좌표 fallback 구조
public List<Vector3> GetPreviewGridPositions() =>
PreviewPositions.Count > 0 ? new(this.PreviewPositions) : GetSelectedWorldPositions();이 함수는 PreviewPositions가 존재하면 그것을 복사해서 반환하고, 비어 있으면 selectedWorldPositions를 대신 반환한다.
이 구조는 단순 편의 기능처럼 보이지만 실제로는 매우 유용하다.
어떤 전략은 프리뷰 전용 좌표를 따로 계산하지만, 어떤 전략은 선택 월드 좌표와 프리뷰 좌표가 동일할 수 있다.
그럴 때 PreviewPositions를 꼭 별도로 채우지 않아도 되도록 fallback을 제공한 것이다.
이 함수는 프리뷰 좌표가 있으면 그것을 쓰고, 없으면 월드 좌표를 프리뷰 좌표처럼 사용한다는 의미를 가진다.
이런 구조 덕분에 모든 전략이 PreviewPositions를 강제로 설정하지 않아도 되고, 프리뷰 시스템은 일관된 방식으로 데이터를 받아올 수 있다.
결과적으로 이 함수는 좌표 데이터의 유연성을 높여 주는 연결 지점이다.
3.7. 회전 데이터 조회와 기본 회전 fallback
public List<Quaternion> GetSelectedPositionsObjectRotation() =>
this.selectedPositionsRotation.Count > 0 ? new(this.selectedPositionsRotation)
: selectedWorldPositions.Select(x => this.Rotation).ToList();
public List<Quaternion> GetSelectedPositionsGridRotation() =>
this.selectedPositionGridCheckRotation.Count > 0 ? new(this.selectedPositionGridCheckRotation)
: SelectedGridPositions.Select(x => this.Rotation).ToList();이 두 함수는 회전 데이터를 조회하는 함수인데, 좌표 함수보다 더 중요한 의미를 가진다.
첫 번째 함수는 실제 오브젝트 회전을 위한 값이고, 두 번째 함수는 Grid 검사에 사용할 회전값이다.
지금은 둘 다 Quaternion 리스트를 반환하지만, 의미는 다르다.
그래서 필드도 분리되어 있다.
둘 다 공통적으로, 리스트가 이미 채워져 있으면 그 복사본을 반환하고, 비어 있으면 기본 회전 this.Rotation을 각 위치 개수만큼 복제해서 새 리스트를 만든다.
여기서 LINQ의 Select(...).ToList()를 사용한 이유는 위치 리스트 길이에 맞춰 같은 회전값을 반복 생성하기 위해서다.
예를 들어 현재 오브젝트 회전이 따로 세팅되지 않았다면, 전체 선택 위치는 모두 공통 Rotation을 따른다고 볼 수 있다.
이때 Select를 사용하면 코드가 간결하고 의도도 분명하다.
단점은 루프를 직접 도는 것보다 약간의 오버헤드가 있다는 점이지만, 선택 데이터 생성은 매 프레임 무거운 수준은 아니고, 명확성이 더 중요하다.
이 fallback 구조 덕분에 각 전략은 꼭 회전 리스트를 매번 직접 채우지 않아도 된다.
특수한 회전이 필요한 전략은 SetObjectRotation이나 SetGridCheckRotation으로 명시적으로 넣으면 되고, 그렇지 않은 경우는 기본 Rotation만으로도 충분하다.
이 함수들은 회전 데이터를 항상 유효한 리스트 형태로 보장하는 역할을 한다.
3.8. GetData와 SelectionResult 변환
public SelectionResult GetData() => new SelectionResult
{
selectedGridPositions = GetSelectedGridPositions(),
selectedPositions = GetSelectedWorldPositions(),
selectedPreviewPositions = GetPreviewGridPositions(),
placementValidity = this.PlacementValidity,
size = PlacedItemData.size,
isEdgeStructure = PlacedItemData.objectPlacementType.IsEdgePlacement(),
selectedPositionsObjectRotation = GetSelectedPositionsObjectRotation(),
selectedPositionGridCheckRotation = GetSelectedPositionsGridRotation()
};이 함수는 SelectionData와 SelectionResult를 연결하는 핵심 함수다.
현재까지 SelectionData 안에 누적되어 있던 가변 상태를, 외부 시스템에 넘길 고정 결과 데이터로 변환한다.
이 코드의 핵심은 그냥 현재 필드를 넘긴다가 아니라, 모든 값을 getter를 통해 복사본으로 가져온 뒤 새 SelectionResult를 만든다는 점이다.
그래서 생성된 SelectionResult는 이후 SelectionData가 바뀌어도 영향을 받지 않는다.
Undo나 Command 저장에서 이게 매우 중요하다.
size와 isEdgeStructure를 직접 넣는 것도 중요하다.
이 값들은 선택 좌표만으로는 알 수 없는, 현재 선택 대상 구조물의 메타 정보다.
PlacedItemData.size를 통해 구조물 크기를 넣고, objectPlacementType.IsEdgePlacement()를 호출해 Edge 기반 구조물인지 판단한다.
여기서 IsEdgePlacement 함수는 PlacementType 확장 메서드로, 현재 구조물이 셀 기반인지 Edge 기반인지 코드 레벨에서 해석하는 역할을 한다.
SelectionResult는 좌표와 회전만 담는 것이 아니라, 이후 Command나 PlacementManager가 실행에 필요한 문맥까지 함께 가지는 구조다.
결과적으로 GetData는 단순 DTO 생성 함수가 아니라, 가변 선택 상태를 안전하고 완전한 전달용 데이터로 스냅샷화하는 함수라고 보는 것이 맞다.
3.9. 회전 설정 메서드
public void SetObjectRotation(List<Quaternion> objectsRotation)
{
this.selectedPositionsRotation = objectsRotation;
}
public void SetGridCheckRotation(List<Quaternion> gridCheckRotation)
{
this.selectedPositionGridCheckRotation = gridCheckRotation;
}이 두 함수는 외부 전략 클래스가 SelectionData 내부 회전 리스트를 설정할 수 있도록 제공하는 메서드다.
단순 대입처럼 보이지만, 의미는 분명하다.
회전 계산은 SelectionData가 직접 하지 않는다.
BoxSelection, WallPlacementStrategy, FreeObjectPlacementStrategy 같은 전략 클래스가 선택 방식에 따라 회전을 계산하고, 그 결과를 SelectionData에 주입한다.
즉, SelectionData는 회전 계산 책임이 없고, 회전 저장 책임만 가진다.
이는 데이터 컨테이너와 계산 로직을 분리했기 때문이다.
SelectionData가 스스로 회전까지 계산하려 들면 전략 패턴과 충돌하게 된다.
반면 지금 구조는 전략이 계산하고, SelectionData는 저장한다.
이 두 함수는 그 연결 지점 역할을 한다.
3.10. SelectionResult의 의미와 struct 선택 이유
public struct SelectionResult
{
public List<Vector3> selectedPositions;
public List<Vector3Int> selectedGridPositions;
public List<Vector3> selectedPreviewPositions;
public List<Quaternion> selectedPositionsObjectRotation, selectedPositionGridCheckRotation;
public bool placementValidity;
public Vector2Int size;
public bool isEdgeStructure;
}SelectionResult는 최종 전달용 데이터 구조체다.
이 타입은 SelectionData와 달리 메서드 없이 데이터만 가진다.
이 구조는 행동하는 객체가 아니라 전달되는 결과 값에 가깝다.
PlacementManager, Command, Preview 시스템은 이 SelectionResult를 받아 실제 배치나 제거, Undo에 사용한다.
여기서 class가 아니라 struct를 쓴 점도 중요하다.
C#의 struct는 값 형식(value type)이기 때문에, 대입이나 전달 시 기본적으로 값 복사 semantics를 가진다.
장점은 결과 데이터처럼 다루기에 적합하다는 점이다. 즉 SelectionResult를 함수 인자로 넘기거나 저장할 때, 원본과 독립된 값처럼 취급하기 쉽다.
단점은 내부에 참조형 필드(List)가 들어 있으므로 완전한 깊은 복사가 자동으로 일어나는 것은 아니라는 점이다.
그래서 이 코드에서는 struct를 쓴 것만으로 충분하다고 보지 않고, 아예 GetData에서 리스트 자체를 새로 복사해 넣는 방식을 함께 사용한다.
이 두 가지가 결합되어야 진짜 안전한 전달 구조가 된다.
SelectionResult 필드 구성을 보면, SelectionData가 관리하던 핵심 데이터가 그대로 들어 있다.
Grid 위치, 월드 위치, 프리뷰 위치, 오브젝트 회전, Grid 검사용 회전, 배치 가능 여부, 크기, Edge 여부까지 포함한다.
이는 이후 실행 시스템이 SelectionResult 하나만 받아도 배치/제거에 필요한 대부분의 문맥을 알 수 있게 만들기 위한 설계다.
4. 개발 의도
SelectionData / SelectionResult 시스템의 핵심 설계 의도는 선택 과정에서 계속 바뀌는 데이터와, 외부에 전달되어 고정되어야 하는 데이터를 분리하는 것이다.
SelectionData는 전략 시스템이 자유롭게 수정할 수 있는 작업 버퍼 역할을 하고, SelectionResult는 그 결과를 안전하게 복사해서 외부 시스템으로 넘기는 스냅샷 역할을 한다.
이 구조 덕분에 선택 중 데이터 변경이 이미 저장된 Command나 Undo 데이터에 영향을 주지 않게 된다.
또한 좌표, 프리뷰, 회전, 배치 가능 여부를 각각 분리해 저장한 것은 단순한 필드 분해가 아니라, 시스템 간 역할 차이를 반영한 설계다.
Preview는 프리뷰만, PlacementManager는 월드/Grid 좌표와 회전만, Command는 불변 결과만 필요로 한다.
SelectionData / SelectionResult를 분리한 덕분에 각 시스템은 자신이 필요한 시점에 적절한 형태의 데이터를 사용할 수 있게 된다.
