간략히 언급한 이야기지만 퍼즐위치사가 시리즈는 단순히 퍼즐보블을 현대화 시키는 내용뿐만 아니라, 체력을 깎아 보스를 무찌르는 내용의 스테이지도 있다. 만들다보면 이 스테이지는 배울게 많다고 느낀다.
경로 개념 정의하기
천장을 쓰는 대신 어떤 좌표에서 스테이지가 생성되는 형태를 만들 생각이었다. 그리고 원본은 그 부분이 꽤 잘 되어 있었고 직접 스크린샷을 가져올 상황이 아니어서 야매로 그린다면 이런 형식이었다.

bad guy 주변에 스폰 포인트가 있기 때문에 아 이게 bad guy가 쓰는 무기, 능력같은거구나 라는 UX를 논할 수 있다. 그리고 spawn에 인접하다는 전제하에 스테이지가 생성되는데, 이것은 순차적으로 이루어진다. 즉, 1 - 5 칸 중 3칸이 없어지면 기존 1 2 4 5끼리 붙고, 새로운게 spawn 옆에서 생성된다. 이 부분을 좀 더 visual 하게 설명해보겠다.

그림의 왼쪽은 경로에 따라 생성된 스테이지에서 제거가 일어났을 때 실질적으로 수행하는 루틴을 설명한다. 하나가 제거되면 남은 것들을 끝으로 밀어버리고 새로운 것을 spawn 하게 된다.
그림의 오른쪽도 동일하다. 3이 제거됨으로써 "인접 없는" 4, 5는 동시에 제거 된다. 그럼 남아있는건 1과 2뿐이니 맨 뒤로 밀고 총 3개를 재생성하게 된다.
이건 레벨 디자인을 하기에 너무 적절한 주제였다. bad guy가 너무 강력하다면 spawn 위치를 훨씬 많이 배치 할 수도 있을 것이다. 아니면 차라리 겁나 끝내주는 해골모양 디자인의 스테이지를 만들 수도 있을 것이다. 미래의 누군가의 창의력을 위해 이 부분을 접근했다. 참고로 나는 기껏 생각해낸게

