프로젝트/프로젝트 A

# 016 Enemy Spawn & movement

효따 2025. 8. 21. 23:57

안녕하세요 😇

 

 

오늘은 Enemy의 Spawn과 이동 로직을 작업할 예정입니다.

 

 

우선.. 조금 창피하긴 한데, 제가 적을 칭하는 영어로 Enemy를 Enumy라고 사용을 했었더라고요..

 

 

몰랐던 게 아니라.. 너무 흔한 단어여서 발음 가는 대로 작성해.. 이상할 것이라고 인지하지 못한 점 고백합니다 하하..😂

 

뭔가 적으면 적을수록 이상해지네요..

 

 


 

 

먼저 Enemy가 Spawn을 해주는 넥서스입니다.

뭔가 정적인 모델을 넣을까 하다가, 애니메이션을 통한 동적인 느낌을 연출하는 것이 더 재밌을 것 같기도 하고

 

Enemy가 소환이 될 때 애니메이션을 추가해 주는 것도 재밌을 것 같았습니다.

 

 

바로 코드를 살펴보겠습니다.

using System.Collections;
using UnityEngine;
using UnityEngine.AI;

public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private Animator nexusAnim;

    [Header("Enemy Settings")]
    [SerializeField] private float enemyMoveSpeed = 3.5f;
    [SerializeField] private float superEnemyMoveSpeed = 5.0f;

    [SerializeField] private GameObject enemyPrefab;
    [SerializeField] private GameObject superEnemyPrefab;

    [Header("Spawn Settings")]
    [SerializeField] private Transform[] spawnPoints;
    [SerializeField] private float spawnInterval = 20.0f;
    [SerializeField] private int enemiesPerWave = 6;
    [SerializeField] private int wavesUntilSuperEnemy = 3;
    [SerializeField] private float delayBetweenEnemies = 1.5f;

    [SerializeField] private int waveCount = 0;

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

    private IEnumerator SpawnEnemies()
    {
        while (true)
        {
            waveCount++;

            bool isSuperWave = (waveCount % wavesUntilSuperEnemy == 0);

            int regularCount = isSuperWave ? enemiesPerWave - 1 : enemiesPerWave;

            for (int i = 0; i < regularCount; i++)
            {
                SpawnEnemy(enemyPrefab, enemyMoveSpeed);
                nexusAnim.SetTrigger("IsAttack");
                yield return new WaitForSeconds(delayBetweenEnemies);
            }

            if (isSuperWave)
            {
                nexusAnim.SetTrigger("IsAttack");
                SpawnEnemy(superEnemyPrefab, superEnemyMoveSpeed);
            }

            yield return new WaitForSeconds(spawnInterval);
        }
    }

    private void SpawnEnemy(GameObject prefab, float speed)
    {
        if (prefab == null || spawnPoints.Length == 0) return;

        Transform spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)];
        GameObject enemy = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation);

        NavMeshAgent agent = enemy.GetComponent<NavMeshAgent>();
        if (agent != null)
        {
            agent.speed = speed;
        }
        else
        {
            Debug.Log($"Nav check Go");
        }
    }
}

 

흐름은 다음과 같습니다.

 


 

게임 시작
   ↓
Start() → SpawnEnemies() 코루틴 실행
   ↓
[웨이브 루프 시작]
   waveCount 증가
   ↓
   슈퍼 웨이브 여부 판별
   ↓
   일반 적 n명 소환 (간격: delayBetweenEnemies)
   ↓
   (슈퍼 웨이브면) 슈퍼 적 1명 소환
   ↓
   spawnInterval 대기
   ↓
[다음 웨이브 반복...]

 


 

1. 변수 선언부입니다.

    [SerializeField] private Animator nexusAnim;

    [Header("Enemy Settings")]
    [SerializeField] private float enemyMoveSpeed = 3.5f;
    [SerializeField] private float superEnemyMoveSpeed = 5.0f;

    [SerializeField] private GameObject enemyPrefab;
    [SerializeField] private GameObject superEnemyPrefab;

    [Header("Spawn Settings")]
    [SerializeField] private Transform[] spawnPoints;
    [SerializeField] private float spawnInterval = 20.0f;
    [SerializeField] private int enemiesPerWave = 6;
    [SerializeField] private int wavesUntilSuperEnemy = 3;
    [SerializeField] private float delayBetweenEnemies = 1.5f;

    [SerializeField] private int waveCount = 0;

 

