Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(33일차) - 2D 플랫포머 게임 (8)

by 독기품은토끼 2025. 7. 2.
✅ 오늘의 학습 목표
1. 몬스터 FSM 코드 정리
2. 체력바 구현
3. 인벤토리 UI 만들기

 

1. FSM 마무리

기존 코드는 몬스터 상태(IDLE, PATROL, TRACE 등)에 따라 거리 계산이나 방향 체크를 각각 따로 처리했었는데,
상태마다 같은 계산을 반복하게 되니까 코드가 지저분해지고 성능 측면에서도 좀 좋지 않아서 코드를 코루틴으로 다듬어주었다.

 

using UnityEngine;

public abstract class MonsterCore : MonoBehaviour
{
    public enum MonsterState { IDLE, PATROL, TRACE, ATTACK }
    public MonsterState monsterState = MonsterState.IDLE; // 안 적어도 IDLE(가장 앞)이 기본값

    protected Animator animator;
    protected Rigidbody2D monsterRb;
    protected Collider2D monsterColl;

    public Transform target;

    public float hp;
    public float speed;
    public float attackTime;

    protected float moveDir;
    protected float targetDist;

    protected bool isTrace;

    protected virtual void Init(float hp, float speed, float attackTime)
    {
        this.hp = hp;
        this.speed = speed;
        this.attackTime = attackTime;

        target = GameObject.FindGameObjectWithTag("Player").transform; // 태그가 적을 경우 사용
        // target = FindFirstObjectByType<KnightController_Keyboard>().transform; // 일반적으로 찾는 방법

        animator = GetComponent<Animator>();
        monsterRb = GetComponent<Rigidbody2D>();
        monsterColl = GetComponent<Collider2D>();
    }

    void Update()
    {
        switch (monsterState)
        {
            case MonsterState.IDLE:
                Idle();
                break;
            case MonsterState.PATROL:
                Patrol();
                break;
            case MonsterState.TRACE:
                Trace();
                break;
            case MonsterState.ATTACK:
                Attack();
                break;
        }
    }

    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Return"))
        {
            moveDir *= -1;
            transform.localScale = new Vector3(moveDir, 1, 1);
        }
    }

    public abstract void Idle();
    public abstract void Patrol();
    public abstract void Trace();
    public abstract void Attack();

    public void ChangeState(MonsterState newState)
    {
        if (monsterState != newState) // 몬스터 State가 새로 설정하는 State와 다를경우
            monsterState = newState;
    }
}
using System.Collections;
using UnityEngine;

public class Goblin : MonsterCore
{
    private float timer;
    private float idleTime, patrolTime;
    // private float percent;
    // private Vector3 startPos, endPos;

    private float traceDist = 5f;
    private float attackDist = 1.5f;

    private bool isAttack;

    void Start()
    {
        Init(10f, 3f, 2f);
        StartCoroutine(FindPlayerRoutine());
    }

    protected override void Init(float hp, float speed, float attackTime)
    {
        base.Init(hp, speed, attackTime);
        idleTime = Random.Range(1f, 5f);
    }

    // 거리 체크 통합 & 업데이트문(200 FPS) 자원 낭비로 인하여 코루틴으로 거리 계산 처리 - 몬스터의 선공이 좀 늦을 수 있음
    IEnumerator FindPlayerRoutine()
    {
        while (true)
        {
            yield return new WaitForSeconds(0.02f); // 50 FPS
            targetDist = Vector3.Distance(transform.position, target.position);

            if (monsterState == MonsterState.IDLE || monsterState == MonsterState.PATROL)
            {
                Vector3 monsterDir = Vector3.right * moveDir; // 몬스터가 바라보는 방향
                Vector3 playerDir = (transform.position - target.position).normalized; // 플레이어가 몬스터를 바라보는 방향
                float dotValue = Vector3.Dot(monsterDir, playerDir);
                isTrace = dotValue < -0.5f && dotValue >= -1f; // 고블린이 플레이어를 바라보고 있는 경우

                if (targetDist <= traceDist && isTrace)
                {
                    animator.SetBool("[Bool] IsRun", true);
                    ChangeState(MonsterState.TRACE);
                }
            } 
            else if (monsterState == MonsterState.TRACE)
            {
                if (targetDist > traceDist)
                {
                    timer = 0f;
                    idleTime = Random.Range(1f, 5f);
                    animator.SetBool("[Bool] IsRun", false);

                    ChangeState(MonsterState.IDLE);
                }

                if (targetDist < attackDist)
                {
                    ChangeState(MonsterState.ATTACK);
                }
            }
        }
    }


