골드메탈 - 언데드 서바이버 따라하기 네 번째 (영상 11 ~ 13 정리 노트)

 

유니티 게임 개발 무료 강의 - 네 번째 따라하기


강사: 골드메탈 (나무위키 링크)


강의 링크 (영상 22 개)

의욕 저하로.. 잠시 쉬다가.. 다시 11 번 영상부터.. 😑

첫번째 시간 - 몬스터 검색 구현

레이어(Layer)를 활용한다. 

레이어: 물리, 시스템 상으로 구분짓기 위한 요소

Enemy (Prefab Asset) 에서 추가한 Layer 를 지정하고, child object 에도 적용한다

Physics2D.CircleCastAll(): 원형의 캐스트를 쏘고 모든 결과를 반환하는 함수
    void FixedUpdate()
    {
        targets = Physics2D.CircleCastAll(transform.position, scanRange,
Vector2.zero, 0, targetLayer);
    }
1. 캐스팅 시작 위치
2. 원의 반지름
3. 캐스팅 방향
4. 캐스팅 길이
5. 대상 레이

Scanner.cs

public class Scanner : MonoBehaviour
{
    public float scanRange;
    public LayerMask targetLayer;
    public RaycastHit2D[] targets;
    public Transform nearestTarget;

    void FixedUpdate()
    {
        targets = Physics2D.CircleCastAll(transform.position, scanRange,
Vector2.zero, 0, targetLayer);
        nearestTarget = GetNearest();
    }

    Transform GetNearest()
    {
        Transform result = null;
        float diff = 100;

        foreach (RaycastHit2D target in targets)
        {
            Vector3 myPos = transform.position;
            Vector3 targetPos = target.transform.position;
            float curDiff = Vector3.Distance(myPos, targetPos);

            if (curDiff < diff)
            {
                diff = curDiff;
                result = target.transform;
            }
        }

        return result;
    }
}


Player GameObject 에 Scanner.cs 를 할당 후에 Scan Range 와 Target Layer 를 설정
FixedUpdate() 시마다 가장 가까운 Enemy 를 찾아서 Nearest Target 을 설정하기 때문에 비워둔다.. 
이렇게 자주 해도 괜찮은건가? 성능 문제?



Prefabs 에서 Bullet 0 선택해서 Scene 에 드래그 앤 드랍해서 편집 후에
다시 Prefabs 로 드래그 앤 드랍해서 새로운 아이템으로 등록 가능


원본과의 차이점을 보여준다.. (더해진것과 삭제된것)


편집 후에 Prefabs 로 드래그 앤 드랍한 경우에 새로운 것을 만들 수 있는 선택지가 주어진다


세 번째 시간 - 총탄 생성하기


Weapon.cs 에서 Scanner 에 있는 메소드를 쓰기 위해서 Player.cs 에 아래와 같이 Scanner 에 대한 처리를 추가해준다

Player.cs
public class Player : MonoBehaviour
{
    ...
    public Scanner scanner;
    ...

    void Awake()
    {
        ...
        scanner = GetComponent<Scanner>();
    }

직접 만든 스크립트도 컴토넌트로 동일하게 취급해서 GetComponent<>() 이용



Weapon.cs
public class Weapon : MonoBehaviour
{
    ...
    float timer;
    Player player;

    void Awake()
    {
        player = GetComponentInParent<Player>();
    }
    ...
    void Update()
    {
        switch (id)
        {
            case 0:
                transform.Rotate(Vector3.back * speed * Time.deltaTime);
                break;
            default:
                timer += Time.deltaTime;

                if (timer > speed)
                {
                    timer = 0f;
                    Fire();
                }
                break;
        }
    }

