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

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

by 독기품은토끼 2025. 7. 1.
✅ 오늘의 학습 목표
1. 몬스터 구현
- 정찰 모드
- 추적 모드
- 공격 모드

 

1. Sound

들어가기에 앞서 타운 씬에서 어드벤처 씬으로 이동될 때 사운드가 그대로 유지되도록 설정해 주겠다.

public class SoundController : MonoBehaviour
{
    void Awake()
    {
        DontDestroyOnLoad(gameObject);
    }
}

 

Sound Manager가 갖고 있는 스크립트에 위 코드만 추가해 주면 된다.

보통 유니티에서는 씬이 바뀌면 그 씬에 있는 오브젝트들이 전부 삭제되고 새 씬의 오브젝트들만 살아남게 되는데

DontDestroyOnLoad는 씬이 바뀌어도 이 오브젝트는 파괴되지 않게 만들어주어 예외를 줄 수 있다.

 

2. Monster

🥕 예행 작업
1. Script 생성 (MonsterCore)

 

몬스터 배치는 기존에 만들어둔 프리팹을 활용해 줄 것이다.

다만 프리팹 안에 있는 스크립트는 다른 스크립트를 상속받고 있어서 이 부분을 좀 수정해 줄 것이다.

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

      public float hp;
      public float speed;

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

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

      public abstract void Idle();
      public abstract void Patrol();
      public abstract void Trace();
      public abstract void Attack();
  }
public class Goblin : MonsterCore
{
    void Start()
    {
      Init(10f, 3f);
    }

    protected override void Init(float hp, float speed)
    {
    base.Init(hp, speed);
    }

    public override void Idle()
    {
        Debug.Log("Idle");
    }

    public override void Patrol()
    {
        Debug.Log("Patrol");
    }

    public override void Trace()
    {
        Debug.Log("Trace");
    }

    public override void Attack()
    {
        Debug.Log("Attack");
    }
}

 

MonsterCore는 말 그대로 몬스터들의 공통 기능을 담는 부모 클래스이다.

enum MonsterState로 몬스터의 상태(IDLE, PATROL, TRACE, ATTACK)를 정의하였고 이때 초기값은 IDLE로 자동 설정돼서 따로 안 적어도 무방하다.

 

Init()은 자식 클래스에서 원하는 식으로 커스터마이징 하도록 virtual로 열어둔 초기화 함수로 자세한 내용은 아래와 같다.

 

▶ 접근 제한자

[protected]

  • 해당 클래스와 이를 상속받는 자식 클래스에서만 접근 가능한 접근 제어자
  • 외부에서 Init()을 직접 호출하는 것은 불가능하지만, 상속받은 자식 클래스에서는 자유롭게 사용할 수 있음

[virtual]

  • virtual 키워드는 이 함수가 자식 클래스에서 재정의(override)될 수 있다는 의미
  • 즉, 자식 클래스는 이 함수를 고유하게 구현할 수 있지만 필요하다면 부모의 기본 구현도 그대로 사용할 수 있음

 

1. 정찰 상태

몬스터가 3초 동안 가만히 있다가 좌우 랜덤위치로 이동하는 것을 구현하려고 한다.

 

 

우선 고블린에 가만히 있는 애니메이션 (Idle)을 생성해 주고

Bool 타입의 파라미터를 생성해서 움직임에 따라 다른 애니메이션이 구현되도록 설정해 준다.

 

public abstract class MonsterCore : MonoBehaviour
{
    protected Animator animator;

    protected virtual void Init(float hp, float speed)
    {
        animator = GetComponent<Animator>();
    }

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

 

상태를 변경하는 로직은 부모 클래스(MonsterCore)에서 공통으로 관리되도록 만들었다.

 

public class Goblin : MonsterCore
{
    private float timer;
    private float percent;

    private float ranDir;
    private float idleTime, patrolTime;

    private Vector3 startPos, endPos;

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

            patrolTime = Random.Range(1f, 5f);

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

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

    public override void Patrol()
    {
        timer += Time.deltaTime;
        percent = timer / patrolTime;

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

        if (timer >= patrolTime)
        {
            timer = 0f;

            idleTime = Random.Range(1f, 5f);

            animator.SetBool("[Bool] IsRun", false);

            ChangeState(MonsterState.IDLE);
        }
    }
}

 