    public override void Idle()
    {
        // 3초동안 가만히 있으면 정찰 상태로 변경
        timer += Time.deltaTime;
        if (timer >= idleTime)
        {
            timer = 0f;
            moveDir = Random.Range(0, 2) == 1 ? 1 : -1;
            transform.localScale = new Vector3(moveDir, 1, 1);
            patrolTime = Random.Range(1f, 5f);
            animator.SetBool("[Bool] IsRun", true);

            //startPos = transform.position;
            //endPos = startPos + Vector3.right * moveDir * patrolTime;

            // monsterState = MonsterState.PATROL; // // 직접 바꾸는 방식 (지양)
            ChangeState(MonsterState.PATROL); // 상태 전환을 일관되게 관리하고, 추적과 수정이 쉽도록
        }
    }

    public override void Patrol()
    {
        transform.position += Vector3.right * moveDir * speed * Time.deltaTime;
        // transform.position = Vector3.Lerp(startPos, endPos, percent); // 출발지점, 목표 위치, 비율

        timer += Time.deltaTime;
        if (timer >= patrolTime)
        {
            timer = 0f;
            idleTime = Random.Range(1f, 5f);
            animator.SetBool("[Bool] IsRun", false);

            ChangeState(MonsterState.IDLE);
        }
    }

    public override void Trace()
    {
        var targetDir = (target.position - transform.position).normalized;
        transform.position += Vector3.right * targetDir.x * speed * Time.deltaTime;

        var scaleX = targetDir.x > 0 ? 1 : -1;
        transform.localScale = new Vector3(scaleX, 1, 1);
    }

    public override void Attack()
    {
        if (!isAttack)
            StartCoroutine(AttackRoutine());
    }

    IEnumerator AttackRoutine()
    {
        // 공격 애니메이션 실행
        isAttack = true;
        animator.SetTrigger("[Trigger] Attack");
        float currAnimLength = animator.GetCurrentAnimatorClipInfo(0).Length; // Anim이 짧거나 Exit Time을 주의할 것
        yield return new WaitForSeconds(currAnimLength);

        // Idle 애니메이션 실행 & 타겟 바라보도록
        animator.SetBool("[Bool] IsRun", false);
        var targetDir = (target.position - transform.position).normalized;
        var scaleX = targetDir.x > 0 ? 1 : -1;
        transform.localScale = new Vector3(scaleX, 1, 1);
        yield return new WaitForSeconds(attackTime - 1f); // 몬스터의 공격 속도에 따라 애니메이션 Idle

        isAttack = false;
        animator.SetBool("[Bool] IsRun", true);
        ChangeState(MonsterState.TRACE);
    }
}

 

  • 0.02초마다 감지 루프 실행 (약 50FPS 속도)
  • 현재 몬스터 상태가 IDLE 또는 PATROL이면 몬스터 방향과 플레이어 방향을 비교해서 추적 여부 판단
  • 거리랑 시야 조건 만족하면 TRACE 상태로 전환
  • TRACE 상태일 땐 너무 멀어지면 다시 IDLE / 가까워지면 ATTACK 상태로 전환

이렇게 되니까 감지 로직이 자연스럽게 흐르고 거리나 방향 계산도 최소한만 수행하게 돼서 훨씬 효율적이다.

 

2. 체력바

이제 몬스터한테 공격 판정도 넣고, 체력바랑 드롭 아이템 기능도 추가해주겠다.

 

1. 데미지

 

우선 몬스터가 실제로 플레이어에게 데미지를 줄 수 있어야 하므로

공격 애니메이션 안에 Collider를 넣고, 애니메이션 타이밍에 맞춰서 Collider를 On/Off 해주었다.

 

 

그리고 데미지를 입을 때마다 체력 게이지가 낮아지는 것을 시각적으로 표현하기 위해서 Hp Bar UI를 만들어 주었다.

 

public interface IDamageable
{
    void TakeDamage(float damage);
    void Death();
}

 

공격 대상이 될 수 있는 오브젝트는 IDamageable 인터페이스를 구현하게 만들었다.

모든 데미지 처리 로직은 TakeDamage()와 Death()로 통일할 수 있다는 뜻이다.

 

public abstract class MonsterCore : MonoBehaviour
{
    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.GetComponent<IDamageable>() != null)
        {
            other.GetComponent<IDamageable>().TakeDamage(atkDamage);
        }
    }
}

 

몬스터가 플레이어를 공격할 때 OnTriggerEnter2D() 안에서 IDamageable 인터페이스가 붙은 오브젝트를 찾아 TakeDamage()를 호출하는 방식으로 공격 판정을 처리해 주었다.

 

public class KnightController_Keyboard : MonoBehaviour, IDamageable
{
    public float hp = 100f;
    public float currHp;

    void Start()
    {
        currHp = hp;
        hpBar.fillAmount = currHp / hp; // 1
    }

    public void TakeDamage(float damage)
    {
        currHp -= damage;

        hpBar.fillAmount = currHp / hp;

        if (currHp <= 0f)
            Death();
    }
}

 

