Unity 본캠프 3일차 - 미니 프로젝트

한예준·2025년 4월 9일

오늘은 미니 프로젝트의 전반적인 작업을 정리하고, 어제까지 진행한 내역과 팀 단위 협업 과정을 점검하는 시간을 가졌다. 어제까지는 각 팀원들이 개별적으로 맡은 기능을 구현하고 있었고, 오늘은 팀장 주도로 각자의 파트를 검수하고 하나의 프로젝트로 병합하는 작업을 진행했다. 이를 통해 기능 간 충돌을 사전에 확인하고, 전체적인 흐름이 자연스럽게 연결되는지 테스트할 수 있었다.

나는 카드 애니메이션 구현을 중심으로 작업을 진행했다. 카드 클릭 시 Y축 회전 애니메이션을 통해 앞면이 자연스럽게 보이도록 설정하였고, 두 장의 카드가 열렸을 때 일치 여부를 판단하여 맞지 않으면 다시 뒤집히는 애니메이션이 재생되도록 구성하였다. 카드가 일치했을 경우에는 제거 처리까지 연계되도록 애니메이터 트리거를 제어했다.


이후 작업 정리

  • 병합 이후 팀원들과 함께 게임의 실행을 확인하였다.
    • 추가 기능인 Stage 분리가 완성됨을 확인하였다.
    • Hidden Stage에 잉크가 떨어지는 효과가 제대로 구현됐음을 확인했다.
    • Stage clear 시에 나오는 화면이 의도대로 구현됐음을 확인했다.
  • 약간의 수정이 필요한 작업도 끝마쳤다.
    • 버튼이 제대로 작동하지 않는 문제를 해결했다.
      • 버튼 위에 이미지가 있어서 버튼이 제대로 클릭되지 않았음.
    • 약간의 충돌 문제를 해결했다.
    • 잉크 애니메이션을 수정했다.
    • 히든 스테이지로 가는 조건을 추가하여 구현하였다.

개인 학습

  • 팀 프로젝트가 거의 끝나고 남은 부분은 내일 끝마치기로 하고, 개인 학습을 시작했다. 나는 랜덤타워디펜스 형식의 게임을 모방하여 Unity 활용법을 조금 더 익혀보기로 했다.

초기 기반 작업

1. 프리팹 구조 세팅

  • 타워는 8종의 노말 등급 프리팹으로 구성되어 있으며, 각 프리팹은 Tower 스크립트를 통해 개별 관리가 가능하다.
  • 타일 프리팹은 Tile.cs가 붙어 있고, Collider2D가 적용되어 클릭 판정이 가능하다.
  • 프리팹들은 모두 Resources/Towers/ 또는 Prefabs/Tile/ 등의 구조로 정리되었다.

2. GameManager 생성

  • 골드, 웨이브, 체력 등 전역 상태를 관리할 싱글톤 GameManager를 생성했다.
  • 골드 차감은 SpendGold() 함수로 작성했다.
public bool SpendGold(int amount)
{
    if (gold >= amount)
    {
        gold -= amount;
        return true;
    }
    return false;
}

타일 생성 및 타워 배치 시스템

타일 자동 생성 (TileManager)

  • 게임이 시작되면 TileManager가 10x10 형태의 타일을 자동으로 생성한다.
  • 각 타일은 "Tile_x_y" 형태로 이름이 지정되고, Tile 스크립트를 통해 상태 관리가 가능하다.
  • 생성된 타일은 딕셔너리 구조 (Dictionary<Vector2Int, Tile>)로 저장되어 좌표 기반 참조가 편리하다.

타워 배치 시스템

  • 플레이어는 타일을 클릭해 타워를 배치할 수 있다.
  • 타워는 8종의 프리팹 중 하나가 무작위로 생성되며, 배치 시 골드가 차감된다.
  • 이미 타워가 설치된 타일에는 중복 배치할 수 없다.
if (tile != null && !tile.isOccupied && tile.isBuildable)
{
    if (GameManager.Instance.SpendGold(towerCost))
    {
        SpawnRandomTower(tile.transform.position);
        tile.isOccupied = true;
    }
}

