[ Pattern ] State Pattern(상태 패턴) in Unity C#
[ Programming Pattern 시리즈 ]
싱글턴 패턴 (Singleton Pattern) Link
컴포넌트 패턴 (Component Pattern) Link
커멘드 패턴 (Command Pattern) Link
관찰자 패턴 (Observer Pattern) Link
상태 패턴 (State Pattern) Link
CS 전공이라면 오토마타 수업은 한번 쯤 들어보거나, 한 다리 건너 들어 보긴 했을 것이다. 유한 상태 기계 (Finite State Machine)는 State 패턴의 개념과 동일하다. 상태 패턴의 GoF 정의는 아래와 같다.
“객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다.”
유니티의 Animator Controller는 FSM, 즉 상태 패턴의 전형적인 구현이다.
상태 패턴은 여러 방식으로 구현이 가능하다. 아래는 간단하게 구현한 하나의 예이다. / Original Source Link
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 |
using UnityEngine; using System.Collections; public class CoMiner : MonoBehaviour { public enum State { EnterMineAndDigForNuggets, EnterBankAndDepositGold } public State state; public void Awake() { state = State.EnterMineAndDigForNuggets; // Start the Finite State Machine StartCoroutine(FSM()); } IEnumerator FSM() { // Execute the current coroutine (state) while (true) yield return StartCoroutine(state.ToString()); } IEnumerator EnterMineAndDigForNuggets() { /* This part works as the Enter function of the previous post (it's optional) */ print("Entering the mine..."); yield return null; /* Now we enter in the Execute part of a state which will be usually inside a while - yield block */ bool dig = true; int digged = 0; while (dig) { print("Digging... " + (digged++) + " " + Time.time); if (digged == 2) dig = false; yield return new WaitForSeconds(1); } /* And finally do something before leaving the state (optional too) and starting a new one */ print ("Exiting the mine..."); state = State.EnterBankAndDepositGold; } IEnumerator EnterBankAndDepositGold() { //Enter print ("Entering the bank..."); yield return null; //Execute bool queing = true; float t = Time.time; while (queing) { print ("waiting..."); if (Time.time - t > 5) queing = false; yield return new WaitForSeconds(1); } //Exit print ("Leaving the bank a little bit richer..."); state = State.EnterMineAndDigForNuggets; } } |
코루틴 안에서 코루틴을 호출해서 코루틴이 자신이 호출한 코루틴이 종료되기를 기다리는 형태이다. 해당 코루틴이 종료되는 순간, 즉 해당 State의 실행이 끝나는 순간에 다시 호출 되면서 새로운 State의 실행 코루틴을 호출하게 된다.
1 2 3 4 5 6 7 |
IEnumerator FSM() { // Execute the current coroutine (state) while (true) yield return StartCoroutine(state.ToString()); } |
그리고 아래는 조금 더 다양한 상황을 설정해서, State의 Kill도 가능하도록 구현해 본 상태 패턴이다.
거동은 몬스터가 패트롤을 하다가 시야에 캐릭터가 들어오면 추적한다. 그리고 추적하다가 캐릭터가 시야에서 벗어 날 경우 근처를 둘러보는 Investigate 상태에 들어간다. 그리고는 다시 패트롤 상태로 가는 3가지 상태가 존재한다.
1 2 3 4 5 6 7 8 |
public enum eState{ Patrol, Investigate, Chase, None } |
거동 플레이를 영상으로 보면 아래와 같다.
그리고 상태 패턴이 적용된 몬스터 클래스이다.
<Monster.cs>
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 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
using UnityEngine; using System.Collections; using System.Collections.Generic; using UnityEngine.UI; using System; public class Monster : MonoBehaviour { public enum eState{ Patrol, Investigate, Chase, None } private eState state_; public eState _state { get { return state_; } set { ExitState(state_); state_ = value; EnterState(state_); } } private Job hear; private Job see; private Job patrol; private Job investigate; private NavMeshAgent guard; private float patrolSpeed = 3.5f; private float alertSpeed = 5.0f; private float playerSpeed = 0f; public Transform playerTr; public List<Transform> patrolPoints = new List<Transform>(); Vector3 suspiciousPosition; Transform _tr; float _damping = 0.5f; Vector3 _lookTarget = Vector3.forward; GameObject[] _effList = new GameObject[3]; Text _stateText; void Start() { _tr = gameObject.transform; playerTr = GameObject.Find("Capsule").transform; GameObject mon = GameObject.Find("monster1"); _effList[0] = mon.transform.FindChild("eA").gameObject; _effList[1] = mon.transform.FindChild("eB").gameObject; _effList[2] = mon.transform.FindChild("eC").gameObject; guard = gameObject.GetComponent<NavMeshAgent>(); patrolPoints.Add(GameObject.Find("patrolP1").transform); patrolPoints.Add(GameObject.Find("patrolP2").transform); patrolPoints.Add(GameObject.Find("patrolP3").transform); patrolPoints.Add(GameObject.Find("patrolP4").transform); _stateText = GameObject.Find("HUDCanvas/Panel/state").GetComponent<Text>(); _state = eState.Patrol; } void SetUIText() { _stateText.text = "State : " + _state.ToString(); } void SetEffect() { for(int i=0; i<_effList.Length;i++) _effList[i].SetActive(false); if(_state != eState.None) _effList[(int)_state].SetActive(true); } void EnterState(eState state) { switch(state) { case eState.Patrol: patrol = new Job(Patrolling(),true); SetEffect(); see = new Job(Seeing(playerTr, 45f,60f,10f,0.5f,true, () => { Debug.Log ("Saw you!"); _state = eState.Chase; }),true); hear = new Job(Hearing( () => { Debug.Log ("What was that?"); _state = eState.Investigate; }),true); break; case eState.Investigate: SetEffect(); investigate = new Job(Investigating( () => { _state = eState.Patrol; } ),true); see = new Job(Seeing(playerTr, 45f,60f,10f,0.5f,true, () => { Debug.Log ("Saw you!"); _state = eState.Chase; }),true); break; case eState.Chase: guard.speed = alertSpeed; SetEffect(); see = new Job(Seeing(playerTr, 45f,60f,10f,0.5f,false, () => { Debug.Log ("Where you gone?"); _state = eState.Investigate; }),true); break; } SetUIText(); } void ExitState(eState state) { switch(state) { case eState.Patrol: if(patrol != null) patrol.kill(); if(see != null) see.kill(); if(hear != null) hear.kill(); break; case eState.Investigate: if(investigate != null) investigate.kill(); if(see != null) see.kill(); break; case eState.Chase: if(see != null) see.kill(); break; } } IEnumerator Patrolling() { int i = 0; while(true) { guard.speed = patrolSpeed; guard.SetDestination(patrolPoints[i].position); while((_tr.position - guard.destination).sqrMagnitude > 2f) { yield return null; } if(i == patrolPoints.Count - 1) i = 0; else ++i; guard.speed = 0f; yield return new WaitForSeconds(1f); } } IEnumerator Investigating(Action OnComplete) { suspiciousPosition = playerTr.position; while(true) { guard.speed = 0f; yield return new WaitForSeconds(1f); guard.SetDestination(suspiciousPosition); guard.speed = alertSpeed; while((_tr.position - guard.destination).sqrMagnitude > 2f) { yield return null; } guard.speed = 0f; yield return new WaitForSeconds(1f); if(OnComplete != null) OnComplete(); } } IEnumerator Seeing(Transform target, float angle, float distance, float maxHeight, float time, bool inRange, Action OnComplete) { while(true) { float timer = 0f; if(inRange) { while(IsInFov(target, angle, maxHeight) && (VisionCheck(target,distance)) && timer < time) { timer += Time.deltaTime; yield return null; } } else if(!inRange) { while((!IsInFov(target, angle, maxHeight) || !VisionCheck(target,distance)) && timer < time) { timer += Time.deltaTime; yield return null; } } if(timer > time && OnComplete != null) OnComplete(); yield return null; } } IEnumerator Hearing(Action onComplete) { while(true) { float hearingRange = 10f; bool heardNoise = false; while(!heardNoise && (_tr.position - playerTr.position).sqrMagnitude < hearingRange*hearingRange && playerSpeed > 20f) { heardNoise = true; } if(heardNoise && onComplete != null) onComplete(); yield return null; } } public bool VisionCheck(Transform target, float distance) { RaycastHit hit; if(Physics.Raycast(_tr.position, target.position-_tr.position,out hit,distance)) { if(hit.transform == playerTr) return true; else return false; } else return false; } public bool IsInFov(Transform target, float angle, float maxHeight) { var relPos = target.position - _tr.position; float height = relPos.y; relPos.y = 0; if(Mathf.Abs(Vector3.Angle(relPos,transform.forward)) < angle) { if(Mathf.Abs(height) < maxHeight) { return true; } else { return false; } } else return false; } } |
위의 코드와 같이 상태 패턴을 만들 수도 있고, 또 다르게 만들 수도 있다. NavMesh Agent를 사용하여 Chasing하게 하는 코드 부분은 이 글의 핵심은 아니다. 핵심은 State 별로 모듈을 분리하여 서로 Coupling이 발생하지 않도록 분리해 주는 데 있다. 자연히 State를 정의하는 enum 형태의 변수가 존재하고, State를 Setting 하는 Method들이 들어간다. 그리고 일반 Method나 Class, Coroutine Method들로 Update 로직들을 분리 할 수 있게 된다. 위 클래스에서 Job 클래스는 Seeing과 Hearing을 Coroutine으로 돌려주는 클래스이다. 캐릭터가 시야에 들어왔는지, 주변에 있는지 체크한다.
Github Source Project Link : https://github.com/inbgche/UnityStatePattern
Reference: https://github.com/TomCoadjoint/coAdjoint_CSharp_Tutorials