    public void Init()
    {
        switch (id)
        {
            case 0:
                speed = 150;
                Batch();
                break;
            default:
                speed = 0.3f;
                break;
        }
    }
    ...
    void Fire()
    {
        if (!player.scanner.nearestTarget)
            return;

        Transform bullet = GameManager.instance.pool.Get(prefabId).transform;
        bullet.position = transform.position;
    }
}




Weapon 1 의 Parent Object 가 Player 이기 때문에
Player player = GetComponentInParent<Player>() 를 이용해서 player 오브젝트에 있는 scanner 변수를 사용할 수 있고,  player.scanner.nearestTarget 값을 읽을 수 있다.

네 번째 시간 - 총탄 발사하기

총탄은 속도가 필요하므로 Rigidbody 2D 를 추가하고,
중력의 영향을 받지 않기 위해서 Gravity Scale 을 0 으로 한다.

Weapon.cs - 총알 (Bullet 1) 이 가장 가까운 적을 향해 날아가게 된다.
public class Weapon : MonoBehaviour
{
    ...
    void Fire()
    {
        if (!player.scanner.nearestTarget)
            return;

        Vector3 targetPos = player.scanner.nearestTarget.position;
        Vector3 dir = targetPos - transform.position;
        dir = dir.normalized;

        Transform bullet = GameManager.instance.pool.Get(prefabId).transform;
        bullet.position = transform.position;
        bullet.rotation = Quaternion.FromToRotation(Vector3.up, dir);
        bullet.GetComponent<Bullet>().Init(damage, count, dir);
    }
}


normalized : 현재 벡터의 방향은 유지하고 크기를 1로 변환된 속도
FromToRotation : 지정된 축(Vector3.up / z축)을 중식으로 목표를 향해 회전하는 함수



12 번째 영상

첫 번째 시간 - 피격 리액션





Hit 상태에 대한 처리를 Coroutine 을 이용한다 (대소문자 주의)

Coroutine : 생명 주기와 비동기처러 실행되는 함수
IEnumerator : 코루틴만의 반환형 인터페이스
yield : 코루틴의 반환 키워드

플레이어 기준의 반대 방향 처리를 위해서 KnockBack() 에서 현재 위치 - 플레이이 위치
값을 이용

AddForce 함수로 힘 가하기를 수행하지만, 방향만 가진 벡터가 되기 위해서 normalized 값 사용
순간적인 힘이므로 Impulse 속성으로 설정

GetCurrentAnimatorStateInfo() 의 parameter 는 layer index 인데, 
현재 Base Layer 만 있으니 해당 index 값인 0 으로 하면 된다

Enemy.cs

public class Enemy : MonoBehaviour
{
    ...
    WaitForFixedUpdate wait;

    void Awake()
    {
        ...
        wait = new WaitForFixedUpdate();
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        if (!isLive || anim.GetCurrentAnimatorStateInfo(0).IsName("Hit"))
        {
            return;
        }
        ...
    }

    ...

    void OnTriggerEnter2D(Collider2D collision)
    {
        if (!collision.CompareTag("Bullet"))
            return;

        health -= collision.GetComponent<Bullet>().damage;
        StartCoroutine(KnockBack()); // or, StartCoroutine("KnockBack");

        if (health > 0)
        {
            anim.SetTrigger("Hit");
        }
        else
        {
            // .. Die
            Dead();
        }
    }

    IEnumerator KnockBack()
    {
        yield return wait; // 다음 하나의 물리 프레임을 딜레이
        Vector3 playerPos = GameManager.instance.player.transform.position;
        Vector3 dirVec = transform.position - playerPos;
        rigid.AddForce(dirVec.normalized * 3, ForceMode2D.Impulse);
    }
    ...
}




두 번째 시간 - 사망 리액션


컴포넌트의 비활성화는 .enabled = false
리지드바디의 물리적 비활성화는 .simulated = false
SetBool 함수를 통해 죽는 애니메이션 상태로 전환
스프라이트 렌더러의 Sorting Order 감소 (Player 는 5)


재활용을 위해서 OnEnable 함수에서 되돌려야 한다
기존 Dead() 함수는 바로 없어지게 만들기 때문에 직접 호출하지 않고, Event 로 호출하게 한다

Enemy.cs

public class Enemy : MonoBehaviour
{
    ...
    Collider2D coll;

    void Awake()
    {
        ...
        coll = GetComponent<Collider2D>();
    }

    void OnEnable()
    {
        target = GameManager.instance.player.GetComponent<Rigidbody2D>();
        isLive = true;
        coll.enabled = true;
        rigid.simulated = true;
        spriter.sortingOrder = 2;
        anim.SetBool("Dead", false);
        health = maxHealth;
    }

    void OnTriggerEnter2D(Collider2D collision)
    {
        if (!collision.CompareTag("Bullet"))
            return;

        health -= collision.GetComponent<Bullet>().damage;
        StartCoroutine(KnockBack()); // or, StartCoroutine("KnockBack");

        if (health > 0)
        {
            anim.SetTrigger("Hit");
        }
        else
        {
            isLive = false;
            coll.enabled = false;
            rigid.simulated = false;
            spriter.sortingOrder = 1;
            anim.SetBool("Dead", true);
            Dead();
        }
    }