경로 타일 색칠 + 설치 제한

목표

  • 그 중 Enemy가 이동할 경로 타일만 빨간색으로 표시
  • 경로에 포함된 타일에는 타워를 설치하지 못하도록 제한

문제 상황

  • 초기에는 PathManager가 경로 좌표만 받아 타일을 찾는 방식이었는데, Unity의 Start() 순서 특성상 TileManager가 타일을 생성하기 전에 PathManager가 먼저 실행되어 타일을 못 찾는 문제가 있었다.

해결 방법

  1. PathManager에서 Start() 대신 SetupPathTiles() 함수를 따로 만들고,
  2. 타일이 모두 생성된 이후(TileManager의 GenerateTiles 끝) 해당 함수를 호출하도록 구조를 바꿈
// TileManager.cs 내부
if (PathManager.Instance != null)
{
    PathManager.Instance.SetupPathTiles();
}

추가 개선

  • 처음에는 pathTileCoords에 명시한 좌표만 빨갛게 칠했지만, Enemy는 경로 두 점 사이를 직선으로 이동하기 때문에 중간에 밟는 타일들도 포함해줘야 했다.
  • 이를 위해 PathManager에서 두 좌표 사이의 정수 타일들을 계산해서 모두 색칠하고 pathPoints에 추가하는 로직으로 확장했다.
for (int x = xStart; x <= xEnd; x++)
{
    ApplyPathToTile(new Vector2Int(x, y));
}

결과

  • 경로를 구성하는 모든 타일이 빨간색으로 표시되고,
  • 해당 타일 위에는 타워가 설치되지 않도록 isBuildable = false가 자동 설정된다.
  • 시각적으로도 플레이어가 경로를 명확히 인식할 수 있게 되었다.

Enemy의 이동 경로 설정 오류

문제 인식

  • 하지만 실제 실행을 해보니 Enemy 오브젝트가 뒤틀린 경로로 이동하는 게 확인됐다.
  • 중간 좌표를 자동으로 계산하여 채우는 과정에서 GetLineBetween()함수가 항상 좌표를 정방향으로만 채우기 때문에 실제 pathPoints에는 (0,9), (0,8), (0,7), (0,6) 같은 식으로 역방향으로 좌표가 들어가야 하는데 정방향으로 들어가게 됐다.
  • 즉, 입력한 순서와 pathPoints에 들어가는 순서가 일치하지 않아 경로가 꼬이는 문제가 발생했다.
  • 그 결과 Enemy가 경로를 따라 이동하지 않고, 오히려 반복되거나 엉뚱한 순서로 타일을 밟는 현상이 발생했다.

해결 방향

  • Enemy가 정확히 우리가 입력한 좌표 순서대로, 그리고 그 사이의 모든 타일을 순서대로 따라가도록 해야 했다.
  • 이를 위해 각 경로 좌표 사이의 모든 타일 좌표를 순서대로 확장해주는 GetLineBetween 함수 구현이 필요했다.
  • 좌표 사이를 정방향/역방향 모두 처리할 수 있도록 방향까지 고려한 루프 구성으로 순서 꼬임 문제를 해결했다.

최종 구조 (PathManager.cs)

  • pathTileCoords에는 플레이어가 입력한 좌표만 저장한다. 예: (0,9), (0,6), (9,6)...
  • SetupPathTiles()에서는 GetLineBetween()을 이용해 좌표 사이의 모든 타일을 계산하고 pathPoints에 순서대로 Transform을 추가한다.
  • Enemy는 이 pathPoints를 따라 정확히 순서대로 이동하며, 모든 중간 타일까지 밟게 된다.
  • 동시에 해당 타일들은 빨간색으로 칠해지며, isBuildable=false 처리가 되어 타워 설치가 불가능해진다.

GetLineBetween 구현