enemyMoveSpeed / suiperEnemyMoveSpeed 

일반 / 슈퍼(대포) Enemy 이동 속도입니다.

 

enemyPrefab / superEnemyPrefab

소환할 프리팹입니다.

 

spawnPoints []

enumy가 등장할 위치입니다.

 

spawnInterval

웨이브 종료 후 다음 웨이브까지 기다리는 시간입니다.

 

enemiesPerWave

웨이브당 기본 적 수입니다.

 

wavesUntilSuperEnemy

해당 주기마다 슈퍼(대포) Enemy가 등장할 값입니다.

(현재는 3번째 웨이브마다)

 

delayBetweenEnemies

Enemy가 생성되는 시간 간격입니다.

 

nexusAnim

넥서스의 애니메이터입니다.

(동적인 효과를 연출하기 위함입니다.)

 

 


 

 

2. Start에서 StartCoroutine()을 활용하여 코루틴 SpawnEnemies를 실행합니다.

현재 SpawnEnemies는 넥서스가 부서지거나, 혹은 비활성화될 때까지 무한 반복 루트로 진행될 예정입니다.

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

 

 


 

 

3. SpawnEnemies 코루틴입니다.

    private IEnumerator SpawnEnemies()
    {
        while (true)
        {
            waveCount++;

            bool isSuperWave = (waveCount % wavesUntilSuperEnemy == 0);

            int regularCount = isSuperWave ? enemiesPerWave - 1 : enemiesPerWave;

            for (int i = 0; i < regularCount; i++)
            {
                SpawnEnemy(enemyPrefab, enemyMoveSpeed);
                nexusAnim.SetTrigger("IsAttack");
                yield return new WaitForSeconds(delayBetweenEnemies);
            }

            if (isSuperWave)
            {
                nexusAnim.SetTrigger("IsAttack");
                SpawnEnemy(superEnemyPrefab, superEnemyMoveSpeed);
            }

            yield return new WaitForSeconds(spawnInterval);
        }
    }

 

While(true)를 사용하여 무한 반복으로 웨이브를 돌리며

 

1회 실행 시마다 waveCount 증가, 이번 웨이브가 슈퍼 Enemy가 등장할 웨이브인지 판정합니다.

 

 

또한 SpawnEnemy() 함수를 통해 enemy pr superEney를 생성한 뒤 일정 시간을 기다려줍니다.

 

반복분 안에서는 미니언이 생성될 시간 간격을 기다려주며

 

미니언 생성이 끝난 후 다음 웨이브의 시간 간격을 기다려줍니다.

 

 


 

 

4. 실제 Enemy 소환 담당 함수입니다.

    private void SpawnEnemy(GameObject prefab, float speed)
    {
        if (prefab == null || spawnPoints.Length == 0) return;

        Transform spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)];
        GameObject enemy = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation);

        NavMeshAgent agent = enemy.GetComponent<NavMeshAgent>();
        if (agent != null)
        {
            agent.speed = speed;
        }
        else
        {
            Debug.Log($"Nav check Go");
        }
    }

 

Instantiate()를 통해 Enemy를 생성시켜 주며

 

Ememy가 이동할 스피드를 정해주었습니다.

 


 

다음은 EnemyMovement입니다.

using UnityEngine;
using UnityEngine.AI;

public class EnemyMovement : MonoBehaviour
{
    [SerializeField] private NavMeshAgent agent;
    [SerializeField] private string minionTag = "EnemyMinion";
    [SerializeField] private string turretTag = "Turret";

    [Header("Targeting Settings")]
    [SerializeField] private float stopDistance = 2.0f;
    [SerializeField] private float aggroRange = 5.0f;
    [SerializeField] private float targetSwitchInterval = 2.0f;

    private Transform currentTarget;
    private float timeSinceLastTargetSwitch = 0.0f;

    void Start()
    {
        agent.stoppingDistance = stopDistance;
        UpdateTarget();
    }