이런 느낌이었다. 해골은 spawn 에 대응한다. 완성한 지금도 가능은 할 것 이다.
public void GenerateStageFromPath()
{
if (paths.Count == 0 || bubblePrefab == null)
{
Debug.LogError("PathManager 또는 BubblePrefab이 연결되지 않았습니다!");
return;
}
bubbleGrid.Clear();
foreach (var spawnManager in paths)
{
foreach (Vector2Int hexCoords in spawnManager.pathCoordinates)
{
Vector2 worldPos = HexGridConverter.HexToWorld(hexCoords);
BubbleController bubbleCtrl =
ObjectPoolManager.Instance.GetObject(bubblePrefab, worldPos, Quaternion.identity);
if (bubbleCtrl != null)
{
bubbleCtrl.gameObject.transform.parent = this.transform;
bubbleCtrl.Setup(true, lightSpawnRatio, this);
}
bubbleGrid.Add(hexCoords, bubbleCtrl);
}
}
Debug.Log($"{bubbleGrid.Count}개의 버블로 스테이지 생성을 완료");
}
이 메서드를 통해 직접적으로 스폰이 이루어진다. 여기서 생각 할 것은 paths 라는 변수인데, 이 메서드를 담고 있는 매니저는 게임 초기화 시점에 paths 내용을 주입 받게 되고 해당 내용에 따라 정직하게 배치된다.
public IEnumerator RearrangePath()
{
foreach (var spawnManager in paths)
{
List<BubbleController> remainingPathBubbles = new List<BubbleController>();
foreach (Vector2Int pathCoord in spawnManager.pathCoordinates)
{
if (bubbleGrid.TryGetValue(pathCoord, out BubbleController bubble))
{
if (bubble != null && bubble.isPathBubble)
remainingPathBubbles.Add(bubble);
}
}
foreach (BubbleController bubbleToRemove in remainingPathBubbles)
{
Vector2Int keyToRemove = bubbleGrid.FirstOrDefault(x => x.Value == bubbleToRemove).Key;
if(bubbleGrid.ContainsKey(keyToRemove))
bubbleGrid.Remove(keyToRemove);
}
// 살아남은건 경로의 맨 뒷부분으로 밀어낸다.
int remainingCount = remainingPathBubbles.Count;
int totalPathCount = spawnManager.pathCoordinates.Count;
int startIndex = totalPathCount - remainingCount;
for (int i = 0; i < remainingCount; i++)
{
int newPathIndex = startIndex + i;
Vector2Int newCoords = spawnManager.pathCoordinates[newPathIndex];
BubbleController bubble = remainingPathBubbles[i];
StartCoroutine(MoveBubble(bubble, HexGridConverter.HexToWorld(newCoords)));
bubbleGrid.Add(newCoords, bubble); // 새로운 위치로 다시 등록
}
yield return new WaitForSeconds(0.1f);
// 새롭게 생성하여 보충한다.
for (int i = 0; i < startIndex; i++)
{
Vector2Int spawnCoords = spawnManager.pathCoordinates[i];
BubbleController bubbleCtrl = ObjectPoolManager.Instance.GetObject(bubblePrefab, HexGridConverter.HexToWorld(spawnCoords), Quaternion.identity);
if (bubbleCtrl != null)
{
bubbleCtrl.gameObject.transform.position = HexGridConverter.HexToWorld(spawnManager.pathCoordinates[0]);
bubbleCtrl.Setup(true, lightSpawnRatio, this);
}
StartCoroutine(MoveBubble(bubbleCtrl, HexGridConverter.HexToWorld(spawnCoords)));
bubbleGrid.Add(spawnCoords, bubbleCtrl);
}
}
}
그리고 이 내용은 스테이지에 해당되는 구슬이 제거 되었을 때 스테이지를 복구하는 코루틴이다. 앞에서 이야기했듯 우선 남아있는 것들을 제일 뒤로 밀고 그 다음 새로운 것들을 만들도록 구성되어있다.
레벨 디자인 구성하기
우린 paths 가 어떻게 만들어져있는지 정확히 논해보겠다. 이 레벨 디자인은 별 다른 리소스를 추가로 생각하지 않고 파일만 추가 시킴으로써 매번 다른 스테이지를 구성 할 수 있게 해준다. 즉, 더 적은 비용으로 더 다채로운 스테이지 구성을 가능하게 한다.
앞에서 봤던 두 코드 뭉치들이 들어있는 Manager에는 이와 같은 리스트가 정의 되어 있다 :
private List<PathData> paths;
public class PathData
{
public string pathName;
public Vector2Int spawnPointCoordinate;
public List<Vector2Int> pathCoordinates;
}
그리고 이 PathData라는 것은 명시적으로 구분 할 수 있는 이름, 그리고 (spawn 점에 해당하는) 스폰 위치, 그리고 경로 한 개에 대한 내용이 적혀있다.