    IEnumerator KnockBack()
    {
        yield return wait; // 다음 하나의 물리 프레임을 딜레이
        Vector3 playerPos = GameManager.instance.player.transform.position;
        Vector3 dirVec = transform.position - playerPos;
        rigid.AddForce(dirVec.normalized * 3, ForceMode2D.Impulse);
    }

    void Dead()
    {
        gameObject.SetActive(false);
    }
}



DeadEnemy 1 과 2 의 Animation 에 Event 를 추가해준다

Animation Event 에 Dead() 메소드를 지정해준다.




세 번째 시간 - 처치 데이터 얻기


게임 메니저에 레벨, 킬수, 경험치 변수 선언
Inspector 가 지져분해니 Header (인스펙터의 속성들을 이쁘게 구분시켜주는 타이틀) 이용 

GameManager.cs
public class GameManager : MonoBehaviour
{
    public static GameManager instance;
    [Header("# Game Control")]
    public float gameTime;
    public float maxGameTime = 2 * 10f;
    [Header("# Player Info")]
    public int level;
    public int kill;
    public int exp;
    public int[] nextExp = { 3, 5, 10, 100, 150, 210, 280, 360, 450, 600 };
    [Header("# Game Object")]
    public PoolManager pool;
    public Player player;
    ...
public void GetExp()
    {
        exp++;
        if (exp == nextExp[level])
        {
            level++;
            exp = 0;
        }
    }
}



Enemy.cs

Dead 처리시에 경험치 처리 코드 추가

public class Enemy : MonoBehaviour
{
    ...

    void OnTriggerEnter2D(Collider2D collision)
    {
        if (!collision.CompareTag("Bullet") || !isLive)
            return;

        health -= collision.GetComponent<Bullet>().damage;
        StartCoroutine(KnockBack()); // or, StartCoroutine("KnockBack");

        if (health > 0)
        {
            anim.SetTrigger("Hit");
        }
        else
        {
            isLive = false;
            coll.enabled = false;
            rigid.simulated = false;
            spriter.sortingOrder = 1;
            anim.SetBool("Dead", true);
            GameManager.instance.kill++;
            GameManager.instance.GetExp();
        }
    }

    ...
}



13 번째 영상

첫 번째 시간 - UI 캔버스

일반적인 게임오브젝트는 월드 공간에 배치

유저 인터페이스는(GUI)는 스크린 공간에 배치
- UI > Canvas 메뉴로 캔버스 생성



Canvas 의 경우에 RectTransform 이 사용됨. 스크린 전용 Transform 역할 컴포넌트

* Render Mode
Screen Space - Overlay => 스크린에 그대로 얹는 형태 (Default)
Screen Space - Camera => 스크린을 카메라에 맞추는 형
World Space => 오브젝트처럼 월드 공간에 배치하는 형


여러 해상도에서 Text 의 크기를 일정하게 하기 위해서는 아래 설정을 변경해주어야 한다.


✅ UI Scale Mode를 Scale with Screen size 로 변경


Reference Resolution 은 픽셀 퍼펙트 카메라와 동일하게 적용


Match 와 Pixel Per Unit 도 적절하게 조절한 후에

강의 내용처럼 글자가 보이지 않는다면 
Alignment 를 조정해서 중간에 오도록 해줘야 함


핵심 : 어느 해상도에서나 크기를 유지하는 것이 UI의 포인트


두 번째 시간 - 스크립트 준비


UI 컴포넌트를 사용할 때는 UnityEngine.UI 네임스페이스 사용

HUD.cs
public class HUD : MonoBehaviour
{
    public enum InfoType
    {
        Exp, Level, Kill, Time, Health
    }
    public InfoType type;

    Text myText;
    Slider mySlider;

    void Awake()
    {
        myText = GetComponent<Text>();
        mySlider = GetComponent<Slider>();
    }