hpBar.fillAmount 값을 조절해서 체력바가 점점 줄어들게 시각적으로 표현했다.

 

 

 

2. 쿨다운

다음으로는 공격 애니메이션이 끝나자마자 다음 공격을 하게 되면 너무 부자연스럽기 때문에

애니메이션 길이를 기반으로 쿨다운을 줘서 약간의 텀이 생기도록 해주었다.

public class Goblin : MonsterCore
{
    IEnumerator AttackRoutine()
    {
        // 공격 애니메이션 실행
        isAttack = true;
        animator.SetTrigger("[Trigger] Attack");
        float currAnimLength = animator.GetCurrentAnimatorStateInfo(0).length; // Anim이 짧거나 Exit Time을 주의할 것
        yield return new WaitForSeconds(currAnimLength);

        // Idle 애니메이션 실행 & 타겟 바라보도록
        animator.SetBool("[Bool] IsRun", false);
        var targetDir = (target.position - transform.position).normalized;
        var scaleX = targetDir.x > 0 ? 1 : -1;
        transform.localScale = new Vector3(scaleX, 1, 1);
        hpBar.transform.localScale = new Vector3(scaleX, 1, 1);
        yield return new WaitForSeconds(attackTime - 1f); // 몬스터의 공격 속도에 따라 애니메이션 Idle

        isAttack = false;
        animator.SetBool("[Bool] IsRun", true);
        ChangeState(MonsterState.TRACE);
    }
}

 

GetCurrentAnimatorStateInfo() 메서드를 활용하여 애니메이션의 길이를 가져와 그 길이(시간)만큼 쿨다운이 동작되도록 해주었다.

 

🚨 여기서 주의할 점은 GetCurrentAnimatorStateInfo()  메서드는 애니메이션의 길이가 짧거나 Exit Time이 체크 해제되어 있을 경우 제대로 동작하지 않을 수 있으므로 유니티 에디터에서 설정 값을 잘 확인해주어야 한다.

 

public class KnightController_Keyboard : MonoBehaviour, IDamageable
{
    public void Death()
    {
        animator.SetTrigger("[Trigger] Death");
        knightCol.enabled = false; // 계속 공격하기 때문에 콜라이더를 꺼버림
        knightRb.gravityScale = 0f; // 콜라이더를 해제해주었기 때문에 중력때문에 떨어짐 -> 중력을 0으로 설정
    }
}

 

이제 플레이어의 체력이 0이 되면 Death 애니메이션이 실행되도록 구현해 주었고,

Collider를 비활성화해서 몬스터가 더 이상 공격하지 못하도록 해주었다.

이때 콜라이더를 해제해 주면 플레이어 컴포넌트에 RigidBody가 적용되어 있어 중력 영향을 받기 때문에 중력 값도 0으로 설정해 주었다.

 

 

 

이번엔 고블린도 플레이어처럼 공격을 받았을 때 체력이 줄어들고, 죽으면 애니메이션이 실행되도록 만들어주겠다.

 

public abstract class MonsterCore : MonoBehaviour, IDamageable
{
    protected Animator animator;
    protected Rigidbody2D monsterRb;
    protected Collider2D monsterColl;
    public Image hpBar;

    private bool isDead;

    protected virtual void Init(float hp, float speed, float attackTime, float atkDamage)
    {
        animator = GetComponent<Animator>();
        monsterRb = GetComponent<Rigidbody2D>();
        monsterColl = GetComponent<Collider2D>();

        currHp = hp;
        hpBar.fillAmount = currHp / hp;
    }

    void Update()
    {
        if (isDead)
            return;
        }
    }

    public void TakeDamage(float damage)
    {
        currHp -= damage;

        hpBar.fillAmount = currHp / hp;

        if (currHp <= 0f)
            Death();
    }

    public void Death()
    {
        isDead = true;
        animator.SetTrigger("[Trigger] Death");
        monsterColl.enabled = false; // 계속 공격하기 때문에 콜라이더를 꺼버림
        monsterRb.gravityScale = 0f; // 콜라이더를 해제해주었기 때문에 중력때문에 떨어짐 -> 중력을 0으로 설정
    }
}
public class Goblin : MonsterCore
{
    void Start()
    {
        Init(30f, 3f, 2f, 10f);
        StartCoroutine(FindPlayerRoutine());
    }

    protected override void Init(float hp, float speed, float attackTime, float atkDamage)
    {
        base.Init(hp, speed, attackTime, atkDamage);
        idleTime = Random.Range(1f, 5f);
    }
}

 

플레이어와 마찬가지로 몬스터도 IDamageable을 구현해 주었고

Hp Bar와 Death 애니메이션을 모두 구현해 주었다.

 