private List<Vector2Int> GetLineBetween(Vector2Int a, Vector2Int b)
{
    List<Vector2Int> list = new List<Vector2Int>();

    if (a.x == b.x) // 세로 이동
    {
        int dir;
        if (a.y < b.y) dir = 1;
        else dir = -1;

        for (int y = a.y; y != b.y + dir; y += dir)
        {
            list.Add(new Vector2Int(a.x, y));
        }
    }
    else if (a.y == b.y) // 가로 이동
    {
        int dir;
        if (a.x < b.x) dir = 1;
        else dir = -1;

        for (int x = a.x; x != b.x + dir; x += dir)
        {
            list.Add(new Vector2Int(x, a.y));
        }
    }

    return list;
}
  • 먼저 두 좌표가 같은 x값이면 세로 라인, 같은 y값이면 가로 라인으로 인식한다.
  • 시작 좌표 \rarr 끝 좌표 순서를 유지하기 위해 방향을 고려한 for문 구성

결과

  • 입력한 좌표 순서 그대로 Enemy가 이동한다.
  • 경로가 의도한 대로 연결되고, Gizmos로도 정확하게 표시된다.
  • pathPoints 중복, 경로 꼬임 문제가 없어졌다.
    • 정확하게 경로가 연결된 것이 Gizmos로 시각화된 모습이다.

Enemy 생성 및 웨이브 구현

목표

  • 일정 시간 간격으로 적을 자동으로 생성
  • 한 웨이브에 30기의 적을 출현시키고, 매 10 웨이브마다 보스를 추가 생성
  • 웨이브가 끝나면 재화 보상을 지급하고, 일정 대기 시간 후 다음 웨이브로 자동 진입

EnemySpawner 구조

  • EnemySpawner 오브젝트를 씬에 생성하고 EnemySpawner.cs를 붙여줬다.
  • 게임 시작 시 첫 웨이브를 자동으로 시작한다.
  • 일반 적과 보스 적 프리팹은 각각 따로 관리
  • PathManager.pathPoints를 참조하여 Enemy가 정확히 경로를 따라 움직이도록 구성
  • 다음은 EnemySpawner.cs의 전체 구조이다.
public class EnemySpawner : MonoBehaviour
{
    public GameObject normalEnemyPrefab;
    public GameObject bossEnemyPrefab;

    public float spawnInterval = 0.5f;
    public int enemiesPerWave = 30;

    private int currentWave = 1;
    private bool isSpawning = false;

    void Start()
    {
        StartCoroutine(StartWave());
    }

    IEnumerator StartWave()
    {
        isSpawning = true;

        for (int i = 0; i < enemiesPerWave; i++)
        {
            SpawnEnemy(false);
            yield return new WaitForSeconds(spawnInterval);
        }

        if (currentWave % 10 == 0)
        {
            SpawnEnemy(true); // 10 웨이브마다 보스 출현
        }

        isSpawning = false;

        GameManager.Instance.AddGold(400); // 웨이브 보상 지급

        yield return new WaitForSeconds(10f);
        currentWave++;
        StartCoroutine(StartWave());
    }

    void SpawnEnemy(bool isBoss)
    {
        GameObject prefab = isBoss ? bossEnemyPrefab : normalEnemyPrefab;

        if (PathManager.Instance.pathPoints.Count > 0)
        {
            Vector3 spawnPos = PathManager.Instance.pathPoints[0].position;
            Instantiate(prefab, spawnPos, Quaternion.identity);
        }
    }
}

Coroutine이란?

IEnumerator로 작성된 함수를 StartCoroutine()으로 호출하면, 해당 함수는 여러 프레임에 걸쳐 비동기적으로 실행된다.

yield return을 사용해 딜레이, 조건 대기, 프레임 건너뛰기 등을 쉽게 구현할 수 있다.

예를 들어, yield return new WaitForSeconds(0.5f);는 0.5초 뒤에 다음 줄을 실행한다.

EnemySpawner에서는 다음과 같이 사용되었다.:

  • StartCoroutine(StartWave()) → 게임 시작 시 첫 웨이브를 실행한다.
  • yield return new WaitForSeconds(spawnInterval) → 각 적을 0.5초 간격으로 생성한다.
  • yield return new WaitForSeconds(5f) → 웨이브 끝나고 5초 대기 후 다음 웨이브를 실행한다.
  • 코루틴을 사용하면 Update()에 모든 처리를 몰아넣지 않아도 되며, 시간 제어가 직관적이고 깔끔해진다.
  • 아직 보스몹은 구현하지 않았지만, 보스 생성 코드도 추가하였다.