    void LateUpdate()
    {
        switch (type)
        {
            case InfoType.Exp:
                break;
            case InfoType.Level:
                break;
            case InfoType.Kill:
                break;
            case InfoType.Time:
                break;
            case InfoType.Health:
                break;
       }
    }
}


세 번째 시간 - 경험치 게이지

Canvas 에 Slider 추가 후에 앵커를 통해서 위치를 조정


앵커 : UI 오브젝트의 기준점 설정, 변경 시 오른쪽 속성이 달라짐

아래 키 조합으로 이용

Shift : 기준점 변경
Alt : 위치(크기) 변경

Slider componet 의 Interactable 속성을 꺼야 게이머가 값을 수정할 수 없게 된다
기타 등등 설정.... 😕


슬라이더에 적용할 값: 현재 경험치 / 최대 경험치

HUD.cs

public class HUD : MonoBehaviour
{
    ...
    void LateUpdate()
    {
        switch (type)
        {
            case InfoType.Exp:
                float curExp = GameManager.instance.exp;
                float maxExp = GameManager.instance.nextExp[GameManager.instance.level];
                mySlider.value = curExp / maxExp;
                break;
            ...
       }
    }
}



두 번째 시간 - 레벨, 킬수 텍스트

UI - Text - TextMeshPro 의 텍스트는 한글로 사용하기에 살짝 번거롭다고 한다.....
UI - Legacy - Text 메뉴로 텍스트 생성
UI - Image 에 사용된 이미지 크기가 작은 경우에 Set Native Size (오브젝트 크기를 스프라이트의 원래 크기로 변경) 를 선택한다


HUD.cs
public class HUD : MonoBehaviour
{
    public enum InfoType
    {
        Exp, Level, Kill, Time, Health
    }
    public InfoType type;

    Text myText;
    Slider mySlider;

    void Awake()
    {
        myText = GetComponent<Text>();
        mySlider = GetComponent<Slider>();
    }

    void LateUpdate()
    {
        switch (type)
        {
            case InfoType.Exp:
                float curExp = GameManager.instance.exp;
                float maxExp = GameManager.instance.nextExp[GameManager.instance.level];
                mySlider.value = curExp / maxExp;
                break;
            case InfoType.Level:
                myText.text = string.Format("Lv.{0:F0}", GameManager.instance.level);
                break;
            case InfoType.Kill:
                myText.text = string.Format("{0:F0}", GameManager.instance.kill);
                break;
            case InfoType.Time:
                break;
            case InfoType.Health:
                break;
       }
    }
}



다섯 번째 시간 - 타이머 텍스트

Level 텍스트를 복사해서 Anchor Presets 를 열어서 Shift + Alt 키로 상단 정중앙에 배치한다


HUD.cs
public class HUD : MonoBehaviour
{
    ...
    void LateUpdate()
    {
        switch (type)
        {
            ...
            case InfoType.Time:
                float remainTime = GameManager.instance.maxGameTime - GameManager.instance.gameTime;
                int min = Mathf.FloorToInt(remainTime / 60);
                int sec = Mathf.FloorToInt(remainTime % 60);
                myText.text = string.Format("{0:D2}:{1:D2}", min, sec);
                break;
            ...
       }
    }
}



여섯 번째 시간 - 체력 게이지


GameManager.cs 에 체력 변수를 추가해준다

    ...
    [Header("# Player Info")]
    public int Health;
    public int maxHealth = 100;
    ...


Canvas 에 Create Empty 하는 경우에는 RectTransform 이 들어있다
슬라이드 복사 후에 슬라이드가 부모 오브젝트와 넓이가 같도록 앵커 설정 (Shift + Alt 후에)

게임 실행시에 Slider 가 Player 와 제대로 연결이 되지 않아 플레이어를 따라가는 UI 스크립트 생성

월드 좌표와 스크린 좌표는 서로 다르기 때문에 변환해주어야 한다
Camera.main.WorldToScreenPoint() 를 이용해서... 월드 상의 오브젝트 위치를 스크린 좌표로 변환

Follow.cs
public class Follow : MonoBehaviour
{
    RectTransform rect;

    void Awake()
    {
        rect = GetComponent<RectTransform>();
    }

    void FixedUpdate()
    {
        rect.position = Camera.main.WorldToScreenPoint(GameManager.instance.player.transform.position);
    }
}




댓글

이 블로그의 인기 게시물

나도코딩 - 유니티 무료 강의 (Crash Course) 따라하기

Unity Tip 2 : Visual Studio Code 로 변경

노마드 코더 (Nomad Coders) 강의 따라하기 - KIMCHI-Run 게임