Unity 3D/디펜스 게임

타워 디펜스 게임 (1) - 맵, 적, 이동 경로

ybbro 2025. 3. 25. 18:06

1. 맵 제작

1) 녹색 육면체 : 적이 필드로 들어오는 입구

2) 적색 육면체 : 적이 필드에서 나가는 출구

3) 흰 타일

타워 배치가 가능한 타일

타워가 올라갈 수 있는 1칸을 기준으로 나눠서 제작

4) 검은 타일

적이 지나가는 통로

길 형태를 갖추도록 콜라이더를 최소한으로 사용

 

 

2. 적이 지나가는 경로 만들기. 웨이포인트

적이 검은 타일을 따라 지나다니게 하려면

 

각 구간마다 목표가 되는 위치 좌표가 있어야 하며

길의 코너마다 위치하게 하는 게 좋겠지요.

 

위의 스크린샷에서 코너마다 있는 빨간 점이 그 포인트입니다.

적의 이동 목표인 빨간 육면체 내에도 하나 더 존재합니다.

 

잘 보이게 하기 위해 인스펙터에서 씬에서 빨간 점 표시로 변경해주었습니다.

 

using UnityEngine;
public class WayPoints : MonoBehaviour
{
    public static Transform[] points;

    private void Awake() //private는 생략이 가능
    {
        //부모 오브젝트를 통해 자식 오브젝트들의 transform 정보를 받아온다.
        points = new Transform[transform.childCount];
        for (int i = 0; i < points.Length; i++)
        {
            points[i] = transform.GetChild(i);
        }
    }
}

 

웨이포인트를 담는 부모 오브젝트에

자식인 웨이포인트들의 트랜스폼 >> 위치에 접근할 수 있는 스크립트를 붙여

적들이 이를 참조하여 이동 경로를 만들 수 있게 합니다.

3. 적

에셋을 특별히 쓰지 않고 심볼로 구형의 적 생성

>> HP바를 표시할 자식 오브젝트를 만들어 넣기

>> 프리팹 생성

>> 원본 삭제

공격 받을 때 판정을 위해 적의 형태와 같은 구형 콜라이더를 부착했습니다.

 

적의 역할에 따라 2가지 스크립트를 부착했습니다.

using UnityEngine;
using TMPro;

public class EnemyMove : MonoBehaviour
{
    //이동할 목표지점의 오브젝트
    private Transform target;

    //목표 WayPoint 인덱스
    public int wayPointIndex = 0;

    Enemy Enemy;

    // Start is called before the first frame update
    void Start()
    {
        Enemy = GetComponent<Enemy>();
        //target 설정
        wayPointIndex = 0;
        target = WayPoints.points[wayPointIndex];
    }

    // Update is called once per frame
    void Update()
    {
        //타켓까지 이동하기
        Vector3 dir = target.position - this.transform.position;
        transform.Translate(dir.normalized * Time.deltaTime * Enemy.GetEnemySpeed(), Space.World);

        float distance = Vector3.Distance(target.position, transform.position);
        if (distance <= 0.5f)
        {
            GetNextPoint();
        }
    }

    //다음 WayPoint 지점을 타겟으로 설정
    private void GetNextPoint()
    {
        //마지막 지점에 도착하면
        if (wayPointIndex == WayPoints.points.Length - 1) //마지막 인덱스 번호는 배열의 길이 - 1
        {
            // 플레이어 라이프가 0 초과인 경우 하나 차감하고
            PlayerStatus.SetLife(PlayerStatus.GetLife() != 0 ? PlayerStatus.GetLife() - 1 : 0);
            // 자신을 파괴
            Destroy(gameObject);
            return;
        }
        //마지막 지점이 아니라면 다음 목표로 설정
        wayPointIndex++;
        target = WayPoints.points[wayPointIndex];
    }
}

1) 길을 따라서 목표 지점까지 이동 > 목표에 도착했다면 플레이어 라이프 차감 및 스스로를 파괴
플레이어 라이프는 다음 포스팅에서 다루도록 하겠습니다.

 

2) 공격을 받으면 체력이 깎이다 처치되면 보상 드랍

 

체력바는 전체 체력을 나타내는 녹색 막대(Hpbar)와 잃은 체력을 나타내는 빨간 막대(Hploss)로 나뉘어집니다.
체력을 잃을 때 (잃은 체력 / 최대 체력) 비율에 맞게 x축 스케일을 늘리고 x축 위치를 이동하여 깎인 부분이 표시되게끔 합니다.

using UnityEngine;


public class Enemy : MonoBehaviour
{
    // 적의 스테이터스
    // 빔에 공격받고 있는지 여부
    bool isAttackedByBeam = false;

    // 체력에 변화가 있었는지 여부
    bool isHPChaged = false;

