본문 바로가기
Unity/Undead Survivor

코루틴(Corutine) / 적 생성(Enemy, Spawn)

by hwan91 2025. 3. 13.

Corutine

IEnumerator는 **코루틴 전용 반환형(인터페이스)**이다.
코루틴은 일반 함수와 다르게, 실행 도중 yield return을 만나면 일시 중지되었다가 나중에 다시 실행된다.
이 방식 덕분에 시간이 걸리는(프레임을 기다리는) 동작을 자연스럽게 구현 가능하다.

 

IEnumerator Example()
{
    Debug.Log("코루틴 시작");
    yield return new WaitForSeconds(2f); // 2초 기다림
    Debug.Log("2초 후 실행");
}

위 예제를 실행하면 즉시 첫 번째 로그를 출력하고, 2초 후 두 번째 로그를 출력한다.

 

yield return null;

위 코드를 만나면 한 프레임 동안 대기한 후 다음 코드가 실행된다.
이번 스크립트에선 넉백 함수에 사용되는데 불필요한 물리 충돌 문제를 방지하고, 부드러운 애니메이션을 위해 쓰였다.

 

yield return wait; // 다음 FixedUpdate까지 대기
yield return new WaitForSeconds(0.1f); // 0.1초 기다린 후 실행

위 두 방법으로 대체가 가능하다.

wait를 사용하면 FixedUpdate 주기에 맞춰 실행할 수도 있다.


Enemy.cs

적(몬스터) 행동을 제어하는 역할을 한다.

