[C#, 유니티] Unity3D_RPG 몬스터 (FSM, ViewDector)

2022. 8. 31. 16:24코딩 2막 <C#개념편>

728x90

몬스터

RPG게임에서 빠져서는 안될 요소로 수많은 RPG게임에 유저들이 매료되고

게임의 스토리 확장과 몰입감에 있어서 필수적인 몬스터를 오늘 구현해보겠습니다

오늘 사용할 에셋은 Wolf(늑대)라는 몬스터를 적용하겠습니다


학습 주제

" 플레이어나 몬스터처럼 복잡한 것들은 가능한 상태패턴을 적용하기로 한다
이는 객체지향적으로 서로 모듈화하여 서로 다른 것들에 영향을 주지않도록 개방폐쇄원칙을 지켜야한다 "

 

몬스터는 5가지패턴의 상태패턴을 가짐 : { 가만히 있는, 추적, 공격, 맞기, 죽음 }

 

몬스터는 플레이어의 상태패턴 방식처럼 클래스형태(파일)로 빼놓는게 아닌 코루틴 상태패턴을 사용하겠습니다


코루틴(Coroutine)

Co + Routine 의 뜻을 보면 '같이 + 루틴'이라는 의미로서

프레임 단위마다 진행되는 업데이트 + 비동기식으로 같이 돌아가고 싶게 만들때 사용한다

코루틴 예시
업데이트 후 코루틴

쉽게 말해 나와 같이 일할 노예를 만들어 놓은것으로 비유할 수 있다

왜 게임은 스레드를 쓰지 않고 코루틴을 사용할까?
게임의 특성상 스레드를 사용하는것이 불리하다
게임은 진행단위가 프레임이다. 만약 스레드를 쓴다면 작업단위마다 작업내용들을 확인을 해야하므로 복잡하다
(병렬프로그램에서 적합x)

스레드는 완벽한 동결처리방법이지만 코루틴는 그렇지 않다 
코루틴은 기본적으로 싱글 프로세싱이다 

Update에서 매번 체크하기 어렵기 때문에 하나의 작업으로 하긴 하지만 분신술처럼 2개가 같이 일하는 것처럼 보이는 것이다

update()처럼 프레임 단위가 아닌 시간단위로 이벤트를 처리하는 코루틴을 쓰겠다는 말임

 

따라서 몬스터의 상태패턴을 짜기위해 각각의 상태에 맞는 코루틴을 적용하겠습니다

 

몬스터 상태패턴 구상도

코루틴의 최적화 WaitForSeconds(time)

반복주기를 늘려주어서 상태패턴의 최적화를 시켰다

예를들어 매 프레임마다 몬스터 100마리들이 플레이어를 추적하게 한다면 최적화의 문제가 생길것이다

그래서 위와 같이 0.1초 뒤에 따라오도록 한다


유한상태머신(FSM)

또는 유한오토마타(FA)라고도 부르며 유한한 개수의 상태들이 조건(주어진 입력)에 따라서

어떤 상태에서 다른 상태로 전이되거나 출력이나 액션들이 일어나게 하는 장치 혹은 모델을 말한다 

 

여러 제한된 상태가 존재하고 그 상태들이 특정 조건에 물려 서로 연결되어있는 형태이다

유한상태머신을 쓰는 이유는?
개발자 혹은 제 3자들이 해당 AI의 개념에 대해서 FSM을 통해 쉽게 확인할 수 있고, 설계가 가능하기 때문이다(직관적)
또 FSM을 통해 정해진 룰이 있어서 개발자의 입장에서 안정성있는 코드를 만들 수 있다(오류수정에 용이, 유연성)

객체지향프로그래밍의 5가지 원칙 가운데 객체지향 캡슐화(Encapsulation)라는 개념이 있다

캡슐화란 외부에서 알 필요가 없는 부분을 감춤으로 대상을 단순화하는 추상화의 한 종류이다

캡슐화가 중요한 이유는 불안정한 부분(Implementation)과 안정적인 부분(Public interface)을

분리하여 변경의 영향을 통제하기 위함이다.

캡슐화를 통해 변경 가능성이 높은 부분을 객체 내부로 추상화하면 변경을 최소화 할 수 있다

 

따라서 위처럼 유한상태머신은 캡슐화를 위반하지 않도록 public이 아닌 private를 써야한다!


몬스터가 대상을 탐지하기 위한 시야각 (게임수학 : 벡터의 내적)

몬스터는 세가지 조건을 만족하면 타겟을 볼 수 있도록 설계한다

1. 찾는 대상이 범위 내에 있는가?

2. 찾는 대상이 각도 내에 있는가?

3. 대상을 가로막는 장애물이 있는가?

 

여기서 찾는 대상이 각도안에 있는지 추적하기 위해서 알아야 할 게임수학의 꽃인 내적에 대한 소개를 간단히 하겠다

내적 (영어로 Dot Product)

유니티에서 벡터 내적 계산을 하려면 다음과 같이 쓴다

Vector3.Dot(VectorA, VectorB)

내적을 통해서 두 벡터의 방향 관계를 알 수 있다 이 점이 매우 중요한데

예를 들어 플레이어와 몬스터 간의 위치관계를 따질 때 내적을 통해 알 수 있다

cos&Theta;

그리고 게임의 시야각을 구현하기위해 쓰는 내적은 cosΘ 을 비교하여 구할 수 있다

플레이어의 방향벡터와 몬스터의 방향벡터의 내적값이 음수(-)다면 몬스터는 플레이어 뒤에 존재한다는 뜻이고 양수(+)라면 앞에 존재한다는 뜻이다

그래서 내적을 이용하면 어떤 벡터를 중심으로 정해진 각 범위 내에 물체가 존재하는지 판별이 되므로

플레이어나 몬스터의 범위 공격이나 스킬을 구현할때 유용하다!

몬스터의 현재 방향 벡터(출발지)를 v,

플레이어의 위치에서 몬스터가 플레이어에게 향하는 벡터(목적지)를 w, 몬스터의 시야각을 a라고 할때

만약 cos(B) > cos(a/2)라고 한다면!! 플레이어(A)는 몬스터(B)의 시야각 안에 있다는 의미이다!

 

view dectector 결과

 


코드리뷰

 

 

스크립트

 

1. Wolf.cs

Monster클래스를 상속받는 Wolf클래스는 5가지의 상태패턴을 갖는다

각 상태들은 코루틴을 통해 반복적으로 시간에 따라 수행하고 조건(추적요소)에 따라 상태전환이 발생한다

몬스터는 시각과 청각을 갖도록 하고 시야거리, 시야각, 장애물의 유무에 따라 플레이어를 추적할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
public class Wolf : Monster
{
    public enum State { Idle, Trace, Attack, Hit, Die }
    protected State curState;
 
    private Animator animator;
    private CharacterController characterController;
    private ViewDetector viewDetector;
 
    [SerializeField]
    private float moveSpeed = 2f;
    [SerializeField]
    private float hp = 10f;
 
 
    [Header("Targeting")]
    [SerializeField]
    private LayerMask targetLayerMask;
    [SerializeField]
    private GameObject traceTarget = null;
    [SerializeField]
    private float attackRange;
    [SerializeField]
    private GameObject attackTarget = null;
 
    private void Awake()
    {
        animator = GetComponent<Animator>();
        characterController = GetComponent<CharacterController>();
        viewDetector = GetComponent<ViewDetector>();
    }
    
    private void Start()
    {
        curState = State.Idle;
        ChangeState(curState);
    }
    
    private void ChangeState(State nextState)
    {
        StopCoroutine(curState.ToString());
        curState = nextState;
        StartCoroutine(curState.ToString());
    }
 
    public void OnFindTarget(GameObject target)
    {
        traceTarget = target;
        ChangeState(State.Trace);
    }
 
    public void OnLostTarget()
    {
        traceTarget = null;
        ChangeState(State.Idle);
    }
 
    private bool FindTraceTarget()
    {
        viewDetector.FindTarget();
        traceTarget = viewDetector.target;
        
        if (traceTarget != null)
            return true;
        else
            return false;
    }
 
    private bool FindAttackTarget()
    {
        Collider[] targets = Physics.OverlapSphere(transform.position, attackRange, targetLayerMask);
        if (targets.Length > 0)
        {
            attackTarget = targets[0].gameObject;
            return true;
        }
 
        attackTarget = null;
        return false;
    }
 
    public void TakeDamage(float damage)
    {
        hp -= damage;
        if (hp > 0)
        {
            ChangeState(State.Hit);
        }
        else
        {
            ChangeState(State.Die);
        }
    }
 
    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.blue;
        Gizmos.DrawWireSphere(transform.position, attackRange);
    }
 
    private IEnumerator Idle()
    {
        while (true)
        {
            animator.SetBool("Run Forward"false);
            Debug.Log("플레이어를 찾습니다.");
 
            if (FindTraceTarget())
            {
                ChangeState(State.Trace);
            }
            yield return new WaitForSeconds(0.1f);
        }
    }
 
    private IEnumerator Trace()
    {
        while (true)
        {
            animator.SetBool("Run Forward"true);
            Debug.Log("플레이어를 추적합니다.");
            Vector3 moveDir = traceTarget.transform.position - transform.position;
            characterController.Move(moveDir.normalized * Time.deltaTime * moveSpeed);
            transform.LookAt(traceTarget.transform.position);
            if (!FindTraceTarget())
            {
                ChangeState(State.Idle);
            }
            if (FindAttackTarget())
            {
                ChangeState(State.Attack);
            }
            yield return null;
        }
    }
 
    private IEnumerator Attack()
    {
        animator.SetTrigger("Bite Attack");
        transform.LookAt(attackTarget.transform.position);
        Debug.Log("플레이어 공격합니다.");
        yield return new WaitForSeconds(1f);
        Debug.Log("플레이어 공격이 끝났습니다.");
        ChangeState(State.Trace);
    }
 
    private IEnumerator Hit()
    {
        animator.SetTrigger("Take Damage");
        Debug.Log("맞습니다.");
        yield return new WaitForSeconds(1f);
        Debug.Log("맞는게 끝났습니다.");
        ChangeState(State.Idle);
    }
 
    private IEnumerator Die()
    {
        animator.SetTrigger("Die");
        Debug.Log("죽습니다");
        yield return new WaitForSeconds(1.5f);
        Destroy(gameObject);
    }
}
cs