  • Idle 상태에서는 일정 시간(3초) 동안 가만히 있다가, 랜덤한 방향으로 일정 거리만큼 이동하는 Patrol 상태로 전환되도록 구현
  • Patrol 상태에서는 Vector3.Lerp()를 활용하여 시작 지점부터 목표 지점까지 부드럽게 이동하도록 설정
  • 애니메이션은 [Bool] IsRun 파라미터를 사용해 이동 여부에 따라 Idle ↔ Run 애니메이션이 자연스럽게 전환되도록 구성

 

🚨 여기서 주의할 점은

상태가 전환되는 시점이 Idle → Patrol이기 때문에 위치 초기화(startPos, endPos)와 애니메이션 설정(IsRun 파라미터) 같은 초기 준비 작업도 Idle 상태에서 처리해야 한다.

Transform / Lerp 비교

 

하지만 이 코드의 문제점은 몬스터가 정찰 중 벽에 부딪히거나 낭떠러지 앞에 도달해도 무작정 그 방향으로 계속 이동하는 문제가 있다

이를 해결하기 위해 벽이나 낙하 방지용 오브젝트에 "Return"이라는 태그를 부여하고 몬스터가 해당 오브젝트에 닿았을 때 방향을 반대로 바꾸도록 개선해 줄 것이다.

 

 

벽이나 떨어지는 쪽에 아무 오브젝트를 배치해 준 다음 스프라이트 이미지를 비활성화해주고, 콜라이더가 통과되도록 설정해 준다.

 

public abstract class MonsterCore : MonoBehaviour
{
    protected float moveDir;

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

 

다른 몬스터들도 같은 방식으로 벽 충돌 처리를 적용해 주기 위해서

부모클래스에서 OnTriggerEnter2D() 함수를 통해 공통적으로 사용할 ‘방향 반전 로직’을 구현해 주었다.

  • "Return" 태그를 가진 오브젝트와 충돌 시 moveDir 값을 -1 또는 1로 반전
  • transform.localScale도 함께 반전시켜 몬스터가 바라보는 방향까지 변경

 

public class Goblin : MonsterCore
{
    private float timer;
    private float idleTime, patrolTime;

    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);

            ChangeState(MonsterState.PATROL); // 상태 전환을 일관되게 관리하고, 추적과 수정이 쉽도록
        }
    }

    public override void Patrol()
    {
        transform.position += Vector3.right * moveDir * speed * Time.deltaTime;

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

            ChangeState(MonsterState.IDLE);
        }
    }
}

 

  • Goblin 클래스는 Patrol 상태에서 Vector3.right * moveDir 방향으로 계속 이동하도록 구현되어 있고,
    이동 방향(moveDir)은 Idle 상태에서 무작위로 설정되도록 하였다.
  • 벽과 부딪히면, 부모 클래스의 OnTriggerEnter2D()에 인하여 방향이 즉시 반전되고, 그에 따라 이동 방향과 몬스터 시선 방향도 자동으로 바뀌게 된다.

정리하자면 부모 클래스에서 공통적으로 필요한 ‘방향 전환 처리’를 담당하고,

자식 클래스에서 이동 방향은 설정만 하고, 실제 반전 로직은 부모 클래스에 맡기는 로직이다.

 

 

 

2. 추적 상태

플레이어와 몬스터 사이의 거리를 계산하여 일정 거리 이내로 가까워지면 몬스터가 Trace 상태로 전환되도록 구현해 줄 것이다.

Trace 상태로 전환되면 몬스터는 플레이어를 향해 이동하며 추적 동작을 수행한다.

 

public class Goblin : MonsterCore
{
    private float traceDist = 5f;

    public override void Idle()
    {
        if (targetDist <= traceDist)
        {
            timer = 0f;
            animator.SetBool("[Bool] IsRun", true);

            ChangeState(MonsterState.TRACE);
        }
    }

    public override void Patrol()
    {
        if (targetDist <= traceDist)
        {
            timer = 0f;
            ChangeState(MonsterState.TRACE);
        }
    }

    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);

        if (targetDist > traceDist)
        {
            animator.SetBool("[Bool] IsRun", false);

            ChangeState(MonsterState.IDLE);
        }
    }
}

 

 

  • Idle & Patrol 상태에서 일정 거리 이내에 플레이어가 접근하면 Trace 상태로 전환
    targetDist <= traceDist 조건을 통해 플레이어가 감지 범위에 들어왔는지 확인한다.
  • Trace 상태에서는 플레이어 방향으로 몬스터가 이동
    target.position - transform.position 방향을 기준으로 몬스터가 따라오게 설정
  • 추적 중 거리 변화에 따라 자동으로 상태 전환 → 플레이어가 너무 멀어지면 Idle로 전환

 

🚨 기존 로직은 단순히 거리만 확인해서 Trace 상태로 전환되었기 때문에 플레이어가 고블린의 등 뒤에 있어도 추적이 시작되는 문제가 있었다.

이를 해결하기 위해 벡터 내적(Vector3.Dot)을 활용하여 플레이어가 고블린의 시야 방향 안에 있을 때만 추적이 가능하도록 수정해 주었다.

 

public abstract class MonsterCore : MonoBehaviour
{
    protected float moveDir;
    protected float targetDist;

    protected bool isTrace;

