[Unity][3D-Game] Tower Defense Game (14)

suhan0304·2023년 12월 17일
0
post-thumbnail

강의영상 (16)


개발

레이저 포탑 데미지 설정

레이저 포탑에게 공격받는 적은 일정 시간마다 데미지를 받도록 구현한다. 초당 입힐 데미지를 변수로 선언한 후 Laser에 데미지 입히는 로직을 구현한다.

Turret.cs

public int damageOverTime = 30;

void UpdateTarget() //가장 가까운 적을 찾아 목표로 업데이트
{
    //매 프레임마다 모든 적을 확인하면서 업데이트하면 성능 시간 낭비
    //"1초에 2번"과 같이 검색 횟수를 제한, 타겟을 가지고 있지 않은 경우에만 탐색하는 등의 방법이 가능
    //0.5초에 한번 실행 되도록 start에 InvokeRepeating을 실행
        
    GameObject[] enenmies = GameObject.FindGameObjectsWithTag(enemyTag); //태그에 enemyTag인 오브젝트를 모두 탐색
    float shortestDistance = Mathf.Infinity; //최소거리를 구하기 위한 초기값을 Infinity로 설정
    GameObject nearestEnemy = null;

    foreach(GameObject enemy in enenmies)
    {
        //적과 내 거리를 구함
        float distanceToEnemy = Vector3.Distance(transform.position, enemy.transform.position);
        if(distanceToEnemy < shortestDistance) // 더 가까운 적을 찾았다면 nearestEnemy를 최단 거리 업데이트 후 해당 오브젝트로 설정  
        {
            shortestDistance = distanceToEnemy;
            nearestEnemy = enemy;               
        }
    }

    if (nearestEnemy != null && shortestDistance <= range) //적을 찾았고 + 사거리 안에 들어왔다면
    {
        target = nearestEnemy.transform;    //이제 목표 오브젝트를 미리 찾아놓은 적으로 설정
        targetEnemy = nearestEnemy.GetComponent<Enemy>();
    }
    else
    {
        target = null; //만족하지 않으면 target을 null로 초기화
    }
}

void Laser() //레이저 그리기
{
    //---- Damage ----
    targetEnemy.TakeDamage(damageOverTime * Time.deltaTime);
	~~~(생략)~~~
}

Enemy 움직임 모듈화

둔화 효과 구현에 앞서 기존 Enemy 코드 내에서 움직임을 담당하는 부분을 EnemyMovement라는 스크립트를 생성하고 해당 부분으로 가져와 구현해서 모듈화를 해준다.

EnemyMovement.cs

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

[RequireComponent(typeof(Enemy))]
public class EnemyMovement : MonoBehaviour
{
    private Transform target; //목표 방향
    private int wavepointIndex = 0;//현재 목표로하는 웨이포인트 인덱스

    private Enemy enemy;

    private void Start() 
    {
        enemy = GetComponent<Enemy>();

        //WayPoints의 points를 static으로 선언해놨기 때문에 바로 불러올 수 있다.
        //points를 싱글톤 디자인 패턴으로 사용하는 모습
        target = WayPoints.points[0];
    }

    void Update()
    {
        //이동해야 할 방향 ( 목표 위치 - 내 위치  )
        Vector3 dir = target.position - transform.position;

        //방향 벡터로 스피드 만큼 이동
        //방향을 단위벡터로 바꾸기 위해 normalized 진행 후 speed를 곱한만큼 진행 (프레임-시간 보정으로 deltaTime을 사용)
        transform.Translate(dir.normalized * enemy.speed * Time.deltaTime, Space.World); //World Space에서 이동


        //웨이포인트 도착 시 다음 웨이포인트로 변경
        if (Vector3.Distance(transform.position, target.position) <= 0.4f) // 웨이 포인트와 에너미의 거리가 0.4 이하면 다음 웨이 포인트로
        {
            GetNextWayPoint(); //다음 웨이포인트를 타겟으로 변경
        }
    }