    // 현재 속도
    float speed;
    // 원래 속도
    float originSpeed;
    // 빔에 공격받고 있을 때 속도
    float beamSlow;


    // 현재 체력
    float hp;
    // 최대 체력
    float MaxHP;

    // 적을 잡았을 경우 얻는 골드 -> 여기서 쓴다면 다른 유형의 적이 생성될 때 확장이 용이함
    int gainGold = 50;

    // 체력바의 초록 부분
    Transform HPbar;
    // 체력바의 빨간 부분
    Transform HPloss;

    // Start is called before the first frame update
    void Start()
    {
        // 리스폰되면 최대 체력으로
        hp = MaxHP;

        // Enemy 프리팹의 자식 오브젝트들을 가져옴
        HPloss = transform.Find("HPloss");
        HPbar = transform.Find("HPbar");
    }

    // Update is called once per frame
    void Update()
    {
        // 게임 오버가 된다면 적 모두 파괴
        if (!GameManager.isPlayerAlive)
        {
            // 적 파괴 이펙트에 들어갈 색상 보내주기
            EffectManager.SetKillEffectColor(GetComponent<Renderer>().material.color);
            // 적 파괴 이펙트를 호출하고 적 자신을 파괴
            EffectManager.NeedEffect((int)EffectManager.effectIndex.KillEffect, transform.position);
            Destroy(gameObject);
        }

        // 빔에 의해 공격을 받는 도중에는 이동 속도를 느리게, 공격에서 벗어나면 원래 속도로
        if (isAttackedByBeam)
        {
            speed = beamSlow;
            isAttackedByBeam = false; // 빔에 의한 공격이 끊길 경우 원래 속도로 돌아가기 위함
        }
        else
            speed = originSpeed;

        // 다음에는 이미지로 표현해보기
        // 체력의 변화가 있었다면, HP바 표시 변경
        if(isHPChaged)
        {
            // 체력이 100%인 경우 적 HP 바 비활성화, 100% 미만일 경우 활성화
            if (!HPbar.gameObject.activeSelf)
            {
                HPbar.gameObject.SetActive(true);
                HPloss.gameObject.SetActive(true);
            }

            // 잃은 체력% 만큼 x축 방향 스케일을 키우고 그 절반만큼 이동
            float lostHpRatio = (MaxHP - hp) / MaxHP;
            float initial_PosX = 0.5f;
            HPloss.transform.localScale = new Vector3 (lostHpRatio, HPloss.transform.localScale.y, HPloss.transform.localScale.z);
            HPloss.transform.localPosition = new Vector3(initial_PosX - lostHpRatio / 2, HPloss.transform.localPosition.y, HPloss.transform.localPosition.z);
            // 다음 hp 변경 대기
            isHPChaged = false;
        }
    }

    private void OnDestroy()
    {
        // 체력이 0 이하로 내려가 파괴되었을 경우에만
        if (hp <= 0)
        {
            // 적 파괴 이펙트에 들어갈 색상 보내주고
            EffectManager.SetKillEffectColor(GetComponent<Renderer>().material.color);
            // 적 파괴 이펙트 호출
            EffectManager.NeedEffect((int)EffectManager.effectIndex.KillEffect, transform.position);
            // 파괴되었다면 골드 가산
            PlayerStatus.SetGold(PlayerStatus.GetGold() + gainGold);
        }
    }

    // 적이 공격을 받았을 때 호출 -> 대미지 계산 후 죽었는지 살았는지 판정
    public bool IsThisEnemyDied(float damage)
    {
        // 현재 체력 - 대미지
        hp -= damage;

        // 체력의 변화가 있음을 알리고
        isHPChaged = true;

        // true : 쥬금, false : 살았음
        return hp <= 0;
    }
	
    // 람다식으로 메서드를 줄여서 쓸 수 있음
    public void SetisAttackedByBeam() => isAttackedByBeam = true;
    public float GetEnemySpeed() => speed;

	// 적의 스테이터스를 설정하는 메서드
    public void SetEnemyStatus(EnemyBlueprint enemyName)
    {
        GetComponent<Renderer>().material.color = enemyName.color;
        MaxHP = enemyName.Health;
        originSpeed = enemyName.Speed;
        beamSlow = originSpeed * 0.6f;
        gainGold = enemyName.Reward;
    }
}

 

 

4. 적 생성기(스포너)

인스펙터에는 해당 이미지와 같이 적의 스텟, 웨이브 정보, 스폰 위치와 같은 정보를 작성 및 할당

 

빈 오브젝트 생성 >> 이름을 SpawnManager 로 변경 >> 스크립트 생성 후 아래처럼 작성하여 부착

 

using System;
using UnityEngine;