    void Update()
    {
        targetDist = Vector3.Distance(transform.position, target.position);

        Vector3 monsterDir = Vector3.right * moveDir; // 몬스터가 바라보는 방향
        Vector3 playerDir = (transform.position - target.position).normalized; // 플레이어가 몬스터를 바라보는 방향

        float dotValue = Vector3.Dot(monsterDir, playerDir);
        isTrace = dotValue < -0.5f && dotValue >= -1f;
        //isTrace = Vector3.Dot(monsterDir, playerDir) < 0; // 0보다 작으면 서로 마주보고 있는 상태
    }
}

 

▶ 벡터 내적 활용

Vector3.Dot(a, b)는 두 벡터의 방향 유사도를 나타내는 값이다.

  • 두 벡터가 같은 방향이면 +1
  • 완전 반대 방향이면 -1
  • 직각 방향(90도) 면 0

따라서 dotValue가 -0.5보다 작고 -1 이상이면 플레이어가 고블린의 정면 기준 약 60도 이내에 있다는 의미이다.

즉, 플레이어가 고블린의 앞쪽에 있을 때만 isTrace가 true가 되고, 뒤에 있을 경우에는 추적 상태로 전환되지 않는다.

 

using System.Collections;
using UnityEngine;

public class Goblin : MonsterCore
{
    public override void Idle()
    {
        if (targetDist <= traceDist && isTrace)
        {
            timer = 0f;
            animator.SetBool("[Bool] IsRun", true);

            ChangeState(MonsterState.TRACE);
        }
    }

    public override void Patrol()
    {
        if (targetDist <= traceDist && isTrace)
        {
            timer = 0f;
            ChangeState(MonsterState.TRACE);
        }
    }
}

 

이제 Idle, Patrol 상태에서 Trace 상태로 전환하는 코드에도 isTrace 조건을 함께 추가해 주면 시야 판단이 반영된다.

 

 

3. 공격 상태

마지막으로 고블린이 플레이어를 향해 공격을 수행하는 동작을 추가해 주겠다.

 

 

우선 공격 애니메이션이 수행되도록 애니메이터를 설정해 주었다

 

public abstract class MonsterCore : MonoBehaviour
{
    public float attackTime;

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

 

애니메이션 실행 및 공격 상태 유지에 필요한 시간 설정용 변수 attackTime을 부모클래스에서 선언해 주고

public class Goblin : MonsterCore
{
    private float traceDist = 5f;
    private float attackDist = 1.5f;

    private bool isAttack;

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

    protected override void Init(float hp, float speed, float attackTime)
    {
        base.Init(hp, speed, attackTime);
    }

    public override void Trace()
    {
        if (targetDist < attackDist)
        {
            ChangeState(MonsterState.ATTACK);
        }
    }

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

    IEnumerator AttackRoutine()
    {
        isAttack = true;
        animator.SetTrigger("[Trigger] Attack");
        yield return new WaitForSeconds(1f);
        animator.SetBool("[Bool] IsRun", false);

        yield return new WaitForSeconds(attackTime - 1f);
        isAttack = false;
        ChangeState(MonsterState.IDLE);
    }
}

 

 

Trace 상태에서 targetDist < attackDist 조건이 만족되면 공격상태로 전환되어 공격 애니메이션이 실행되도록 구현해 주었다.

  • Trace 상태에서 플레이어가 일정 거리 이내로 접근하면 Attack 상태로 전환
  • 공격 애니메이션을 재생하고 일정 시간 대기한 후 Idle 상태로 돌아감
  • isAttack 플래그를 활용해 공격 중복 실행을 방지

 

 

 


 

 

어제 작성한 포스팅에서 일부 코드가 잘못 복붙 되었던 것을 확인했습니다..

오늘도 전체 코드에서 일부만 잘라와서 붙여 넣은 거라 약간의 문제가 있을 수도 있습니다..

전체 코드는 강사님 노션이나 깃을 참고해 주시고 제 포스팅은 그냥 어떤 흐름으로 진행되었는지를 참고해 주시면 좋을 것 같습니다.

 

 

 

 

오늘은 수업 잘 따라가고 있었는데 갑자기 유니티 무한 로딩이 발생했다.

5분이 넘도록 로딩이 안 끝나길래 결국 강종해 주었고 수업 처음부터 다시 따라갔다..ㅠㅠ

다행히 오늘은 애니메이션 작업한 거밖에 없어서 금방 따라잡을 수 있었지만 그냥 너무 짱났다!!!

오늘따라 강사님도 자식 클래스에서 구현하다가 갑자기 부모 클래스로 가져가버리고

A 메서드에 기능 구현하다가 갑자기 B로 옮겨서 구현하고..

내가 수업에만 집중하면 잘 따라갈 수 있을 텐데 중간중간에 블로그 이미지 따느라 ㅋㅋㅋ 자꾸 놓쳤었다.....

앞으로 블로그에 그냥 코드만 올려야 할지.. 참.. 잘하고 있는 건지 잘 모르겠네 허허