    void GetNextWayPoint()
    {
        if (wavepointIndex >= WayPoints.points.Length - 1) //만약 가지고 있는 모든 웨이포인트를 방문 > 도착 지점 도달
        {
            EndPath();
            return; //아래의 다음 웨이포인트 가져오는 것을 하지 않고 바로 종료
        }

        wavepointIndex++; //다음 웨이포인트 인덱스
        target = WayPoints.points[wavepointIndex]; //다음 인덱스의 웨이포인트 오브젝트를 받아온다.
    }

    void EndPath() //경로의 끝(목표)에 도달
    {
        Destroy(gameObject); //도착지점 도착 시 오브젝트 파괴
        PlayerStats.Lives--; // lives를 1 감소
    }
}

[RequireComponent(typeof(Enemy))] 를 통해 EnemyMovement 스크립트가 정상적으로 작동하기 위해서는 Enemy 컴포넌트가 반드시 있어야한다고 선언해주는 것이다. 이를 이용해 Enemy의 여러 변수를 읽어올 때 Enemy 컴포넌트가 없어서 생기는 오류를 방지할 수 있다..

위와 같이 EnemyMovement로 기존 Enemy의 움직임을 모두 모듈화 시켰다면 Turret 스크립트는 다음과 같다.

Turret.cs

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;

public class Turret : MonoBehaviour
{
    public Transform target; // 공격할목표 오브젝트
    private Enemy targetEnemy;

    [Header("General")]

    public float range = 15f; // 사거리는 15로 설정


    [Header("Use Bullets (default)")]

    public GameObject bulletPrefab; //총알 프리팹
    public float fireRate = 1f; //초당 발사하는 탄의 개수 (공격 속도)
    private float fireCountdown = 0f; //fireRate에 맞게 공격하도록 fireCountdown을 설정한 후 해당 주기마다 공격

    [Header("Use Laser (default)")]
    public bool useLaser = false; //레이저를 사용하는 포탑인가? (기본값은 False)

    public int damageOverTime = 30;

    public LineRenderer lineRenderer; //레이저를 사용하면 라인 렌더러가 필요함
    public ParticleSystem impactEffect; //레이저 이펙트
    public Light impactLight; // 조명 이펙트

    [Header("Unity Setup Fields")]

    public string enemyTag = "Enemy";

    public Transform partToRotate; //실제로 base를 제외하고 회전될 오브젝트의 트랜스폼
    public float turnSpeed = 10f;

    public Transform firePoint; //총알이 복사되어 생성될 위치

    // Start is called before the first frame update
    void Start()
    {
        InvokeRepeating("UpdateTarget", 0f, 0.5f); //0f에 시작해서 0.5f마다 반복호출
    }