플레이어 추적, 애니메이션, 넉백, 피격 및 사망 처리를 관리하는 기능이 포함되어 있다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public float speed; // 속도
    public float health; // 현재 체력(데미지가 소수점 단위로 들어올 수 있어 float 사용)
    public float maxHealth; // 최대 체력
    public RuntimeAnimatorController[] animCon; // 애니메이터(몬스터별 다른 애니메이션을 적용할 수 있도록 배열로 저장)
    public Rigidbody2D target; // 목표(플레이어)
    bool isLive; // 살아있는지 여부

    Rigidbody2D rigid; // 물리 이동
    Collider2D coll; // 충돌 처리
    Animator anim; // 애니메이션 제어
    SpriteRenderer spriter; // 스프라이트 반전(좌우 방향 조정)
    WaitForFixedUpdate wait;// 넉백 효과에서 사용할 대기 시간(다음 FixedUpdate까지 기다리는 오브젝트)

    // 초기화
    void Awake()
    {
        rigid = GetComponent<Rigidbody2D>();
        coll = GetComponent<Collider2D>();
        anim = GetComponent<Animator>();
        spriter = GetComponent<SpriteRenderer>();
        wait = new WaitForFixedUpdate(); // 코루틴에서 대기하는 객체를 미리 만들어 성능 최적화
    }

    // 플레이어 추적 AI(물리적 업데이트이기 때문에 Update가 아닌 FixedUpdate 사용)
    void FixedUpdate()
    {
        // 게임이 종료되면
        if (!GameManager.instance.isLive)
            return;

        /* 아래 MovePosition 사용 시 불필요한 움직임(예: 피격 중 이동)을 막아야 함
         * 피격 애니메이션이 재생 중일 때 이동하면 몬스터가 끊기면서 이동하는 문제가 발생할 수 있음
         * 따라서 "몬스터가 이동해도 되는 상태인지를 먼저 확인"하고 움직이는 것이 가장 안정적 */
        // GetCurrentAnimatorStateInfo() = 현재 상태 정보를 가져오는 함수
        // IsName() = 해당 상태의 이름이 지정된 것과 같은지 확인하는 함수
        // 몬스터가 죽었거나 피격 애니메이션을 재생 중이라면
        if (!isLive || anim.GetCurrentAnimatorStateInfo(0).IsName("Hit"))
            
            return;

        // 플레이어를 향하는 방향 계산, 현재 플레이어(target)의 위치 - 현재 적(Enemy)의 위치
        Vector2 dirVec = target.position - rigid.position;
        
        /* dirVec 자체는 벡터의 방향 + 거리(크기)를 포함한 값으로
         * 플레이어와 거리가 멀어질수록 몬스터가 더 빠르게 이동하게 되는 문제가 생김
         * 이를 방지하기위해 normalized를 사용해 크기를 정규화하여 항상 1로 고정해야함 */
        // 프레임의 영향으로 결과가 달라지지 않도록 FIxedDeltaTime 사용
        Vector2 nextVec = dirVec.normalized * speed * Time.fixedDeltaTime; // 이동할 방향과 거리

        // MovePosition = 물리 기반 이동을 수행하는 함수로, 게임 오브젝트를 부드럽게 이동시키며 물리적 충돌을 감지함
        // transform.position은 즉시 위치 변경(순간이동)이며 충돌 감지도 되지 않아 MoviePosition 사용
        rigid.MovePosition(rigid.position + nextVec); // 지금 현재 위치 + 다음에 가야할 위치를 더해 이동시킴

        /* velocity를 0으로 초기화(불필요한 물리 효과를 제거하는 작업)하는 이유
         * Rigidbody2D를 사용하는 경우 이전 프레임의 속도가 남아 있을 수 있고,
         * 넉백 등 이전 프레임의 힘(Force)로 인해 예상치 못한 이동이 발생할 수 있어
         * 이동을 MovePosition()으로 직접 제어하는 경우 초기화 필요 */
        rigid.velocity = Vector2.zero;
    }

    // 좌우 방향 조정
    void LateUpdate()
    {
        // 게임이 종료되면
        if (!GameManager.instance.isLive)
            return;

        // 몬스터가 죽었다면
        if (!isLive )
            return;

        // spriter.flipX는 bool 타입 속성이기 때문에 true 또는 false를 반환
        // 플레이어가 몬스터보다 왼쪽에 있으면 flipX = true(좌우 반전)
        spriter.flipX = target.position.x < rigid.position.x;
    }

    // 몬스터가 활성화 될 때 호출
    void OnEnable()
    {
        target = GameManager.instance.player.GetComponent<Rigidbody2D>(); // 플레이어를 추적 목표로 설정

        // OnTriggerEnter2D 내 else에서 변경한 상태 초기화
        // 하드코딩 된 부분을 추후 ScriptableObject를 활용하여 초기값을 외부에서 설정할 수 있도록 변경 예정
        isLive = true;
        coll.enabled = true;
        rigid.simulated = true;
        spriter.sortingOrder = 2;
        anim.SetBool("Dead", false);
        health = maxHealth; // 체력을 풀로 회복시켜 재사용 가능하도록 설정
    }

    // 몬스터 생성 정보를 담고 있는 SpawnData 객체를 받아 초기 속성 적용
    // 외부 데이터를 받아오므로, 몬스터마다 다른 값을 가질 수 있음
    public void Init(SpawnData data)
    {
        // runtimeAnimatorController = 현재 적용된 AnimatorController
        anim.runtimeAnimatorController = animCon[data.spriteType]; // 몬스터의 애니메이션을 spriteType에 따라 변경
        speed = data.speed;
        maxHealth = data.health;
        health = data.health;
    }

    // 몬스터 피격
    void OnTriggerEnter2D(Collider2D collision)
    {
        // 사망 로직이 연달아 실행되는 것을 방지하기 위해 !isLive 조건 추가
        // 충돌한 물체가 총알(Bullet)이 아니라면 or 몬스터가 죽었다면
        if (!collision.CompareTag("Bullet") || !isLive)
            return;

        health -= collision.GetComponent<Bullet>().damage; // 총알의 데미지를 체력에서 차감
        StartCoroutine(KnockBack()); // 넉백 실행, 코루틴 함수 실행 방법 1
        //StartCoroutine("KnockBack"); // 코루틴 함수 실행 방법 2

        // 체력이 남아있다면
        if (health > 0)
        {
            anim.SetTrigger("Hit"); // 피격 부분 Animator의 SetTrigger 함수를 호출하여 상태 변경
            AudioManager.instance.PlaySfx(AudioManager.Sfx.Hit);
        }
        // 죽었다면
        else
        {
            isLive = false;
            coll.enabled = false; // Component 비활성화
            rigid.simulated = false; // rigidbody의 물리적 비활성화
            spriter.sortingOrder = 1; // Inspector 내 Order in Layer 단계 2에서 1로 하향 조정
            anim.SetBool("Dead", true); // Animation 상태 전환
            GameManager.instance.kill++; // 킬 수 카운트
            GameManager.instance.GetExp(); // 레벨 업 함수 실행

            if (GameManager.instance.isLive)
                AudioManager.instance.PlaySfx(AudioManager.Sfx.Dead);
        }
    }

    /* 코루틴(Coroutine) = 생명 주기와 비동기처럼 실행되는 함수(지연 실행 함수)
     * 코루틴은 일반적인 함수랑 다르게, 실행하면 한 번에 끝나지 않고 중간에 멈췄다가 다시 실행이 가능
     * 넉백은 물리 연산과 관련된 움직임이므로, Update()보다 FixedUpdate() 주기에 맞춰 실행하는 게 자연스러움 */
    // IEnumerator = 코루틴만의 반환형 Interface
    // 총알에 맞은 몬스터가 플레이어 반대 방향으로 튕겨나가도록(넉백) 하는 역할
    IEnumerator KnockBack()
    {
        // yield = 코루틴의 반환 키워드
        yield return null; // 다음 프레임(다음 Update)까지 대기

        Vector3 playerPos = GameManager.instance.player.transform.position; // 넉백 적용 시 몬스터가 플레이어 반대 방향으로 밀려나야 하므로 현재 플레이어 위치 필요
        Vector3 dirVec = transform.position - playerPos; // 플레이어 기준 반대 방향 = 현재 몬스터 위치 - 현재 플레이어 위치

        // dirVec는 거리(크기)가 포함된 벡터라 넉백의 힘이 플레이어와의 거리에 따라 달라지는 문제가 생길 수 있음
        // 이를 해결하기위해 normalized를 사용하여 벡터의 크기를 1로 고정해 어떤 거리에서도 일정한 넉백 효과를 유지
        // AddForce = Rigidbody2D에 힘을 추가하여 물리적인 움직임을 발생시키는 함수
        // * 3 = 넉백 강도 조절
        /* ForceMode2D 종류
         * ForceMode2D.Impluse = 순간적인 힘 (한 번만 적용됨, 넉백에 적합)
         * ForceMode2D.Force = 지속적인 힘(매 프레임 적용됨, 넉백이 이상하게 적용될 수 있음)
         * ForceMode2D.VelocityChange = 속도를 즉시 변경(중력 영향을 받지 않음) */
        rigid.AddForce(dirVec.normalized * 3, ForceMode2D.Impulse);
    }
}