시각구현

2. ViewDetector.cs

ViewDetector를 통해 눈(시각)을 얻게 된 wolf를 만들었다

찾는 대상이 각도 안에 있는가?
Cos과 각도를 비교할수는 없으니, 시야각의 반을 Cos해주어서(각도법을 호도법으로 바꾸는 작업) cos과 cos끼리 각을 비교하는데,
Cos의 성격에 따라 Cos값이 더 작은것이 더 큰 각을 갖는다는 성질을 이용하여서 시야각을 비교한다(더 큰 값이 더 작은 각도)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
 
public class ViewDetector : MonoBehaviour
{
    public GameObject target;
  
    // 시야(View Detector)를 통해 뭘 발견했나?, 시야는 몬스터만의 것이 아니다
    [SerializeField] private UnityEvent<GameObject> OnFindTarget;
    [SerializeField] private UnityEvent<GameObject> OnLostTarget;
    
 
    [Header("View Detector")]
    [SerializeField] private float viewRadius = 1f; // 시야거리
    [SerializeField, Range(0f, 360f)] private float viewAngle = 30f; // 바라보는 각도(시야각)
    [SerializeField] private LayerMask targetMask; // 누굴볼껀대?
    [SerializeField] private LayerMask obstacleMask; // 벽을 통과해서 볼수없다
 