GameManager 연동

public void AddGold(int amount)
{
    gold += amount;
}
  • EnemySpawner에서 웨이브 보상을 지급할 때 GameManager.Instance.AddGold() 호출

결과 및 문제 확인

  • 적이 0.5초 간격으로 하나씩 등장하며, 각 웨이브마다 총 30기가 생성된다.
    • 이는 Inspector창을 통해 조정 가능하다.
  • 모든 적은 PathManager가 생성한 pathPoints를 순서대로 따라 이동한다.
  • 하지만 문제가 발생했다. 내 의도는 웨이브가 끝나는 조건은 모든 Enemy 오브젝트가 사라지는 것이었는데, 웨이브가 끝나는 조건이 Enemy의 소환이 끝나고 나서 5초 뒤로 설정되어있었던 것이다.
  • 따라서 이 문제를 해결하기 위해 EnemySpawner.cs를 수정해주었다.
        yield return StartCoroutine(WaitUntilAllEnemiesDead());
   .
   .
   .

    IEnumerator WaitUntilAllEnemiesDead()
    {
        while (GameObject.FindGameObjectsWithTag("Enemy").Length > 0)
        {
            yield return null; // 다음 프레임까지 대기
        }
    }
  • EnemyTag가 있는 오브젝트가 없어질 때까지 대기하도록 설정했다.
  • 실행 결과 의도한대로 Enemy가 생성됨을 확인할 수 있었다.

회고

배운 점

Path 지정 개념

  • 2D 게임에서 경로는 일반적으로 좌표(Point) 리스트로 관리된다.
  • 두 지점 사이의 경로는 보통 직선으로 연결하거나 중간 좌표를 하나씩 계산하는 방식으로 구성한다.
  • 이동 대상(예: Enemy)은 해당 포인트 리스트를 순차적으로 따라가며 경로를 따른다.

좌표 기반 경로 오류와 해결

  • 중간 좌표를 구해야 할 때는 이동 순서가 엉킬 수 있으므로, 시작점과 끝점의 방향을 비교해 중간 좌표를 정확한 순서로 생성해야 한다.
  • 중복 제거를 위해 사용하는 자료구조는 정렬 순서를 보존하는 방식(List)이 적절하다.

코루틴 일반 사용법

  • Coroutine은 Unity에서 시간 기반 처리(딜레이, 조건 대기)에 가장 널리 사용되는 방법이다.
  • yield return new WaitForSeconds(x)로 특정 시간만큼 기다릴 수 있으며,
  • yield return null은 한 프레임 쉬기, yield return 조건은 특정 조건을 만족할 때까지 기다리는 데 쓰인다.

느낀 점

오늘도 쉽지 않은 실습이었다. 초반에 Tile 생성 및 Tower 소환 스크립트를 작성할 때만 해도 쉽게 느껴졌는데, 경로 지정할 때 의도한대로 오브젝트가 움직이지 않아서 굉장히 헤매었다.
하지만, 오브젝트의 AI 등을 설정할 때, 경로 지정은 아주 많이 쓰이므로, 지금 헤맨 게 오히려 다행이라는 생각이 든다.
또, 코루틴을 새로 배웠는데, 실제 게임 제작에 굉장히 자주 쓰일 것 같다. 항상 Invoke()를 썼었는데, 이걸 쓰지 않아도 간편하게 조건, 시간 대기를 설정할 수 있다는 점이 효율적이라고 생각한다.
내일 오전에 팀 회의를 마치고, 내일은 Tower 오브젝트의 공격 설정, UI 제작, 레벨 디자인을 해볼 생각이다. 특히 레벨 디자인은 지금까지 다루지 않았던 영역이라 새로운 영역인 만큼 더 시간을 들일 필요가 있을 것 같다.

0개의 댓글