데이터 추가 및 제거 시스템
목차
1. 시스템 요구 사항
Grid 기반 건축 시스템에서 구조물을 실제로 배치하거나 제거하는 과정은 단순히 좌표 하나를 Dictionary에 넣거나 빼는 수준으로 끝나지 않는다.
구조물은 1x1 크기일 수도 있지만, 2x2 이상의 멀티타일 구조물일 수도 있고, 벽처럼 셀 중심이 아니라 셀과 셀 사이의 Edge를 점유할 수도 있다.
따라서 구조물 하나를 배치한다는 것은 곧 그 구조물이 점유하는 모든 셀 또는 모든 Edge를 데이터 구조에 동시에 반영해야 한다는 뜻이다.
또한 이 프로젝트에서는 하나의 구조물이 여러 좌표를 차지하더라도, 각각의 좌표에 서로 다른 데이터를 따로 저장하지 않는다.
대신 구조물 전체를 대표하는 단일 데이터 객체를 만들고, 그 구조물이 점유하는 모든 좌표가 동일한 데이터를 참조하는 방식으로 저장한다.
그래야 특정 좌표 하나만 알고 있어도 구조물의 origin, structureID, 회전, 점유 범위, 실제 GameObject 인덱스를 역으로 추적할 수 있다.
이 구조는 이후 제거, 이동, 저장/복원, Undo 시스템까지 전부 안정적으로 연결된다.
제거 역시 마찬가지다.
좌표 하나를 지운다고 해서 구조물 전체가 사라지는 것이 아니기 때문에, 특정 좌표에서 시작해 그 구조물이 점유하고 있는 전체 셀 또는 전체 Edge를 찾아 한 번에 제거해야 한다.
그렇지 않으면 Grid 상태에 찌꺼기 데이터가 남게 되고, 이후 충돌 검사나 선택 처리에서 이미 삭제된 구조물이 여전히 존재하는 것처럼 보이는 문제가 생길 수 있다.
따라서 데이터 추가 및 제거 시스템은 단순 CRUD가 아니라, 구조물 단위의 상태를 Grid 전체에 펼쳐서 기록하고, 다시 구조물 단위로 회수하여 제거하는 핵심 갱신 시스템이어야 한다.
이 계층이 정확해야만 위에 있는 PlacementManager, Command, Save/Load, Move 시스템이 모두 올바르게 동작한다.
2. 흐름도
구조물 배치 요청
↓
기준 좌표, 크기, 회전 전달
↓
GetCellPositions 또는 GetEdgePositions 호출
↓
점유할 전체 좌표 계산
↓
PlacedCellObjectData 또는 PlacedEdgeObjectData 생성
↓
계산된 모든 좌표 키에 동일한 데이터 등록
↓
Grid 상태 갱신 완료
구조물 제거 요청
↓
선택 좌표 전달
↓
해당 좌표가 참조하는 구조물 데이터 조회
↓
구조물이 점유 중인 전체 좌표 목록 추출
↓
모든 좌표 키 제거
↓
Grid 상태 갱신 완료
전체 초기화 요청
↓
Clear 호출
↓
셀 Dictionary / Edge Dictionary 동시 비움
↓
Grid 상태 완전 초기화
이 흐름에서 중요한 점은 배치와 제거가 모두 좌표 하나로 끝나지 않는다는 것이다.
배치 시에는 기준 좌표 하나에서 시작하지만, 실제로는 구조물이 차지하는 전체 셀 또는 전체 Edge를 계산한 뒤 그 전부를 Dictionary에 반영한다.
제거 시에도 클릭한 좌표 하나만 지우는 것이 아니라, 그 좌표가 가리키는 구조물 전체의 점유 범위를 찾아 한 번에 제거한다.
이 시스템은 좌표 단위처럼 보이지만, 실제로는 항상 구조물 단위로 동작하는 데이터 갱신 계층이다.
3. 구현
3.1. 셀 기반 구조물 추가
public void AddCellObject(int index, int ID, Vector3Int currentTilePosition,
Vector2Int objectSize, int rotation, int objectRotation)
{
List<Vector3Int> positionsToOccupy = GetCellPositions(currentTilePosition, objectSize, rotation);
PlacedCellObjectData data = new(index, ID, positionsToOccupy, currentTilePosition, rotation, objectRotation);
foreach (Vector3Int pos in positionsToOccupy)
{
if (gridCellsDictionary.ContainsKey(pos))
{
Debug.LogWarning($"Position {pos} is already occupied, skipping addition.");
continue;
}
gridCellsDictionary.Add(pos, data);
}
}이 함수는 셀 기반 구조물을 실제 Grid 데이터에 등록하는 함수다.
입력으로는 구조물의 실제 GameObject 인덱스 index, 구조물 식별자 ID, 기준 좌표 currentTilePosition, 구조물 크기 objectSize, Grid 기준 회전 rotation, 오브젝트 실제 회전 objectRotation이 들어온다.
이 매개변수 구성만 봐도 이 함수가 단순 좌표 등록 함수가 아니라, 구조물의 전체 상태를 Grid 데이터에 연결하는 함수라는 점이 드러난다.
코드에서 가장 먼저 하는 일은 GetCellPositions(currentTilePosition, objectSize, rotation)을 호출하는 것이다.
AddCellObject는 스스로 구조물의 점유 셀을 계산하지 않는다.
좌표 계산 책임은 GetCellPositions에 맡기고, 자신은 그 결과를 저장하는 역할만 수행한다.
이렇게 계산과 저장을 분리해 두었기 때문에, 배치 로직과 삭제 로직이 모두 같은 좌표 계산 기준을 사용할 수 있다.
만약 AddCellObject 내부에서 다시 좌표를 직접 계산했다면, 나중에 좌표 계산 규칙이 바뀔 때 여러 함수를 동시에 수정해야 했을 것이다.
지금 구조는 그 문제를 피한다.
그 다음 줄에서 PlacedCellObjectData data = new(...)가 실행된다.
이 코드가 핵심이다.
여기서 구조물 전체를 대표하는 단일 데이터 객체 하나를 만든다.
이 데이터에는 gameObjectIndex, structureID, origin, rotation, objectRotation, 그리고 PositionsOccupied 전체 목록이 함께 들어간다.
이 시점에 구조물 단위 데이터가 하나 생성되는 것이다.
중요한 점은 이후 각 좌표마다 이 데이터를 복사해서 새로 만드는 것이 아니라, 동일한 data 인스턴스 하나를 모든 점유 셀에 공유시킨다는 점이다.
이 구조 덕분에 어떤 셀 하나만 클릭해도 동일한 data를 통해 구조물 전체 정보를 역으로 찾을 수 있다.
이건 이동, 삭제, 저장/복원에서 매우 중요하다.
이후 foreach 문은 positionsToOccupy의 모든 좌표를 순회한다.
foreach (Vector3Int pos in positionsToOccupy)
{
if (gridCellsDictionary.ContainsKey(pos))
{
Debug.LogWarning($"Position {pos} is already occupied, skipping addition.");
continue;
}
gridCellsDictionary.Add(pos, data);
}
이 반복문은 구조물이 점유하는 모든 셀을 Grid에 반영하는 단계다.
각 좌표마다 먼저 gridCellsDictionary.ContainsKey(pos)를 호출하여 해당 위치에 이미 다른 구조물이 존재하는지를 확인한다.
Dictionary는 해시 기반 자료구조이기 때문에 특정 키의 존재 여부를 빠르게 확인할 수 있으며, 이 코드에서는 충돌 검사를 위한 기본적인 방어 로직으로 사용된다.
만약 이미 값이 존재한다면 해당 좌표는 건너뛰고, 그렇지 않은 경우에만 Add를 수행한다.
마지막으로 gridCellsDictionary.Add(pos, data)가 실행된다.
이 줄의 의미는 단순히 셀 하나 추가가 아니다.
구조물이 차지하는 모든 셀 키에 동일한 data를 등록하는 것이므로, 사실상 구조물 전체를 Grid 위에 펼쳐서 저장하는 과정이다.
결과적으로 AddCellObject는 기준 좌표 하나를 받아 실제 점유 셀 전체를 계산하고, 구조물 전체 상태를 담은 객체를 생성한 다음, 그 객체를 모든 점유 셀에 연결하는 함수다.
이 함수는 셀 기반 구조물의 배치를 Grid 데이터 차원에서 실체화하는 핵심 함수다.
3.2. Edge 기반 구조물 추가
public void AddEdgeObject(int index, int ID, Vector3Int currentTilePosition, Vector2Int objectSize, int rotation, int objectRotation)
{
List<Edge> edgesToOccupy = GetEdgePositions(currentTilePosition, objectSize, rotation);
PlacedEdgeObjectData data = new(index, ID, edgesToOccupy, currentTilePosition, rotation, objectRotation);
foreach (Edge pos in edgesToOccupy)
{
if (gridEdgesDictionary.ContainsKey(pos))
{
Debug.LogWarning($"Edge {pos} is already occupied, skipping addition.");
continue;
}
gridEdgesDictionary.Add(pos, data);
}
}이 함수는 AddCellObject와 동일한 패턴을 유지하면서, 대상만 Edge 기반 구조물로 바뀐 함수다.
여기서도 먼저 GetEdgePositions를 호출해 현재 구조물이 점유할 전체 Edge 목록을 계산한다.
이 함수 역시 좌표 계산은 외부 함수에 위임하고, 자신은 그 결과를 저장하는 역할만 담당한다.
셀 기반과 Edge 기반이 같은 저장 패턴을 공유한다는 점이 중요하다.
구조물이 셀을 차지하든 Edge를 차지하든, '전체 점유 좌표 계산 → 구조물 데이터 객체 생성 → 각 좌표에 동일 데이터 등록' 이라는 큰 흐름은 동일하게 유지된다.
이 일관성이 시스템 전체를 단순하게 만든다.
PlacedEdgeObjectData data = new(...) 역시 셀 기반과 같은 이유로 중요하다.
이 객체는 structureID, origin, 회전, 점유 중인 전체 Edge, 실제 GameObject 인덱스까지 포함한다.
결국 어떤 Edge 하나만 알고 있어도 그 구조물 전체의 상태를 알 수 있게 된다.
벽 삭제나 벽 교체, InWall 구조물 처리에서 바로 이 점이 중요하게 작동한다.
만약 각 Edge마다 개별 데이터를 따로 만들었다면, 구조물 전체를 추적하기가 훨씬 어려워졌을 것이다.
foreach 내부에서는 gridEdgesDictionary.ContainsKey(pos)로 이미 같은 Edge가 점유 중인지 검사한다.
여기서 Edge는 record 기반 값 객체이므로, 같은 smallerPoint와 biggerPoint를 가지는 Edge는 동일한 키로 인식된다.
그래서 이 ContainsKey는 벽 충돌을 안정적으로 검출할 수 있다.
이후 Add로 등록하는 순간, 해당 Edge는 Grid 상에서 이미 점유된 경계선이 된다.
AddEdgeObject는 벽이나 벽 내부 구조물처럼 셀이 아닌 경계선을 기준으로 저장해야 하는 구조물에 대해, Edge 단위 점유 상태를 Grid에 펼쳐서 기록하는 함수다.
셀 기반과 구조는 같지만, 키 체계 자체가 다르기 때문에 공간 표현 방식이 완전히 달라지는 것이 핵심이다.
3.3. 셀 기반 구조물 제거
internal void RemoveCellObject(Vector3Int currentTilePosition)
{
PlacedCellObjectData data = gridCellsDictionary[currentTilePosition];
foreach (Vector3Int position in data.PositionsOccupied)
{
if (gridCellsDictionary.ContainsKey(position))
{
PlacedCellObjectData placementData = gridCellsDictionary[position];
foreach (var item in placementData.PositionsOccupied)
{
gridCellsDictionary.Remove(item);
}
}
}
}이 함수는 셀 기반 구조물을 제거하는 함수다.
중요한 점은 입력으로 구조물 전체가 아니라 currentTilePosition 하나만 받는다는 것이다.
시스템은 특정 좌표 하나만 알고도 그 좌표가 속한 구조물 전체를 제거할 수 있어야 한다.
이게 가능하려면 AddCellObject에서 모든 점유 셀이 동일한 PlacedCellObjectData를 공유하고 있어야 한다.
그래서 이 제거 로직은 곧 추가 로직의 설계를 역으로 증명하는 코드이기도 하다.
첫 줄에서 PlacedCellObjectData data = gridCellsDictionary[currentTilePosition]가 실행된다.
이건 해당 좌표가 참조하고 있는 구조물 데이터 전체를 가져오는 것이다.
currentTilePosition은 단순한 셀 하나지만, 그 셀이 가진 값 안에는 PositionsOccupied 전체 목록이 들어 있다.
바로 그 점 때문에 셀 하나만 알아도 구조물 전체를 찾을 수 있다.
이 부분이 Grid 시스템의 핵심 설계다.
그 다음 foreach는 data.PositionsOccupied를 순회한다.
여기서 끝까지 보면 약간 중첩된 구조가 나온다.
먼저 구조물이 점유하고 있는 각 셀을 돌고, 그 셀이 실제로 Dictionary 안에 존재하는지 다시 확인한 뒤, 해당 셀이 참조하는 placementData를 꺼내고, 다시 그 placementData.PositionsOccupied 전체를 순회하며 Dictionary에서 Remove한다.
얼핏 보면 한 번 더 우회하는 것처럼 보일 수 있지만, 이 구조의 의미는 명확하다.
현재 좌표가 속한 구조물이 아니라 각 좌표가 참조하는 구조물 데이터가 점유하는 전체 셀을 기준으로 삭제한다는 뜻이다.
결국 최종적으로는 구조물이 점유 중이던 모든 셀 키가 Dictionary에서 제거된다.
Dictionary.Remove(key)는 C#에서 해당 키를 가진 엔트리를 삭제하는 기본 API다.
여기서 중요한 것은 셀 하나만 지우는 것이 아니라, 구조물 전체 점유 셀을 모두 지운다는 점이다.
만약 클릭한 셀 하나만 지운다면 나머지 셀들이 Dictionary에 남고, 이후에는 보이지 않는 구조물이 남아 있는 상태가 된다. 따라서 이 제거 로직은 셀 단위 삭제가 아니라 구조물 단위 삭제다.
RemoveCellObject의 본질은 특정 좌표를 지우는 게 아니라, 그 좌표를 통해 구조물 전체를 찾아 전부 제거하는 데 있다.
3.4. Edge 기반 구조물 제거
internal void RemoveEdgeObject(Vector3Int currentTilePosition, Vector2Int size, int rotation)
{
List<Edge> edgePositions = GetEdgePositions(currentTilePosition, size, rotation);
foreach (Edge position in edgePositions)
{
if (gridEdgesDictionary.ContainsKey(position))
{
PlacedEdgeObjectData data = gridEdgesDictionary[position];
foreach (var item in data.PositionsOccupied)
{
gridEdgesDictionary.Remove(item);
}
}
}
}이 함수는 Edge 기반 구조물을 제거하는 함수다.
셀 제거와 큰 흐름은 같지만, 시작점이 셀 하나가 아니라 currentTilePosition, size, rotation을 받아 다시 Edge 목록을 계산하는 구조라는 점이 다르다.
이는 Edge 구조물은 셀처럼 단일 좌표 하나로 바로 접근하기보다, 기준점과 회전으로 전체 Edge 구성을 다시 계산하는 방식이 더 자연스럽기 때문이다.
첫 줄에서 GetEdgePositions(currentTilePosition, size, rotation)을 호출한다.
제거 대상이 되는 전체 Edge 후보를 먼저 계산한다.
그 다음 각 Edge에 대해 실제로 gridEdgesDictionary에 존재하는지 검사하고, 존재하면 그 Edge가 참조하는 PlacedEdgeObjectData를 가져온다.
이 데이터 안에는 그 구조물이 점유한 전체 Edge 목록 PositionsOccupied가 들어 있다.
따라서 마지막 foreach에서 그 전체 Edge를 모두 Remove하면, 벽이나 벽 내부 구조물 전체가 한 번에 제거된다.
이 로직이 중요한 이유는 벽 구조물도 결국 여러 Edge를 점유할 수 있기 때문이다.
InWall 구조물이나 긴 벽은 하나의 선분이 아니라 여러 선분의 집합일 수 있다.
따라서 Edge 하나만 지우면 안 되고, 구조물 전체가 점유 중인 모든 Edge를 지워야 한다. RemoveEdgeObject는 바로 그 역할을 한다.
이 함수 역시 입력은 특정 위치 기반으로 시작하지만, 실제 동작은 구조물 단위 Edge 삭제다.
셀 제거와 동일한 철학을 Edge 공간에 맞게 구현한 함수라고 보면 된다.
3.5. 전체 Grid 데이터 초기화
public void Clear()
{
gridCellsDictionary.Clear();
gridEdgesDictionary.Clear();
}이 함수는 PlacementGridData가 가지고 있는 모든 셀 데이터와 Edge 데이터를 동시에 초기화한다.
겉으로 보면 단순히 두 줄이지만, 실제로는 Grid 상태 전체를 리셋하는 함수다.
게임을 새로 시작하거나, 저장 데이터를 불러오기 전에 기존 상태를 비우거나, 건축 모드를 완전히 재초기화할 때 사용된다.
여기서 Dictionary.Clear()는 내부 엔트리들을 제거하지만, Dictionary 객체 자체는 유지한다.
장점은 메모리를 새로 할당하지 않고 기존 컬렉션을 재사용할 수 있다는 점이다.
반면 새 Dictionary를 다시 생성하는 방식도 가능하지만, 현재 구조에서는 내부 상태만 비우면 되므로 Clear가 더 직접적이고 의도도 분명하다.
무엇보다 중요한 것은 셀과 Edge를 함께 비운다는 것이다.
이 프로젝트는 셀 기반 구조물과 Edge 기반 구조물을 다른 Dictionary에 저장하고 있기 때문에, 둘 중 하나만 초기화하면 시스템 상태가 불일치하게 된다.
예를 들어 셀 데이터는 사라졌는데 벽 데이터가 남아 있으면, 이후 배치 검사에서는 눈에 보이지 않는 벽과 충돌하는 상황이 생길 수 있다.
반대로 벽은 지워졌는데 오브젝트만 남아 있어도 마찬가지다.
따라서 Clear는 단순한 삭제 함수가 아니라, PlacementGridData를 다시 완전한 빈 상태로 되돌리는 전체 초기화 함수다.
4. 개발 의도
이 데이터 추가 및 제거 시스템의 핵심 설계 의도는 좌표 단위처럼 보이는 작업을 실제로는 구조물 단위로 처리하는 것이다.
Grid 기반 시스템에서는 셀 하나나 Edge 하나를 키로 저장하지만, 실제로는 그 키 하나가 구조물 전체를 가리키도록 설계되어 있다.
그래서 특정 좌표 하나만 알고 있어도 structureID, origin, 점유 범위, 실제 GameObject 인덱스를 역으로 찾을 수 있고, 제거 시에도 구조물 전체를 한 번에 지울 수 있다.
또한 좌표 계산 책임을 GetCellPositions와 GetEdgePositions로 분리하고, 추가/제거 함수는 그 결과를 저장하거나 회수하는 역할만 맡도록 설계한 것도 중요하다.
이 덕분에 계산 규칙과 저장 규칙이 분리되어, 좌표 체계가 바뀌더라도 배치/제거 로직 전체를 다시 쓰지 않아도 된다.
셀 기반과 Edge 기반이 서로 다른 키 체계를 사용하지만, '전체 좌표 계산 → 구조물 데이터 생성 → 모든 좌표에 연결' 이라는 흐름을 공통으로 유지한 것도 시스템 일관성을 높여 준다.
결과적으로 이 계층은 단순히 Dictionary에 넣고 빼는 함수들의 집합이 아니라, 구조물 전체 상태를 Grid 위에 펼쳐서 기록하고 다시 회수하는 핵심 데이터 갱신 시스템이다.
이 구조가 정확하게 동작하기 때문에 그 위의 PlacementManager, Command, Move, Save/Load 시스템도 모두 같은 기준 위에서 안정적으로 동작할 수 있다.