[Serializable]
public class EnemyBlueprint
{
    // 적의 색상, 속도, 체력, 처치했을 시 얻는 골드
    public Color color;
    public float Speed;
    public float Health;
    public int Reward;
}

[Serializable]
public class Wave
{
    public EnemyBlueprint enemySort;
    public int numOfEnemy;
    public float spawnDelayTime;
}

Serializable 클래스를 이용하여

반복적으로 필요한 데이터들의 공통 정보를 담을 수 있도록 하며

이를 인스펙터에 나타날 수 있도록 하여 인스펙터에서도 할당할 수 있게끔 하였습니다.

 

using System.Collections;
using UnityEngine;
using TMPro; //텍스트 사용

public class WaveSpawner : MonoBehaviour
{
    //적 오브젝트 프리팹
    public GameObject enemyPrefab;

    // 3종류의 적에 대한 각각의 정보
    public EnemyBlueprint simple;
    public EnemyBlueprint tough;
    public EnemyBlueprint fast;

    // 웨이브 정보
    public Wave[] waves;
    // 현재 웨이브
    int currentWave;
    // 최대 웨이브
    int WaveMax = 4;

    //시작 지점 
    public Transform start;
    //스폰 타이머 - 5초 간격
    float spawnTimer = 5f;
    // 첫 웨이브가 나올 때까지 2초
    private float countdown = 2f;

    //Text UI
    public TextMeshProUGUI countdownText;

    private void Start()
    {
        // 시작은 0번부터
        currentWave = 0;

		// 웨이브 별로 나올 적 종류
        waves[0].enemySort = simple;
        waves[1].enemySort = simple;
        waves[2].enemySort = simple;
        waves[3].enemySort = tough;
        waves[4].enemySort = fast;

        // 레벨에 따른 적 수 조정
        waves[0].numOfEnemy = PlayerStatus.curretStage;
        waves[1].numOfEnemy = PlayerStatus.curretStage*2;
        waves[2].numOfEnemy = PlayerStatus.curretStage*5;
        waves[3].numOfEnemy = PlayerStatus.curretStage*3;
        waves[4].numOfEnemy = PlayerStatus.curretStage*10;
    }

    // Update is called once per frame
    void Update()
    {
        // 게임 오버, 현재 웨이브의 적이 남아있는 상태, 레벨 클리어 상태라면 적이 더 이상 리젠되지 않도록
        if (!GameManager.isPlayerAlive || GameManager.isLevelCleared || GameObject.FindGameObjectsWithTag("Enemy").Length != 0 )
            return;
        // 마지막 웨이브이고, 적을 다 잡았다면 알려줌
        if (currentWave > WaveMax)
        {
            GameManager.isLevelCleared = true;
            return;
        }

        //타이머
        if (countdown <= 0)
        {
            StartCoroutine(SpawnWave());
            //실행문
            countdown = spawnTimer;
        }
        countdown -= Time.deltaTime;

        //text UI 연결
        //countdownText.text = countdown.ToString(); //countdown이 숫자형이라서 문자형으로 변환하기 위해 ToString
        //[1]소수점 두자리까지 끊어서 출력
        //[1-1]
        //countdownText.text = countdown.ToString("F2"); //원하는 소수점 자리수 맞춰서 Fn
        //[1-2]
        //countdownText.text = string.Format("{0:0.0}", countdown);
        //[2]소수점을 보이지 않게 출력 (int형으로 출력)
        countdownText.text = Mathf.Round(countdown).ToString();
    }

    //5초마다 wave 발생
    IEnumerator SpawnWave()
    {
        // 라운드 정보를 넘겨줘서 표시
        PlayerStatus.SetRound(currentWave+1);
        
        // 현재 웨이브 적 수에 맞게
        for (int i = 0; i < waves[currentWave].numOfEnemy; i++)
        {
            // 적을 생성하고 그 적의 스테이터스 할당
            SpawnEnemy();
            // 해당 웨이브의 발생지연 시간만큼 기다리고 다음 적 생성
            yield return new WaitForSeconds(waves[currentWave].spawnDelayTime);
        }
        // 다음 웨이브로 변경
        ++currentWave;
    }

    //시작점 위치에 enemy 1개를 생성
    private void SpawnEnemy()
    {
        GameObject thisEnemy = Instantiate(enemyPrefab, start.position, Quaternion.identity);
        thisEnemy.GetComponent<Enemy>().SetEnemyStatus(waves[currentWave].enemySort);
    }
}

+ 스포너가 이런 게임에서 보통 내부적으로 게임 플레이 시간을 사용하기에
UI에 다음 웨이브까지 남은 시간 표시까지 해줍니다.

 

 

이렇게 맵, 적, 적 생성, 이동까지 되었습니다.