Spawner.cs

몬스터를 생성(스폰)하는 역할을 한다.

몬스터 생성 위치, 생성 주기, 레벨별 몬스터 데이터를 관리하는 기능이 포함되어 있다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Spawner : MonoBehaviour
{
    public Transform[] spawnPoint; // 몬스터가 소환될 위치를 저장하는 배열 변수
    public SpawnData[] spawnData; // 몬스터 데이터를 저장하는 배열 변수
    public float levelTime; // 소환 레벨 구간을 결정하는 변수

    int level; // 게임의 현재 레벨(난이도)
    float timer; // 일정 시간 간격을 두고 몬스터를 소환하기 위한 변수

    void Awake()
    {
        // GetComponentsInChildren = 현재 객체와 그 자식 객체들에 연결된 Transform 컴포넌트를 모두 찾아 배열에 저장
        /* 이 배열의 첫 번째 요소는 부모 객체의 Transform이고, 그 뒤로 자식 객체들의 Transform이 들어감
         * 몬스터가 생성될 여러 개의 위치를 자동으로 가져옴 */
        spawnPoint = GetComponentsInChildren<Transform>();
        // 최대 게임 시간을 몬스터 데이터 배열 크기로 나누어 각 레벨별 몬스터가 등장하는 시간을 자동으로 설정
        levelTime = GameManager.instance.maxGameTime / spawnData.Length;

        /* spawnData만 초기화 하지 않은 이유는?
         * 에디터에서 프리팹을 만들 때 미리 수동으로 데이터가 입력(정적)되어 있어
         * Awake에서 다시 초기화하면 오히려 기존 값이 사라지기에 다시 초기화할 필요가 없음 */
        
        // 반면, spawnPoint는 프리팹이 아닌 현재 씬(Scene)에서 동적으로 찾는 데이터
        // 인스펙터에서 수동으로 설정할 수도 있지만 씬에 배치된 스폰 위치를 자동으로 가져오는 방식이 더 유연
    }

    void Update()
    {
        if (!GameManager.instance.isLive) // 게임이 진행 중인지 확인
            return;

        /* Time.deltaTime의 역할
         * 현재 프레임과 이전 프레임 간의 시간 차이를 나타내는 값
         * 각 프레임은 컴퓨터 성능, 그래픽 설정, 게임 복잡도 등에 따라 시간 차이가 날 수 있음

         * timer += Time.deltaTime의 의미
         * 이 코드는 각 프레임에서 경과된 시간을 timer에 계속 더해감
         * 매 프레임마다 다르게 값이 변하므로, timer는 정확한 경과 시간을 누적하게 됨

         * 이런 방식이 중요한 이유
         * 프레임 레이트에 관계없이 일정 시간 간격으로 행동을 수행하기 위해 Time.deltaTime을 사용
         * 0.2초마다 몬스터를 소환하고 싶다고 할 때, 단순히 timer += 0.2f를 하게 되면 프레임 레이트가 달라지면 소환 주기가 달라질 수 있음

         * 결론
         * 임의 프레임 레이트에 영향을 받지 않고 정확한 시간 간격을 계산할 수 있음
         * 어떤 환경에서도 일정한 주기로 실행 가능 */

        // Time.deltaTime = 프레임 간격 시간
        timer += Time.deltaTime; // 매 프레임마다 경과된 시간을 누적

        // Mathf.FloorToInt = 소수점 아래는 버리고, 정수(Int)형으로 바꾸는 함수
        // Mathf.CeilToInt = 소수점 아래는 올리고, 정수(Int)형으로 바꾸는 함수
        // Mathf.Min = 두 값 중 작은 값을 반환하는 함수
        // 게임이 진행된 시간(gameTime)을 levelTime으로 나눈 값을 level로 설정
        // 게임 시간이 levelTime만큼 증가할 때마다 level 상승
        // spawnData.Length -1로 배열의 크기를 넘지 않도록 제한
        level = Mathf.Min(Mathf.FloorToInt(GameManager.instance.gameTime / levelTime), spawnData.Length - 1); // 게임이 진행된 시간에 따라 level을 증가

        // 레벨별로 몬스터 생성 주기가 다를 수 있도록 조건문 설정
        if (timer > spawnData[level].spawnTime)
        {
            timer = 0; // 몬스터 생성 후 timer를 초기화하여 다음 스폰 주기를 기다림
            Spawn();
        }

        /* timer = 0;과 Spawn(); 위치를 바꾸면 어떻게 될까?
         * 보통은 큰 문제 없이 작동할 가능성이 높지만, 특정한 경우 아주 작은 오차가 누적될 가능성이 있음
         * 이게 매 프레임마다 누적되면 스폰 타이밍이 점점 어긋날 수 있음 */
    }

    // 소환 관련 함수 따로 생성
    void Spawn()
    {
        // 이전에 사용한 코드 2개
        // GameObject enemy = GameManager.instance.pool.Get(Random.Range(0, 2)); // 두 종류(0, 1번) 몬스터 중 랜덤
        // GameObject enemy = GameManager.instance.pool.Get(level); // 난이도에 따라 다른 몬스터
        GameObject enemy = GameManager.instance.pool.Get(0); // PoolManager의 Get 함수를 활용해 몬스터 생성
        // spawnPoint[0]은 부모 오브젝트이므로, 자식 중에서만 랜덤한 위치가 선택되게 1부터로 설정
        enemy.transform.position = spawnPoint[Random.Range(1, spawnPoint.Length)].position;
        // 생성된 enemy 오브젝트에서 Enemy 스크립트를 가져옴
        // spawnData[level] 데이터를 전달하여 현재 레벨에 해당하는 몬스터 속성을 초기화
        enemy.GetComponent<Enemy>().Init(spawnData[level]);
    }
}

// 직렬화(Serialization) = 객체 데이터를 파일, 메모리, 네트워크 전송을 위해 변환하는 과정
// MonoBehaviour를 상속받지 않아도 배열이나 리스트 형태로 인스펙터에서 직접 설정 가능
// 복잡한 데이터 구조를 쉽게 다룰 수 있음 (예: 캐릭터 능력치, 아이템 정보 등)
// 배열이나 List와 함께 사용하면 유용
[System.Serializable]
public class SpawnData
{
    // 몬스터 관련 속성 추가
    public float spawnTime; // 생성 주기
    public int spriteType; // 타입
    public int health; //  체력
    public float speed; // 이동속도
}