    void Update()
    {
        timeSinceLastTargetSwitch += Time.deltaTime;

        if (timeSinceLastTargetSwitch >= targetSwitchInterval)
        {
            UpdateTarget();
            timeSinceLastTargetSwitch = 0.0f;
        }

        if (currentTarget != null)
        {
            agent.SetDestination(currentTarget.position);
        }
        else
        {
            Debug.LogWarning($"{gameObject.name} has no target to move towards.");
        }
    }

    private void UpdateTarget()
    {
        Transform closestMinion = GetClosestObjectInRadius(GameObject.FindGameObjectsWithTag(minionTag), aggroRange);

        if (closestMinion != null)
        {
            currentTarget = closestMinion;
        }
        else
        {
            currentTarget = GetClosestObject(GameObject.FindGameObjectsWithTag(turretTag));
        }
    }

    private Transform GetClosestObject(GameObject[] objects)
    {
        float closestDistance = Mathf.Infinity;
        Transform closestObject = null;
        Vector3 currentPosition = transform.position;

        foreach (var obj in objects)
        {
            float distance = Vector3.Distance(currentPosition, obj.transform.position);

            if (distance < closestDistance)
            {
                closestDistance = distance;
                closestObject = obj.transform;
            }
        }
        return closestObject;
    }

    private Transform GetClosestObjectInRadius(GameObject[] objects, float radius)
    {
        float closestDistance = Mathf.Infinity;
        Transform closestObject = null;
        Vector3 currentPosition = transform.position;

        foreach (var obj in objects)
        {
            float distance = Vector3.Distance(currentPosition, obj.transform.position);

            if (distance < closestDistance && distance <= radius)
            {
                closestDistance = distance;
                closestObject = obj.transform;
            }
        }
        return closestObject;
    }
}

 

흐름은 다음과 같습니다.

 


 

게임 시작
   ↓
Start() → SpawnEnemies() 코루틴 실행
   ↓
[웨이브 루프 시작]
   waveCount 증가
   ↓
   슈퍼 웨이브 여부 판별
   ↓
   일반 적 n명 소환 (간격: delayBetweenEnemies)
   ↓
   (슈퍼 웨이브면) 슈퍼 적 1명 소환
   ↓
   spawnInterval 대기
   ↓
[다음 웨이브 반복...]

 


 

 

1. 변수 선언부입니다.

    [SerializeField] private NavMeshAgent agent;
    [SerializeField] private string minionTag = "EnemyMinion";
    [SerializeField] private string turretTag = "Turret";

    [Header("Targeting Settings")]
    [SerializeField] private float stopDistance = 2.0f;
    [SerializeField] private float aggroRange = 5.0f;
    [SerializeField] private float targetSwitchInterval = 2.0f;

    private Transform currentTarget;
    private float timeSinceLastTargetSwitch = 0.0f;

 

agent

enemy / superEnemy의 내비게이션입니다.

 

minionTag / TurretTag

타깃을 찾을 때 사용할 태그 이름입니다.

(적 미니언 / 포탑)

 

stopDistance

목표에 도달했을 때 멈출 거리입니다.

 

aggroRange

적 미니언을 감지할 수 있는 범위입니다.

 

targetSwitchInterval

타깃을 다시 탐색하는 주기입니다.

 

currentTarget

현재 이동 중인 목표입니다.

 

timeSinceLastTargetSwitch

타깃 교체를 위한 시간 누적용 변수입니다.

 

 


 

 

2.Start함수입니다.

NavMeshAgent의 멈춤 거리를 지정하며, 최초 타깃을 찾아 이동 준비를 하는 UpdateTarget() 함수를 실행합니다.

    void Start()
    {
        agent.stoppingDistance = stopDistance;
        UpdateTarget();
    }

 

 


 

 

3.Update함수입니다.

매 프레임 경과 시간을 누적하며 타깃을 재탐색하는 UpdateTarget() 함수를 실행합니다.

    void Update()
    {
        timeSinceLastTargetSwitch += Time.deltaTime;

        if (timeSinceLastTargetSwitch >= targetSwitchInterval)
        {
            UpdateTarget();
            timeSinceLastTargetSwitch = 0.0f;
        }

        if (currentTarget != null)
        {
            agent.SetDestination(currentTarget.position);
        }
        else
        {
            Debug.LogWarning($"{gameObject.name} has no target to move towards.");
        }
    }

 

 


 

 