    public void Update()
    {
        FindTarget();
    }
 
    // 세가지 조건을 모두 만족하면 타겟을 볼 수 있다!!!
    public void FindTarget()
    {
            // 1. 찾는 대상이 범위 내에 있는가?
        Collider[] targets = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        for (int i = 0; i < targets.Length; i++)
        {
            Vector3 dirToTarget = (targets[i].transform.position - transform.position).normalized;
 
            // 2. 찾는 대상이 각도 내에 있는가?
            // 대상이 시야 각도안에 있는지 확인하고 싶을때, 게임에서 '벡터의 내적'을 자주 쓴다(게임수학 내적문제)
            // 시야각안에 대상이 있는지 확인하려면 바라보는 방향의 cos값을 구하면 알 수 있다
            if (Vector3.Dot(transform.forward, dirToTarget) < Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad))
                continue;
 
            // 3. 중간에 장애물이 있는가?
            float distToTarget = Vector3.Distance(transform.position, targets[i].transform.position);
            if (Physics.Raycast(transform.position, dirToTarget, distToTarget, obstacleMask))
                continue;
 
            Debug.DrawRay(transform.position, dirToTarget * distToTarget, Color.red);
            target = targets[i].gameObject;
            OnFindTarget?.Invoke(target);
            return;
        }
        target = null;
    }
 
    public void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.white;
        Gizmos.DrawWireSphere(transform.position, viewRadius);
 
        Vector3 lookDir = AngleToDir(transform.eulerAngles.y);
        Vector3 rightDir = AngleToDir(transform.eulerAngles.y + viewAngle * 0.5f);
        Vector3 leftDir = AngleToDir(transform.eulerAngles.y - viewAngle * 0.5f);
 
        Debug.DrawRay(transform.position, lookDir * viewRadius, Color.green);
        Debug.DrawRay(transform.position, rightDir * viewRadius, Color.blue);
        Debug.DrawRay(transform.position, leftDir * viewRadius, Color.blue);
    }
    private Vector3 AngleToDir(float angle)
    {
        // 호도법
        float radian = angle * Mathf.Deg2Rad;
        return new Vector3(Mathf.Sin(radian), 0, Mathf.Cos(radian));
    }
}
 