    void UpdateTarget() //가장 가까운 적을 찾아 목표로 업데이트
    {
        //매 프레임마다 모든 적을 확인하면서 업데이트하면 성능 시간 낭비
        //"1초에 2번"과 같이 검색 횟수를 제한, 타겟을 가지고 있지 않은 경우에만 탐색하는 등의 방법이 가능
        //0.5초에 한번 실행 되도록 start에 InvokeRepeating을 실행
        
        GameObject[] enenmies = GameObject.FindGameObjectsWithTag(enemyTag); //태그에 enemyTag인 오브젝트를 모두 탐색
        float shortestDistance = Mathf.Infinity; //최소거리를 구하기 위한 초기값을 Infinity로 설정
        GameObject nearestEnemy = null;

        foreach(GameObject enemy in enenmies)
        {
            //적과 내 거리를 구함
            float distanceToEnemy = Vector3.Distance(transform.position, enemy.transform.position);
            if(distanceToEnemy < shortestDistance) // 더 가까운 적을 찾았다면 nearestEnemy를 최단 거리 업데이트 후 해당 오브젝트로 설정  
            {
                shortestDistance = distanceToEnemy;
                nearestEnemy = enemy;               
            }
        }

        if (nearestEnemy != null && shortestDistance <= range) //적을 찾았고 + 사거리 안에 들어왔다면
        {
            target = nearestEnemy.transform;    //이제 목표 오브젝트를 미리 찾아놓은 적으로 설정
            targetEnemy = nearestEnemy.GetComponent<Enemy>();
        }
        else
        {
            target = null; //만족하지 않으면 target을 null로 초기화
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (target == null) {//타겟이 없으면
            if(useLaser) //레이저 포탑은 레이저를 꺼줘야 함 ( 라인 렌더러를 지워줘야 함)
            {
                if (lineRenderer.enabled)
                { 
                    lineRenderer.enabled = false; //라인 렌더러 컴포넌트를 비활성화
                    impactEffect.Stop();  //이펙트 종료
                    impactLight.enabled = false; //Light 비활성화
                }
            }
            return;
        }

        //--- 만약 target이 있다면 ---
        //target Lock On
        LockOnTarget();

        if(useLaser) //레이저를 사용하는 포탑의 경우
        {
            Laser(); //레이저 그리기
        }
        else //일반 총알을 사용하는 경우 
        {
            if (fireCountdown <= 0f)
            { //카운트 다운이 0이 되면 shoot 발사
                Shoot();
                fireCountdown = 1f / fireRate; // 1초에 fireRate 만큼 발사되도록 Countdown 설정
            }

            fireCountdown -= Time.deltaTime; //카운트 다운을 계속 줄여서 shoot이 반복되도로 ㄱ설정
        }
        
    }

    void LockOnTarget()
    {
        //target Lock On
        Vector3 dir = target.position - transform.position; //목표 방향 = 타겟 위치 - 내 위치
        Quaternion lookRotation = Quaternion.LookRotation(dir); //dir 방향을 보도록 회전하는 정도

        //유니티는 x, y, z를 오일러 각도를 기준으로 사용하고 있다. 
        //Vector3 rotation = lookRotation.eulerAngles; //따라서 우리가 원하는회전을 오일러 각도로 변환해준다.
        //윗줄 코드를 아래의 부드럽게 회전하는 코드로 수정 
        Vector3 rotation = Quaternion.Lerp(partToRotate.rotation, lookRotation, Time.deltaTime * turnSpeed).eulerAngles;
        //partToRate의 회전에서 lookRotation의 회전까지 turnSpeed 단위로 변경되면서 회전을 내보내면 해당 회전을 오일러 각도로 변환해서 rotation Vector에 저장함.

        //y축을 중심으로만 회전하기를 원하기 때문에 y회전 정도만 불러와서 사용한다.
        partToRotate.rotation = Quaternion.Euler(0f, rotation.y, 0f); 
    }
    void Laser() //레이저 그리기
    {
        //---- Damage ----
        targetEnemy.TakeDamage(damageOverTime * Time.deltaTime);


        //----- Lase Graphic -----

        if (!lineRenderer.enabled)
        {
            lineRenderer.enabled = true; //레이저(라인 렌더러)가 꺼져있으면 키고 나서 위치 설정
            impactEffect.Play(); //파티클 시스템 재생
            impactLight.enabled = true; //Light 활성화
        }

        lineRenderer.SetPosition(0, firePoint.position); //시작점을 Fire Point로 
        lineRenderer.SetPosition(1, target.position); //끝점을 Fire Point로 

        Vector3 dir = firePoint.position - target.position; //총구로부터 타겟으로의 방향 벡터

        impactEffect.transform.position = target.position + dir.normalized * 1f;   //파티클 위치를 타겟 위치에서 살짝 터렛 방향으로 이동 시킨 위치로 생성
                                                                                    //레이저 이팩트가 적의 표면에 위치하도록 (중심에 위치X)
        impactEffect.transform.rotation = Quaternion.LookRotation(dir); //이펙트의 각도를 해당 방향 벡터로 설정
    }
    void Shoot()
    {
        GameObject bulletGO = (GameObject)Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);
        Bullet bullet = bulletGO.GetComponent<Bullet>(); //복사한 bullet 오브젝트에서 bullet 컴포넌트를 가져옴

        if (bullet != null)
        {
            bullet.Seek(target); //bullet이 존재한다면 target 오브젝트를 넘겨줌
        }
    }


