[ 섹션 13. 미니 RPG ]
# 환경세팅
- Terrain은 Unity에서 개발자가 직접 지형을 제작할 수 있도록 제공한 Tool이다.
- Terrain은 [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 3D Object ] - [ Terrain ] 을 통해 생성한다.
- Light의 Type에는 Realtime, Mixed, Baked가 있다.
> Realtime은 조명이 씬에 직접적인 빛을 제공하고 매 프레임마다 업데이트되어 게임 오브젝트가 씬 내에서 이동시
조명은 즉시 업데이트된다. (상당한 부하를 유발)
> Baked는 Lightmap을 Baking할 때 Scene의 Static Object에 대한 조명 효과가 계산되고 Texture에 기록된다.
(즉, 움직이지 않는 물체의 빛을 미리 계산해두는 것)
> 물체가 많아질수록 Baking 시간이 상당히 오래 걸린다. 따라서 개발 도중에 물체를 추가할때마다 자동으로 Baking
하는 것을 막기 위해 [ Window ] - [ Rendering ] - [ Lighting Settings ] - [ Lightmapping Setting ] 의 Auto Generate의
체크를 해제한다. (최신 버전에서 에러 발생시 Compress Lightmaps의 체크를 해제)
# 이동
- NavMesh는 Navigation Mesh의 줄임말로, 게임 월드에서 걸을 수 있는 표면을 뜻한다.
- NavMesh는 [ Window ] - [ AI ] - [ Navigation ] 에서 설정 가능하다.
> [ Bake ] 에서 미리 갈 수 있는 영역 또는 제한 사항들을 Bake한다.
(계산에 포함되는 Mesh는 Navigation Static 속성을 가진다.)
- 설정된 NavMesh를 이용하여 목적지까지 경로를 찾고 이동하는 캐릭터에 부착되는 컴포넌트가 Nav Mesh Agent이다.
NavMesh를 이용한 PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { //... void UpdateMoving() { Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다. if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면 { _state = PlayerState.Idle; } else { // NavMesh를 사용 NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>(); float moveDist = Mathf.Clamp(Time.deltaTime * _speed, 0, dir.magnitude); nma.Move(dir.normalized * moveDist); Debug.DrawRay(transform.position + Vector3.up * 0.5f, dir.normalized, Color.green); // Ray를 시각적으로 표현 if (Physics.Raycast(transform.position + Vector3.up * 0.5f, dir, 1.0f, LayerMask.GetMask("Block"))) // 건물, 사물과 인접시 Player가 멈추도록 설정 { _state = PlayerState.Idle; return; } transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록 } // 애니매이션 Animator anim = GetComponent<Animator>(); anim.SetFloat("speed", _speed); } //... }
# 스탯
스탯 관리를 위한 공용 Stat Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class Stat : MonoBehaviour { [SerializeField] protected int _level; [SerializeField] protected int _hp; [SerializeField] protected int _maxHp; [SerializeField] protected int _attack; [SerializeField] protected int _defense; [SerializeField] protected float _moveSpeed; public int Level { get { return _level; } set { _level = value; } } public int Hp { get { return _hp; } set { _hp = value; } } public int MaxHp { get { return _maxHp; } set { _maxHp = value; } } public int Attack { get { return _attack; } set { _attack = value; } } public int Defense { get { return _defense; } set { _defense = value; } } public float MoveSpeed { get { return _moveSpeed; } set { _moveSpeed = value; } } private void Start() { _level = 1; _hp = 100; _maxHp = 100; _attack = 10; _defense = 5; _moveSpeed= 5.0f; } }
Player의 스탯 관리를 위해 Stat를 상속받는 PlayerStat Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStat : Stat { [SerializeField] protected int _exp; [SerializeField] protected int _gold; public int Exp { get { return _exp; } set { _exp = value; } } public int Gold { get { return _gold; } set { _gold = value; } } private void Start() { _level = 1; _hp = 100; _maxHp = 100; _attack = 10; _defense = 5; _moveSpeed = 5.0f; _exp = 0; _gold = 0; } }
# 마우스 커서
- Cursor로 사용하기 위한 Image는 Inspector 창에서 Texture Type을 Cursor로 설정한다.
마우스 커서를 위한 PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... Texture2D _attackIcon; Texture2D _handIcon; enum CursorType { None, Attack, Hand, } CursorType _cursorType = CursorType.None; void Start() { _attackIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Attack"); // Image 파일을 Texture2D를 통해 불러온다. _handIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Hand"); // Image 파일을 Texture2D를 통해 불러온다. } void Update() { UpdateMouseCursor(); } void UpdateMouseCursor() { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 100.0f, _mask)) { if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) { if (_cursorType != CursorType.Attack) { Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Attack; } } else { if (_cursorType != CursorType.Hand) { Cursor.SetCursor(_handIcon, new Vector2(_attackIcon.width / 3, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Hand; } } } } // ... }
+ 추가 검색
- Cursor.SetCursor() 함수는 Cursor의 Image를 사용자 지정 Texture로 바꿔준다.
> 첫번째 인자는 Cursor에 지정할 Texture, 두번째 인자는 Cursor의 Spot, 세번째 인자는 최적화 방법이다.
# 타게팅 락온
타게팅 락온을 위해 Define의 MouseEvent 추가using System.Collections; using System.Collections.Generic; using UnityEngine; public class Define { // ... public enum MouseEvent { Press, PointerDown, // 마우스를 Click하는 순간 PointerUp, // 마우스 Click을 떼는 순간 Click, // 순간적인 Click (굉장히 짧은 시간) } // ... }
InputManager 수정using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; public class InputManager { // ... float _pressedTime = 0; // Click과 PointerUp을 구분하기 위한 것 // 체크하는 부분이 유일해짐 public void OnUpdate() { // ... if (MouseAction != null) { if (Input.GetMouseButton(0)) { if (!_pressed) // 마우스를 Click하는 순간 { MouseAction.Invoke(Define.MouseEvent.PointerDown); _pressedTime = Time.time; } MouseAction.Invoke(Define.MouseEvent.Press); // Press Event를 발생시켜 알려준다. _pressed = true; } else { if (_pressed) { if (Time.time < _pressedTime + 0.2f) // 0.2초 내에 Mouse Click을 뗀 경우 MouseAction.Invoke(Define.MouseEvent.Click); // Click Event를 발생시켜 알려준다. MouseAction.Invoke(Define.MouseEvent.PointerUp); } _pressed = false; _pressedTime = 0; // 초기화 } } } }
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... void UpdateMouseCursor() { if (Input.GetMouseButton(0)) // 만약 마우스를 누르고 있다면 커서가 변경되면 안되기 때문 return; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 100.0f, _mask)) { if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) { if (_cursorType != CursorType.Attack) { Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Attack; } } else { if (_cursorType != CursorType.Hand) { Cursor.SetCursor(_handIcon, new Vector2(_attackIcon.width / 3, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Hand; } } } } int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster); GameObject _lockTarget; void OnMouseEvent(Define.MouseEvent evt) { if (_state == PlayerState.Die) return; RaycastHit hit; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask); //Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현 switch (evt) { case Define.MouseEvent.PointerDown: { if (raycastHit) { _destPos = hit.point; _state = PlayerState.Moving; if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) _lockTarget = hit.collider.gameObject; else _lockTarget = null; } } break; case Define.MouseEvent.Press: { if(_lockTarget != null) _destPos = _lockTarget.transform.position; else if (raycastHit) _destPos = hit.point; } break; case Define.MouseEvent.PointerUp: _lockTarget = null; break; } } }
+ 추가 검색 (https://unity-beginner.tistory.com/17)
- GetMouseButtonDown( )은 마우스 버튼을 클릭하고 있을때 계속해서 발생,
GetMouseButton( )은 마우스 버튼을 클릭했을 때 한 번 발생,
GetMouseButtonUp( )은 마우스 버튼을 놓았을 때 한 번 발생
> 괄호안에 입력한 숫자가 0 : 마우스 왼쪽 버튼, 1 : 마우스 오른쪽 버튼, 2 : 마우스 휠 버튼에 해당한다.
# 공격 #1
PlayerController에서 마우스 Cursor와 관련된 부분을 따로 빼 CursorController 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class CursorController : MonoBehaviour { int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster); Texture2D _attackIcon; Texture2D _handIcon; enum CursorType { None, Attack, Hand, } CursorType _cursorType = CursorType.None; void Start() { _attackIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Attack"); // Image 파일을 Texture2D를 통해 불러온다. _handIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Hand"); // Image 파일을 Texture2D를 통해 불러온다. } void Update() { if (Input.GetMouseButton(0)) // 만약 마우스를 누르고 있다면 커서가 변경되면 안되기 때문 return; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 100.0f, _mask)) { if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) { if (_cursorType != CursorType.Attack) { Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Attack; } } else { if (_cursorType != CursorType.Hand) { Cursor.SetCursor(_handIcon, new Vector2(_attackIcon.width / 3, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다. _cursorType = CursorType.Hand; } } } } }
공격 Animation을 위한 PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... public enum PlayerState { Die, Moving, Idle, Skill, } [SerializeField] PlayerState _state = PlayerState.Idle; public PlayerState State // PlayerState가 변함과 동시에 Animation도 변경하기 위한 프로퍼티 생성 { get { return _state; } set { _state = value; Animator anim = GetComponent<Animator>(); switch (_state) { case PlayerState.Die: anim.SetBool("attack", false); break; case PlayerState.Idle: anim.SetFloat("speed", 0); anim.SetBool("attack", false); break; case PlayerState.Moving: anim.SetFloat("speed", _stat.MoveSpeed); anim.SetBool("attack", false); break; case PlayerState.Skill: anim.SetBool("attack", true); break; } } } // ... void UpdateMoving() { // 몬스터가 내 사정거리보다 가까우면 공격 if (_lockTarget != null) { _destPos = _lockTarget.transform.position; float distance = (_destPos - transform.position).magnitude; if (distance <= 1) { State = PlayerState.Skill; return; } } // ... } // ... void OnHitEvent() { State = PlayerState.Moving; } void Update() { switch (State) { case PlayerState.Die: UpdateDie(); break; case PlayerState.Moving: UpdateMoving(); break; case PlayerState.Idle: UpdateIdle(); break; case PlayerState.Skill: UpdateSkill(); break; } } int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster); GameObject _lockTarget; void OnMouseEvent(Define.MouseEvent evt) { if (_state == PlayerState.Die) return; RaycastHit hit; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask); //Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현 switch (evt) { case Define.MouseEvent.PointerDown: { if (raycastHit) { _destPos = hit.point; State = PlayerState.Moving; if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) _lockTarget = hit.collider.gameObject; else _lockTarget = null; } } break; case Define.MouseEvent.Press: { if(_lockTarget != null) _destPos = _lockTarget.transform.position; else if (raycastHit) _destPos = hit.point; } break; } } }
# 공격 #2
- Animation 전환시 반드시 Unity의 Tool을 사용하라는 법은 없다.
> Parameter의 변경을 통한 Animation의 전환보다 Play( ), CrossFade( ) 사용이 더 편리할때도 있다.
> CrossFade( )는 심지어 Blending도 가능하다. (두번째 매개변수를 통해)
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... public PlayerState State // PlayerState가 변함과 동시에 Animation도 변경하기 위한 프로퍼티 생성 { get { return _state; } set { _state = value; Animator anim = GetComponent<Animator>(); switch (_state) { case PlayerState.Die: break; case PlayerState.Idle: anim.CrossFade("WAIT", 0.1f); break; case PlayerState.Moving: anim.CrossFade("RUN", 0.1f); break; case PlayerState.Skill: anim.CrossFade("ATTACK", 0.1f); break; } } } // ... void UpdateSkill() { if (_lockTarget != null) { Vector3 dir = _lockTarget.transform.position - transform.position; // 목적지까지의 방향벡터를 알 수 있다. Quaternion quat = Quaternion.LookRotation(dir); // 목적지를 바라보기 위해서 transform.rotation = Quaternion.Lerp(transform.rotation, quat, 20 * Time.deltaTime); // 자연스럽게 연결되도록 } } void OnHitEvent() { if (_stopSkill) { State = PlayerState.Idle; } else { State = PlayerState.Skill; } } // ... bool _stopSkill = false; void OnMouseEvent(Define.MouseEvent evt) { switch (State) { case PlayerState.Idle: OnMouseEvent_IdleRun(evt); break; case PlayerState.Moving: OnMouseEvent_IdleRun(evt); break; case PlayerState.Skill: { if (evt == Define.MouseEvent.PointerUp) _stopSkill = true; } break; } } void OnMouseEvent_IdleRun(Define.MouseEvent evt) { RaycastHit hit; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask); //Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현 switch (evt) { case Define.MouseEvent.PointerDown: { if (raycastHit) { _destPos = hit.point; State = PlayerState.Moving; _stopSkill = false; if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) _lockTarget = hit.collider.gameObject; else _lockTarget = null; } } break; case Define.MouseEvent.Press: { if (_lockTarget == null && raycastHit) _destPos = hit.point; } break; case Define.MouseEvent.PointerUp: _stopSkill = true; break; } } }
+ 추가 검색 (https://ansohxxn.github.io/unitydocs/anim/)
- CrossFade( )는 Play( ) 에 대한 결과를 보다 더 부드럽고 자연스럽게 Blending 하여 Animation을 재생한다.
> 첫번째 Parameter는 재생하고자 하는 Clip의 이름
> 두번째 Parameter는 지연 시간 (다음 Animation으로 바뀌는데 걸리는 Fade 시간)
> 세번째 Parameter는 Animation Layer
> 네번째 Parameter는 Animation의 재생 시작 시점 (0 ~ 1 비율)
~> 0.0f로 설정시 다시 처음으로 돌아가 재생한다.
~> Clip의 Loop Time이 체크되어 있지 않더라도 반복 재생시킬 수 있다.
# 체력 게이지 #1
- UI는 2D UI와 (게임 세상과 별개), 3D UI (게임 세상과 공존)로 나눌 수 있다.
> 3D UI를 만들기 위해서는 Canvas의 Render Mode를 World Space로 변경한다.
> 3D UI를 만들때 Canvas의 Scale을 약 1/100 로 줄이면 적당한 크기로 변경이 가능하다.
- Slider의 Value가 0과 1일때 텅 비어 있지도, 가득 차있지도 않다.
> 이는 Fill Area의 [ Inspector ] - [ Rect Transform ] 에서 Left와 Right의 값을 전부 0으로 설정하면 해결이 가능하다.
- Slider의 Fill이 Background의 경계선을 넘어 튀어나와 있다.
> 이는 Fill의 [ Inspector ] - [ Rect Transform ] 에서 Left와 Right의 값을 전부 0으로 설정하면 해결이 가능하다.
HP_Bar의 Canvas를 Prefab으로 저장한 뒤 UI_HPBar Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class UI_HPBar : UI_Base { enum GameObjects { HPBar } public override void Init() { Bind<GameObject>(typeof(GameObjects)); } void Update() { // HP_Bar를 캐릭터의 머리 위로 위치시키기 위해서 Transform parent = transform.parent; transform.position = parent.position + Vector3.up * (parent.GetComponent<Collider>().bounds.size.y); // 부모님 위치를 기준으로 Collider만큼의 높이를 올려 저장 } }
UIManager 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class UIManager { // ... public T MakeWorldSpaceUI<T>(Transform parent = null, string name = null) where T : UI_Base { if (string.IsNullOrEmpty(name)) name = typeof(T).Name; GameObject go = Managers.Resource.Instantiate($"UI/WorldSpace/{name}"); if (parent != null) go.transform.SetParent(parent); Canvas canvas = go.GetOrAddComponent<Canvas>(); canvas.renderMode = RenderMode.WorldSpace; canvas.worldCamera = Camera.main; return Util.GetOrAddComponent<T>(go); } // ... }
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... void Start() { _stat = gameObject.GetComponent<PlayerStat>(); Managers.Input.MouseAction -= OnMouseEvent; // 혹시라도 다른 곳에서 구독 신청을 하고 있는 경우를 대비 Managers.Input.MouseAction += OnMouseEvent; Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform); } // ... }
# 체력 게이지 #2
상속받는 Class들이 Start 함수 선언 없이 Init 함수를 실행하도록 UI_Base 수정using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; public abstract class UI_Base : MonoBehaviour { // ... public abstract void Init(); void Start() { Init(); } // ... }
Billboard 기능 구현 및 실시간 HP 감소를 위한 UI_HPBar 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UI_HPBar : UI_Base { enum GameObjects { HPBar } Stat _stat; public override void Init() { Bind<GameObject>(typeof(GameObjects)); _stat = transform.parent.GetComponent<Stat>(); } void Update() { // HP_Bar를 캐릭터의 머리 위로 위치시키기 위해서 Transform parent = transform.parent; transform.position = parent.position + Vector3.up * (parent.GetComponent<Collider>().bounds.size.y); // 부모님 위치를 기준으로 Collider만큼의 높이를 올려 저장 transform.rotation = Camera.main.transform.rotation; // 자신이 바라보는 방향을 카메라가 바라보는 방향으로 설정 (Billboard의 개념) float ratio = _stat.Hp / (float)_stat.MaxHp; SetHpRatio(ratio); } public void SetHpRatio(float ratio) { GetObject((int)GameObjects.HPBar).GetComponent<Slider>().value = ratio; } }
Hit Event 발생시 상대방의 HP를 감소시키기 위한 PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // ... void OnHitEvent() { if (_lockTarget != null) { Stat targetStat = _lockTarget.GetComponent<Stat>(); Stat myStat = gameObject.GetComponent<PlayerStat>(); int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense); Debug.Log(damage); targetStat.Hp -= damage; } if (_stopSkill) { State = PlayerState.Idle; } else { State = PlayerState.Skill; } } // ... }
+ 추가 검색
- Unity에서 3D 오브젝트가 카메라를 바라보도록 만드는 기술을 Billboard라고 한다.
> UI인 Slider를 카메라를 바라보도록 수정할 경우 좌우 반전이 된 채로 표시된다.
( transform.LookAt(Camera.main) 을 통해 )
~> 따라서 UI인 Slider에 Billboard 기술을 적용하기 위해서는 아래와 같이 수정한다.
( transform.rotation = Camera.main.transform.rotation 으로 수정 )
# 몬스터 AI #1
- 새로 생성하고자 하는 MonsterController가 PlayerController와 겹치는 부분이 상당히 많아서 이럴때는 BaseController를
새로 생성하여 MonsterController와 PlayerController가 BaseController를 상속받도록 하는 것이 좋다.
Define 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class Define { public enum State { Die, Moving, Idle, Skill, } // ... }
Base Controller 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public abstract class BaseController : MonoBehaviour { [SerializeField] protected Vector3 _destPos; // 목적지 좌표를 저장하기 위한 변수 [SerializeField] protected Define.State _state = Define.State.Idle; [SerializeField] protected GameObject _lockTarget; public virtual Define.State State // State가 변함과 동시에 Animation도 변경하기 위한 프로퍼티 생성 { get { return _state; } set { _state = value; Animator anim = GetComponent<Animator>(); switch (_state) { case Define.State.Die: break; case Define.State.Idle: anim.CrossFade("WAIT", 0.1f); break; case Define.State.Moving: anim.CrossFade("RUN", 0.1f); break; case Define.State.Skill: anim.CrossFade("ATTACK", 0.1f); break; } } } private void Start() { Init(); } void Update() { switch (State) { case Define.State.Die: UpdateDie(); break; case Define.State.Moving: UpdateMoving(); break; case Define.State.Idle: UpdateIdle(); break; case Define.State.Skill: UpdateSkill(); break; } } public abstract void Init(); protected virtual void UpdateDie() { } protected virtual void UpdateMoving() { } protected virtual void UpdateIdle() { } protected virtual void UpdateSkill() { } }
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : BaseController { int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster); PlayerStat _stat; bool _stopSkill = false; public override void Init() { _stat = gameObject.GetComponent<PlayerStat>(); Managers.Input.MouseAction -= OnMouseEvent; // 혹시라도 다른 곳에서 구독 신청을 하고 있는 경우를 대비 Managers.Input.MouseAction += OnMouseEvent; if (gameObject.GetComponentInChildren<UI_HPBar>() == null) Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform); } protected override void UpdateMoving() { // 몬스터가 내 사정거리보다 가까우면 공격 if (_lockTarget != null) { _destPos = _lockTarget.transform.position; float distance = (_destPos - transform.position).magnitude; if (distance <= 1) { State = Define.State.Skill; return; } } Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다. if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면 { State = Define.State.Idle; } else { // NavMesh를 사용 NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>(); float moveDist = Mathf.Clamp(Time.deltaTime * _stat.MoveSpeed, 0, dir.magnitude); nma.Move(dir.normalized * moveDist); Debug.DrawRay(transform.position + Vector3.up * 0.5f, dir.normalized, Color.green); // Ray를 시각적으로 표현 if (Physics.Raycast(transform.position + Vector3.up * 0.5f, dir, 1.0f, LayerMask.GetMask("Block"))) // 건물, 사물과 인접시 Player가 멈추도록 설정 { if (Input.GetMouseButton(0) == false) State = Define.State.Idle; return; } transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록 } } protected override void UpdateSkill() { if (_lockTarget != null) { Vector3 dir = _lockTarget.transform.position - transform.position; // 목적지까지의 방향벡터를 알 수 있다. Quaternion quat = Quaternion.LookRotation(dir); // 목적지를 바라보기 위해서 transform.rotation = Quaternion.Lerp(transform.rotation, quat, 20 * Time.deltaTime); // 자연스럽게 연결되도록 } } void OnHitEvent() { if (_lockTarget != null) { Stat targetStat = _lockTarget.GetComponent<Stat>(); Stat myStat = gameObject.GetComponent<PlayerStat>(); int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense); Debug.Log(damage); targetStat.Hp -= damage; } if (_stopSkill) { State = Define.State.Idle; } else { State = Define.State.Skill; } } void OnMouseEvent(Define.MouseEvent evt) { switch (State) { case Define.State.Idle: OnMouseEvent_IdleRun(evt); break; case Define.State.Moving: OnMouseEvent_IdleRun(evt); break; case Define.State.Skill: { if (evt == Define.MouseEvent.PointerUp) _stopSkill = true; } break; } } void OnMouseEvent_IdleRun(Define.MouseEvent evt) { RaycastHit hit; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask); //Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현 switch (evt) { case Define.MouseEvent.PointerDown: { if (raycastHit) { _destPos = hit.point; State = Define.State.Moving; _stopSkill = false; if (hit.collider.gameObject.layer == (int)Define.Layer.Monster) _lockTarget = hit.collider.gameObject; else _lockTarget = null; } } break; case Define.MouseEvent.Press: { if (_lockTarget == null && raycastHit) _destPos = hit.point; } break; case Define.MouseEvent.PointerUp: _stopSkill = true; break; } } }
#몬스터 AI #2
MonsterController 생성using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class MonsterController : BaseController { Stat _stat; [SerializeField] float _scanRange = 10; [SerializeField] float _attackRange = 2; public override void Init() { _stat = gameObject.GetComponent<Stat>(); if (gameObject.GetComponentInChildren<UI_HPBar>() == null) Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform); } protected override void UpdateIdle() { GameObject player = GameObject.FindGameObjectWithTag("Player"); if (player == null) return; float distance = (player.transform.position - transform.position).magnitude; if (distance <= _scanRange) // 사정 거리 안으로 들어올 경우 움직이기 시작 { _lockTarget = player; State = Define.State.Moving; return; } } protected override void UpdateMoving() { // 플레이어가 내 사정거리보다 가까우면 공격 if (_lockTarget != null) { _destPos = _lockTarget.transform.position; float distance = (_destPos - transform.position).magnitude; if (distance <= _attackRange) { NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>(); nma.SetDestination(transform.position); State = Define.State.Skill; return; } } Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다. if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면 { State = Define.State.Idle; } else { // NavMesh를 사용 NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>(); nma.SetDestination(_destPos); nma.speed = _stat.MoveSpeed; transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록 } } protected override void UpdateSkill() { if (_lockTarget != null) { Vector3 dir = _lockTarget.transform.position - transform.position; // 목적지까지의 방향벡터를 알 수 있다. Quaternion quat = Quaternion.LookRotation(dir); // 목적지를 바라보기 위해서 transform.rotation = Quaternion.Lerp(transform.rotation, quat, 20 * Time.deltaTime); // 자연스럽게 연결되도록 } } void OnHitEvent() { if (_lockTarget != null) { Stat targetStat = _lockTarget.GetComponent<Stat>(); Stat myStat = gameObject.GetComponent<Stat>(); int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense); targetStat.Hp -= damage; if (targetStat.Hp > 0) { float distance = (_lockTarget.transform.position - transform.position).magnitude; if (distance <= _attackRange) State = Define.State.Skill; else State = Define.State.Moving; } else { State = Define.State.Idle; } } else { State = Define.State.Idle; } } }
# Destroy #1
- Destroy된 Object는 사실 실제로 삭제된건 C++ Native Object이고, UnityEngine.Object C# Class 부분은 아직 Memory
상에 존재하기 때문에 실제로 null이 된 것은 아니지만 없어진 것처럼 행동해야 하기 때문에 "null"로 처리된다.
> 이것이 바로 fake null 이다.
> 이에 대한 처리가 UnityEngine.Object의 == 연산자 오버로딩에 구현되어 있다.
(진짜 null이 된게 아님에도 Destroy된 Object == null 을 하면 true를 리턴하도록 구현됨)
- Destroy된 Object의 Component를 참조하는 것들을 잘 확인해야 한다.
> Object가 해제되면 당연히 해당 Object의 Component도 참조할 수 없다.
MosterController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class MonsterController : BaseController { // ... void OnHitEvent() { // ... if (targetStat.Hp <=0) { GameObject.Destroy(targetStat.gameObject); } // ... } }
CameraController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class CameraController : MonoBehaviour { // ... // Player의 움직임이 실행된 뒤 Camera의 위치를 움직여야 떨리는 현상이 줄어든다. void LateUpdate() // 게임 Logic에서 LateUpdate()는 Update()보다 늦게 실행된다. { if (_mode == Define.CameraMode.QuaterView) { if (_player == null) { return; } // ... } } // ... }
+ 추가 검색 (https://ansohxxn.github.io/unitydocs/fakenull/)
- 해결 방법 1 : Object를 bool로 묵시적 형변환
- 해결 방법 2 : Destroy 후 진짜 null을 대입
- 해결 방법 3 : System.Object로 형변환 후 비교
# Destroy #2
Define 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class Define { public enum WorldObject { Unknown, Player, Monster, } // ... }
BaseController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public abstract class BaseController : MonoBehaviour { // ... public Define.WorldObject WorldObjectType { get; protected set; } = Define.WorldObject.Unknown; // ... }
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : BaseController { // ... public override void Init() { WorldObjectType = Define.WorldObject.Player; // ... } // ... }
MonsterController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class MonsterController : BaseController { // ... public override void Init() { WorldObjectType = Define.WorldObject.Monster; // ... } // ... void OnHitEvent() { if (_lockTarget != null) { // ... if (targetStat.Hp <=0) { Managers.Game.Despawn(targetStat.gameObject); } // ... } // ... } }
GameManagerEx 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameManagerEx : MonoBehaviour { // 스폰된 Object들은 ID로 관리하는 것이 좋다 => Dictionary가 적당 // 서버에서는 해당 ID를 통해 통신하기 때문이다. //Dictionary<int, GameObject> _players = new Dictionary<int, GameObject>(); //Dictionary<int, GameObject> _monsters = new Dictionary<int, GameObject>(); // 현재는 멀티가 아닌 싱글 게임이기 때문에 아래와 같이 관리한다. GameObject _player; HashSet<GameObject> _monsters = new HashSet<GameObject>(); // HashSet은 Dictionary와 비슷하지만 Key가 X public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null) { GameObject go = Managers.Resource.Instantiate(path, parent); switch (type) { case Define.WorldObject.Monster: _monsters.Add(go); break; case Define.WorldObject.Player: _player = go; break; } return go; } public Define.WorldObject GetWorldObjectType(GameObject go) { BaseController bc = go.GetComponent<BaseController>(); if (bc == null) return Define.WorldObject.Unknown; return bc.WorldObjectType ; } public void Despawn(GameObject go) { Define.WorldObject type = GetWorldObjectType(go); switch (type) { case Define.WorldObject.Monster: { if (_monsters.Contains(go)) _monsters.Remove(go); } break; case Define.WorldObject.Player: { if (_player == go) _player = null; } break; } Managers.Resource.Destroy(go); } }
Managers 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class Managers : MonoBehaviour { // ... GameManagerEx _game = new GameManagerEx(); // ... public static GameManagerEx Game { get { return Instance._game; } } // ... }
Extension 수정using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; public static class Extension { // ... // 폴링하는 Object일 경우 Destroy가 아닌 비활성화 처리가 된다. // 따라서 null 체크뿐만이 아닌 활성화 상태 여부도 체크해줘야 한다. public static bool IsValid(this GameObject go) { return go != null && go.activeSelf; } }
CameraController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class CameraController : MonoBehaviour { // ... public void SetPlayer(GameObject player) { _player = player; } // Player의 움직임이 실행된 뒤 Camera의 위치를 움직여야 떨리는 현상이 줄어든다. void LateUpdate() // 게임 Logic에서 LateUpdate()는 Update()보다 늦게 실행된다. { if (_mode == Define.CameraMode.QuaterView) { if (_player.IsValid() == false) { return; } // ... } } // ... }
GameScene 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameScene : BaseScene { protected override void Init() { // ... GameObject player = Managers.Game.Spawn(Define.WorldObject.Player, "Player"); Camera.main.gameObject.GetOrAddComponent<CameraController>().SetPlayer(player); Managers.Game.Spawn(Define.WorldObject.Monster, "Knight"); } // ... }
- HashSet은 Key가 존재하지 않으며 Value(들)의 구성으로 이루어져 있다.
> 순서가 존재하지 않는다.
> 각 항목의 중복을 허용하지 않는다. (즉, Value 중복을 허용 X)
> Add( ), Remove( ), Clear( ), UnionWith( ), IntersectWith( ), ExceptWith( ) 등의 함수가 존재한다.
# 레벨업
- Damage와 관련된 부분은 공격을 하는 객체가 아닌 공격을 받는 객체에서 처리해주는 것이 좋다.
(공격을 받는 객체의 방어력, 버프 등이 존재하기 때문)
Stat 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class Stat : MonoBehaviour { // ... public virtual void OnAttacked(Stat attacker) { int damage = Mathf.Max(0, attacker.Attack - Defense); Hp -= damage; if (Hp <= 0) { Hp = 0; OnDead(); } } protected virtual void OnDead() { Managers.Game.Despawn(gameObject); } }
PlayerStat 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStat : Stat { // ... protected override void OnDead() { Debug.Log("Player Dead"); } }
MonsterController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : BaseController { // ... void OnHitEvent() { if (_lockTarget != null) { Stat targetStat = _lockTarget.GetComponent<Stat>(); targetStat.OnAttacked(_stat); } // ... } // ... }
PlayerController 수정using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : BaseController { // ... void OnHitEvent() { if (_lockTarget != null) { Stat targetStat = _lockTarget.GetComponent<Stat>(); targetStat.OnAttacked(_stat); } // ... } // ... }
- Monster를 죽일 경우 Monster에 따른 경험치 휙득량을 데이터를 통해 관리해주는 것이 좋다.
- Level Up 확인 코드는 유일한 것이 좋다. (그렇지 않으면 고된 복붙 작업)
StatData json 파일 수정{ "stats": [ { "level": "1", "maxHp": "200", "attack": "20", "totalExp": "0" }, { "level": "2", "maxHp": "250", "attack": "25", "totalExp": "10" }, { "level": "3", "maxHp": "300", "attack": "30", "totalExp": "20" } ] }
Data.Contents 수정using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace Data { #region Stat [Serializable] public class Stat { public int level; public int maxHp; public int attack; public int totalExp; } // ... #endregion }
Stat 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class Stat : MonoBehaviour { // ... public virtual void OnAttacked(Stat attacker) { int damage = Mathf.Max(0, attacker.Attack - Defense); Hp -= damage; if (Hp <= 0) { Hp = 0; OnDead(attacker); } } protected virtual void OnDead(Stat attacker) { PlayerStat playerStat = attacker as PlayerStat; if (playerStat != null) { playerStat.Exp += 5; } Managers.Game.Despawn(gameObject); } }
PlayerStat 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStat : Stat { // ... public int Exp { get { return _exp; } set { _exp = value; // Level Up 체크 int level = Level; while (true) { Data.Stat stat; if (Managers.Data.StatDict.TryGetValue(level + 1, out stat) == false) // 다음 Level이 존재하지 않을 경우 break; if (_exp < stat.totalExp) break; level++; } if (level != Level) // Level에 변화가 있을 경우 { Level = level; SetStat(Level); } } } // ... private void Start() { _level = 1; _exp = 0; _defense = 5; _moveSpeed = 5.0f; _gold = 0; SetStat(_level); } public void SetStat(int level) { Dictionary<int, Data.Stat> dict = Managers.Data.StatDict; Data.Stat stat = dict[level]; _hp = stat.maxHp; _maxHp = stat.maxHp; _attack = stat.attack; } protected override void OnDead(Stat attacker) { Debug.Log("Player Dead"); } }
# 몬스터 자동 생성
SpawningPool 생성using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class SpawningPool : MonoBehaviour { [SerializeField] int _monsterCount = 0; // 현재 몬스터 수 int _reserveCount = 0; // 현재 Spawn 예약 수 [SerializeField] int _keepMonsterCount = 0; // 유지시켜야 하는 몬스터 수 [SerializeField] Vector3 _spawnPos; // Spawn 중심점 [SerializeField] float _spawnRadius = 15.0f; // Spawn 중심점을 기준으로 랜덤 생성을 위한 생성 거리 범위 제한 [SerializeField] float _spawnTime = 5.0f; // 랜덤 생성을 위한 생성 시간 범위 제한 public void AddMonsterCount(int value) { _monsterCount += value; } public void SetkeepMonsterCount(int count) { _keepMonsterCount = count; } void Start() { Managers.Game.OnSpawnEvent -= AddMonsterCount; Managers.Game.OnSpawnEvent += AddMonsterCount; } void Update() { while (_reserveCount + _monsterCount < _keepMonsterCount) { StartCoroutine("ReserveSpawn"); } } IEnumerator ReserveSpawn() { _reserveCount++; yield return new WaitForSeconds(Random.Range(0, _spawnTime)); GameObject obj = Managers.Game.Spawn(Define.WorldObject.Monster, "Knight"); NavMeshAgent nma = obj.GetOrAddComponent<NavMeshAgent>(); Vector3 randPos; while(true) // randPos가 갈 수 있는 길이 나올때까지 반복 { Vector3 randDir = Random.insideUnitSphere * Random.Range(0, _spawnRadius); // 랜덤으로 뽑힌 방향벡터가 randDir에 담긴다. (insideUnitCircle은 2D) randDir.y = 0; // 땅을 뜷고 Spawn 되지 않도록 randPos = _spawnPos + randDir; // randPos가 갈 수 있는 길인지 체크 NavMeshPath path = new NavMeshPath(); if (nma.CalculatePath(randPos, path)) // 갈 수 있는 길이면 True, 갈 수 없는 길이면 False를 반환 break; } obj.transform.position = randPos; _reserveCount--; } }
GameManagerEx 수정using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameManagerEx : MonoBehaviour { // ... public Action<int> OnSpawnEvent; // int는 증가/감소 수 // ... public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null) { // ... switch (type) { case Define.WorldObject.Monster: // ... if (OnSpawnEvent != null) OnSpawnEvent.Invoke(1); break; // ... } // ... } // ... public void Despawn(GameObject go) { // ... switch (type) { case Define.WorldObject.Monster: { if (_monsters.Contains(go)) { _monsters.Remove(go); if (OnSpawnEvent != null) OnSpawnEvent.Invoke(-1); } } break; // ... } // ... } }
GameScene 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameScene : BaseScene { protected override void Init() { // ... //Managers.Game.Spawn(Define.WorldObject.Monster, "Knight"); GameObject go = new GameObject { name = "SpawningPool" }; SpawningPool pool = go.GetOrAddComponent<SpawningPool>(); pool.SetkeepMonsterCount(5); } // ... }
+ 추가 검색 ( )
- Unity는 난수 생성과 관련된 함수들이 있는 집합인 Random 이라는 기능을 제공한다.
~> 가장 대표적인 기능이 Random.Range(min, max) 이며 이는 min과 max 범위 내에서 랜덤한 값을 반환한다.
(이때 max의 Type이 int일 경우 max의 값은 포함되지 X)
~> Random.insideUnitSphere은 반경 1을 갖는 구 안의 임의의 위치(Vector3)를 반환하는 프로퍼티이다.
~> Random. insideUnitCircle 은 반경 1을 갖는 구 안의 임의의 위치(Vector2)를 반환하는 프로퍼티이다.
'Unity Engine Study > Unity 강의 #1' 카테고리의 다른 글
[Unity 강의 #1] 인프런 강의 - Part3: 유니티 엔진 내용 정리 (섹션 7 ~ 12) (0) | 2024.01.24 |
---|---|
[Unity 강의 #1] 인프런 강의 - Part3: 유니티 엔진 내용 정리 (섹션 0 ~ 6) (0) | 2024.01.04 |