4. UpdateTarget() 함수입니다.

    private void UpdateTarget()
    {
        Transform closestMinion = GetClosestObjectInRadius(GameObject.FindGameObjectsWithTag(minionTag), aggroRange);

        if (closestMinion != null)
        {
            currentTarget = closestMinion;
        }
        else
        {
            currentTarget = GetClosestObject(GameObject.FindGameObjectsWithTag(turretTag));
        }
    }

 

현재 범위 내에서 가장 가까운 적 미니언을 탐색하며

 

적 미니언이 없으면 가장 가까운 포탑으로 목표를 변경합니다.

 

 


 

 

5. 범위 내에 미니언을 찾을 때 실행되는 GetClosestObjectInRadius() 함수와

범위 내에 미니언이 없다면 실행될 함수 GetClosestObject() 함수입니다.

    private Transform GetClosestObject(GameObject[] objects)
    {
        float closestDistance = Mathf.Infinity;
        Transform closestObject = null;
        Vector3 currentPosition = transform.position;

        foreach (var obj in objects)
        {
            float distance = Vector3.Distance(currentPosition, obj.transform.position);

            if (distance < closestDistance)
            {
                closestDistance = distance;
                closestObject = obj.transform;
            }
        }
        return closestObject;
    }

    private Transform GetClosestObjectInRadius(GameObject[] objects, float radius)
    {
        float closestDistance = Mathf.Infinity;
        Transform closestObject = null;
        Vector3 currentPosition = transform.position;

        foreach (var obj in objects)
        {
            float distance = Vector3.Distance(currentPosition, obj.transform.position);

            if (distance < closestDistance && distance <= radius)
            {
                closestDistance = distance;
                closestObject = obj.transform;
            }
        }
        return closestObject;
    }

 

배열 내 객체들 중 가장 가까운 Transform을 반환하며

GetClosestObjectInRadius는 반경 제한 추가, GetClosestObject는 범위 제한 없이 단순 거리 기준입니다.

 

즉, 최우선순위는 enemy의 범위 내에 있는 미니언이 되며

 

미니언이 없다면 포탑으로 이동하게 됩니다.

 


 

( EnemyMinion 태그를 가진 오브젝트는 게임 시작 후 30초 뒤 자동으로 비활성화 되게 구현해 놓았습니다.)

 

 

우선은 생각 중인 게.. FindGameObjectsWithTag 같은 함수는 씬 전체에서 해당 태그를 가진 오브젝트를 탐색하기에

 

비용이 큰 연산이 들어간다고 알고 있습니다.

 

enemy만의 Manager 스크립트를 만들어서 모든 Enemy들을 리스트로 관리를 하면 될 것 같아서 작성해보고 있긴 한데..

 

구상이 쪼끔 더 필요할 것 같습니다.

 


 

public class EnemyManager : MonoBehaviour
{
    public static EnemyManager Instance;

    private List<Transform> minions = new List<Transform>();

    void Awake()
    {
        Instance = this;
    }

    public void RegisterMinion(Transform minion)
    {
        if (! minions.Contains(minion))
            minions.Add(minion);
    }

    public void UnregisterMinion(Transform minion)
    {
        if (minions.Contains(minion))
            minions.Remove(minion);
    }

    public List<Transform> GetMinions()
    {
        return minions;
    }
}

 

생성

EnemyManager.Instance.RegisterMinion(minionTransform);

 

제거

EnemyManager.Instance.UnregisterMinion(transform);

 

탐색

List<Transform> enemyMinions = EnemyManager.Instance.GetMinions();

 


 

 

또한 더 해보고 싶은(?) 앞으로 진행될 현재 작업 구상 목록들은 

 

  • enemy 및 포탑 완료 / 승리, 패배
  • 로그인 기능 (fireBase 생각 중)
  • 맵 꾸미기 (분위기 연출)
  • 드래곤 및 바론 (공격 및 버프)
  • 로비(상점 재화구매 등) - 인게임(플레이)
  • 멀티플레이 (챔피언 다수 확보)

 

이렇게 생각이 들고 있습니다.

 

 


 

 

감사합니다. 🙂