    private void OnDrawGizmosSelected() //기즈모를 그려주는 유니티 함수
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, range); //내 위치를 기준으로 range를 반지름을 구를 그려줌
    }
}

이제 EnemyMovement를 Enemy 프리팹의 컴포넌트에 추가해준 후 잘 작동하는지 확인한다.

레이저 포탑 둔화 효과

레이저 포탑에게 공격받은 Enemy 오브젝트의 이동속도가 감소하도록 로직을 구현해보자.

Turret.cs

void Laser() //레이저 그리기
{
    //---- Damage ----
    targetEnemy.TakeDamage(damageOverTime * Time.deltaTime); //초당 데미지 입력
    targetEnemy.Slow(slowPct); //둔화 효과 실행
    
    ~~(생략)~~
}

Enemy.cs

public void Slow(float pct) { //둔화 효과
    speed = startSpeed * (1f - pct); // (1 - 둔화율) * 현재 이동속도 = 이동속도
}

EnemyMovement.cs

void Update()
{
    //이동해야 할 방향 ( 목표 위치 - 내 위치  )
    Vector3 dir = target.position - transform.position;

    //방향 벡터로 스피드 만큼 이동
    //방향을 단위벡터로 바꾸기 위해 normalized 진행 후 speed를 곱한만큼 진행 (프레임-시간 보정으로 deltaTime을 사용)
    transform.Translate(dir.normalized * enemy.speed * Time.deltaTime, Space.World); //World Space에서 이동


    //웨이포인트 도착 시 다음 웨이포인트로 변경
    if (Vector3.Distance(transform.position, target.position) <= 0.4f) // 웨이 포인트와 에너미의 거리가 0.4 이하면 다음 웨이 포인트로
    {
        GetNextWayPoint(); //다음 웨이포인트를 타겟으로 변경
    }

    enemy.speed = enemy.startSpeed; //둔화 효과를 초기화하기 위한 속도 리셋
}

[HideInInspector] 인스펙터 창으로 변수가 노출되지 않도록 한다. ( public이면 다른 스크립트에서 여전히 접근 가능하다. )

위와 같은 로직의 기본 원리는 Turret에서 Target오브젝트, 즉 Enemy 오브젝트에게 slow라는 함수를 호출하도록 보내면, Enemy는 Slow를 실행한다. 그럼 Speed가 감소되게 되고, 이후에 EnemyMovement에서 감소된 Speed로 Update문이 돌아가면서 느린 속도로 프레임 단위 이동 후에 다시 원래 속도로 돌아온다. 이 때 중요한 점은 "그렇다면 EnemyMovement의 Update문이 먼저 돌고 Enemy의 Slow문이 돌아서 속도가 느려지기 전에 움직이면 둔화 효과가 없어지나요?"라고 할 수 있는데 실제로는 그렇지 않다. 여러 Update문이 동시에 돌아가는 것이 아니라 Unity에서 지정한 계층 구조의 순서대로 순차적으로 돌아가기 때문에 실제로는 문제가 없다. 만약 EnemyMovement가 먼저 실행되어 움직인 후에 속도가 느려진다고 하더라도 그 다음 프레임 연산에서 느려진 속도로 움직인 후에 속도 초기화, 다시 Slow가 반복되기 때문에 실제로는 영향이 없다.

만약에 위의 문제를 완전히 해결하고 싶다면, Edit > Project Settings에서 Scipt Excution Order에서 스크립트의 실행 순서를 미리 지정해줄 수 있다.

위와 같이 Enemy, EnemyMovement 간의 순서도 설정해줄 수 있다.

결과물

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글