public class KnightController_Keyboard : MonoBehaviour, IDamageable
{
    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Monster"))
        {
            if (other.GetComponent<IDamageable>() != null)
            {
                other.GetComponent<IDamageable>().TakeDamage(atkDamage);
                other.GetComponent<Animator>().SetTrigger("[Trigger] Hit");
            }
        }
    }

 

몬스터를 피격 시 Hit 애니메이션이 실행되도록 Knight 스크립트도 수정해 주었다.

 

 

 

3. 아이템

이제 몬스터가 죽을 때 아이템을 드랍하고

그 아이템을 주워 인벤토리 로직을 구현해주려고 한다.

🥕 예행 작업
1. Script 생성 (ItemManager, IItemObject, HpPotion)

 

public abstract class MonsterCore : MonoBehaviour, IDamageable
{
    public void Death()
    {
        isDead = true;
        animator.SetTrigger("[Trigger] Death");
        monsterColl.enabled = false; // 계속 공격하기 때문에 콜라이더를 꺼버림
        monsterRb.gravityScale = 0f; // 콜라이더를 해제해주었기 때문에 중력때문에 떨어짐 -> 중력을 0으로 설정
        
        itemManager.DropItem(transform.position);
    }
}

 

몬스터가 죽으면 itemManager.DropItem()을 호출해서 아이템을 드롭하도록 처리해 줬다.

 

 

public class ItemManager : MonoBehaviour
{
    [SerializeField] private GameObject[] items;
    public void DropItem(Vector3 dropPos)
    {
        var randomX = Random.Range(0, items.Length);

        GameObject item = Instantiate(items[randomX], dropPos, Quaternion.identity);

        Rigidbody2D itemRb = item.GetComponent<Rigidbody2D>();

        itemRb.AddForceX(Random.Range(-2f, 2f), ForceMode2D.Impulse);
        itemRb.AddForceY(3f, ForceMode2D.Impulse);

        float ranPower = Random.Range(-1.5f, 1.5f);
        itemRb.AddTorque(ranPower, ForceMode2D.Impulse);
    }
}

 

드롭 로직은 이전에 만들었던 SpanManager 코드를 가져와서 약간 수정해서 사용했다.

 

 

public interface IItemObject
{
    ItemManager Inventory { get; set; }
    GameObject Obj { get; set; }
    Sprite Icon { get; set; }
    string ItemName { get; set; }

    void Get();
    void Use();
}

 

아이템 데이터를 다루기 위해 IItemObject 인터페이스도 만들어주었다.

Get()으로 아이템 획득, Use()로 아이템 사용 등의 공통 로직을 인터페이스로 묶어두면 관리가 쉬워진다.

 

 

public class HpPotion : MonoBehaviour, IItemObject
{
    public ItemManager Inventory { get; set; }
    public GameObject Obj { get; set; }
    public string ItemName { get; set; }
    public Sprite Icon { get; set; }

    void Start()
    {
        Inventory = FindFirstObjectByType<ItemManager>();

        Obj = gameObject;
        ItemName = name;
        Icon = GetComponent<SpriteRenderer>().sprite;
    }

    public void Get()
    {
        gameObject.SetActive(false);

        Inventory.GetItem(this);
    }

    public void Use()
    {
        Debug.Log("HP 포션 사용");
    }

    void OnCollisionEnter2D(Collision2D other)
    {
        if (other.gameObject.CompareTag("Player"))
        {
            Get();
        }
    }
}

 

HpPotion 클래스는 실제 드롭 아이템 중 하나다.

아이템이 플레이어와 닿으면 Get() 메서드를 호출하고,
이 안에서는 아이템을 인벤토리로 옮기면서 화면에서는 더 이상 보이지 않도록 SetActive(false) 처리해 주었다.

 

using UnityEngine;

public class ItemManager : MonoBehaviour
{
    public void GetItem(IItemObject item)
    {
        // 인벤토리에 넣는 기능
    }
}

 

GetItem() 내부 로직은 아직 구현하지 않았고,
인벤토리 UI와 연결해서 슬롯에 아이템을 넣는 방식으로 확장할 예정이다.

 

 

 

오늘은 인벤토리 UI 틀만 만들어두었고, 실제로 아이템을 저장하거나 사용하는 기능은 내일 마무리될 것 같다.

 


 

 

오늘은 코드의 흐름이라고 해야하나.. 암튼 어떻게 흘러가는지에 있어서 조금 혼란이 왔었다

워낙에 코드가 많이 중구난방이었고 불필요한 중복코드가 많았던지라 흠.. 이게 최선인 코드인가? 의심된 적이 많았다..

주석문을 꼼꼼히 달아뒀는데도 지웠다 옮겼다를 반복하니 어느 순간 이해보단 따라가기만 하고 있어서 오늘 나의 수업 태도?는 조금 아쉬웠다ㅠ 그래도 최선을 다 했다.. ^-^