다른건 제목 보면 엥간치 무슨 말인지 보일 거라고 생각한다. 레벨 당 쓸 수 있는 턴, 뭐 점수 배점, 보스 체력 등.. 중요한건 창의 최 하단에 있는 경로이다. Edit 계열을 누르면 입력의 한계까지 입력 할 수 있는 Toggle 모드로 전환된다.
private void HandleSpawnPointEditing()
{
// 시각적 피드백: 마우스 위치에 미리보기 기즈모 표시
Ray ray = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition);
Vector2 worldPos = ray.origin;
Vector2Int hexCoords = HexGridConverter.WorldToHex(worldPos);
Handles.color = new Color(1f, 0f, 0f, 0.5f); // 반투명 빨간색
Handles.DrawSolidDisc(HexGridConverter.HexToWorld(hexCoords), Vector3.forward, HexGridConverter.hexCellRadius);
// UI 텍스트 표시
Handles.BeginGUI();
GUI.backgroundColor = Color.black;
GUI.Label(new Rect(10, 10, 200, 40), "스폰 지점 설정 모드\n원하는 위치를 좌클릭하세요.");
Handles.EndGUI();
Event e = Event.current;
if (e.type == EventType.MouseDown && e.button == 0)
{
// 1. 클릭된 위치의 Hex 좌표를 가져옴
Vector2Int newSpawnPointCoord = HexGridConverter.WorldToHex(worldPos);
// 2. 해당 경로의 SpawnPoint 좌표를 업데이트
Undo.RecordObject(selectedLevelData, "Set Spawn Point");
editingPathData.spawnPointCoordinate = newSpawnPointCoord;
EditorUtility.SetDirty(selectedLevelData);
// 3. 즉시 편집 모드 종료
isEditingSpawnPoint = false;
editingPathData = null;
e.Use(); // 다른 오브젝트 선택 방지
}
// 씬 뷰를 계속 새로고침하여 마우스 따라다니는 효과를 만듦
HandleUtility.Repaint();
}
스폰 포인트 정의 버튼에 대해 논함.
private void HandlePathEditing()
{
// 현재 편집중인 경로의 SpawnPoint와 경로를 시각적으로 표시
DrawPathGizmos();
Event e = Event.current;
if (e.type == EventType.MouseDown && e.button == 0)
{
Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);
Vector2 worldPos = ray.origin;
Vector2Int hexCoords = HexGridConverter.WorldToHex(worldPos);
if (editingPathData.pathCoordinates == null) editingPathData.pathCoordinates = new List<Vector2Int>();
// 중복이 아니면 추가
if (!editingPathData.pathCoordinates.Contains(hexCoords))
{
Undo.RecordObject(selectedLevelData, "Add Path Point");
editingPathData.pathCoordinates.Add(hexCoords);
EditorUtility.SetDirty(selectedLevelData);
}
e.Use();
}
}
private void DrawPathGizmos()
{
// SpawnPoint 그리기
Handles.color = Color.red;
Vector2 spawnWorldPos = HexGridConverter.HexToWorld(editingPathData.spawnPointCoordinate);
Handles.DrawSolidDisc(spawnWorldPos, Vector3.forward, HexGridConverter.hexCellRadius * 0.8f);
Handles.Label(spawnWorldPos, "SpawnPoint");
// 경로 그리기
if (editingPathData.pathCoordinates == null) return;
Handles.color = Color.white;
foreach (var coord in editingPathData.pathCoordinates)
{
Vector2 worldPos = HexGridConverter.HexToWorld(coord);
Handles.DrawWireDisc(worldPos, Vector3.forward, HexGridConverter.hexCellRadius);
}
// 경로 선 그리기
Handles.color = Color.yellow;
for (int i = 0; i < editingPathData.pathCoordinates.Count - 1; i++)
{
Vector2 start = HexGridConverter.HexToWorld(editingPathData.pathCoordinates[i]);
Vector2 end = HexGridConverter.HexToWorld(editingPathData.pathCoordinates[i + 1]);
Handles.DrawLine(start, end);
}
}
경로 생성 코드.
만들어진 내용은 Assets/Contents에 저장되고 게임 시작전 직렬화된 필드에 던져서 바로 시연 할 수 있게 했다.
'구현하기 > Unity' 카테고리의 다른 글
| 트러블슈팅 : WayPoints에 못닿는 문제 (0) | 2025.10.23 |
|---|---|
| 경매 시스템 구현 #2 (0) | 2025.10.20 |
| 경매 시스템 구현 #1 (0) | 2025.10.19 |
| 퍼즐위치사가 3 클론 기록 #2 : MVP 만들기 (1) | 2025.09.26 |
| 퍼즐위치사가 3 클론 기록 #1 : 서론 (1) | 2025.09.25 |