cs

청각구현

3. SoundGenerater.cs

소음이 발생한 발원점으로부터 들을 수 있는 거리(soundRemain)영역에 있다면 추적할 수 있고

장애물이 가로막고있을때 장애물에 가려져 방해받기때문에 그 영향을 고려한 obstacleResist를 고려하였다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
public class SoundGenerater : MonoBehaviour
{
    [Header("Sound")]
    [SerializeField, Range(0, 5f)]
    private float soundRadius;
    [SerializeField, Range(0, 100f)]
    private float obstacleResist;
    [SerializeField]
    private LayerMask targetMask;
    [SerializeField]
    private LayerMask obstacleMask;
 
    public void GenerateSound()
    {
        Collider[] targets = Physics.OverlapSphere(transform.position, soundRadius, targetMask);
        for (int i = 0; i < targets.Length; i++)
        {
            Vector3 dirToTarget = (targets[i].transform.position - transform.position).normalized;
            float distToTarget = Vector3.Distance(transform.position, targets[i].transform.position);
 
            float soundRemain = soundRadius;
            RaycastHit[] hits = Physics.RaycastAll(transform.position, dirToTarget, distToTarget, obstacleMask);
            for (int j = 0; j < hits.Length; j++)
            {
                soundRemain *= (100f - obstacleResist) * 0.01f;
            }
 
            if (distToTarget < soundRemain)
            {
                targets[i].GetComponent<SoundListener>()?.DetectSound(targets[i].gameObject);
            }
        }
    }
}
cs

4. SoundListener.cs

DetectSound를 통해 소리를 듣고 파악할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
 
public class SoundListener : MonoBehaviour
{
    [SerializeField]
    private UnityEvent<GameObject> OnDetectSound;
 
    public void DetectSound(GameObject detectObject)
    {
        OnDetectSound?.Invoke(detectObject);
    }
}
cs

몬스터를 만든 결과물

 

결과

공감해주셔서 감사합니다

728x90