오늘 학습 키워드
유니티 숙련 강의, 챌린저 반 강의, 유니티 입문 팀 프로젝트 피드백 정리
오늘 학습 한 내용을 나만의 언어로 정리하기
유니티 숙련 강의
AI Navigation
- Navigation → Object 로 두고 하이어라키에서 Environment 밑에 있는 오브젝트 클릭하면 설정할 수 있음
- Ex : LowpolyTerrain은 Walkable, Water는 Not Walkable 임
- 이걸 Bake를 해서 걸어다닐 수 있는 길과 아닌 길을 인식하도록 해줘야함
- Nav Mesh Obstacle : 특정 오브젝틀르 장애물로 인식하도록 하는 컴포넌트.
- carve 를 체크해 주어야 nav mash에서 빠짐
- Move Threshold : 이 오브젝트가 Move Threshold 이상 움직이게 되면 다시 carve 한다는 의미
- Time to Stationary : 장애물이 정지되었다고 간주하는 시간
- Carve Only Stationary : 장애물이 정지되었을 때에만 Carve를 한다는 의미
- 움직이는 오브젝트에 대한 Carving은 연산량이 많이 듬.
적 만들기
- Nav Mesh Agent를 달아줘야 얘가 Bake 된 Nav Mesh 따라 움직임
// AI의 상태를 세 가지로 정의
public enum AIState
{
Idle,
Wandering,
Attacking
}
public class NPC : MonoBehaviour
{
[Header("Stats")]
public int health;
public float walkSpeed;
public float runSpeed;
public ItemData[] dropOnDeath; // 죽었을 때 드랍할 아이템들
[Header("AI")]
private NavMeshAgent agent;
public float detectDistance; // 목표 지점까지의 거리
private AIState aiState;
[Header("Wandering")]
public float minWanderDistance;
public float maxWanderDistance;
public float minWanderWaitTime; // 최소 대기 시간
public float maxWanderWaitTime;
[Header("Combat")]
public int damage;
public float attackRate;
private float lastAttackTime;
public float attackDistance;
private float playerDistance;
public float fieldOfView = 120f; // 공격 가능한 시야각
private Animator animator;
private SkinnedMeshRenderer[] meshRenders;
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
animator = GetComponent<Animator>();
meshRenders = GetComponentsInChildren<SkinnedMeshRenderer>();
}
void Start()
{
SetState(AIState.Wandering);
}
void Update()
{
// 플레이어와 NPC간의 거리 계산
playerDistance = Vector3.Distance(CharacterManager.Instance.Player.transform.position, transform.position);
// State가 멈춤 상태가 아니면 Moving 애니메이션 재생
animator.SetBool("Moving", aiState != AIState.Idle);
switch(aiState)
{
case AIState.Idle:
case AIState.Wandering:
PassiveUpdate();
break;
case AIState.Attacking:
AttackingUpdate();
break;
}
}
public void SetState(AIState state)
{
aiState = state;
switch (aiState)
{
case AIState.Idle:
agent.speed = walkSpeed;
agent.isStopped = true; // agent 멈추기
break;
case AIState.Wandering:
agent.speed = walkSpeed;
agent.isStopped = false;
break;
case AIState.Attacking:
agent.speed = runSpeed;
agent.isStopped = false;
break;
}
animator.speed = agent.speed / walkSpeed;
}
void PassiveUpdate()
{
/// 나의 예상
/*
minWanderDistance와 maxWanderDistance 사이의 랜덤한 위치를 생성
그 위치로 NavMeshAgent 이동시킴
만약에 도착했으면 minWanderWaitTime <-> maxWanderWaitTime 사이의 랜덤한 시간 동안 대기
그러고 다시 랜덤 위치 만들어서 이동?
*/
if(aiState == AIState.Wandering && agent.remainingDistance < 0.1f)
{
SetState(AIState.Idle); // 대기 상태로 전환.
// 어느정도 맞는듯?
// 현재 상태가 방황함 + NavMeshAgent가 목표 지점까지 남은 거리가 거의 없을때
// 랜덤 시간 뒤에 새 위치 찾는 함수 호출이니까.. 비슷한듯
Invoke("WanderToNewLocation", Random.Range(minWanderWaitTime, maxWanderWaitTime));
}
if(playerDistance < detectDistance)
{
SetState(AIState.Attacking);
AttackingUpdate();
}
}
void WanderToNewLocation()
{
if (aiState != AIState.Idle) return;
SetState(AIState.Wandering);
agent.SetDestination(GetWanderLocation());
}
Vector3 GetWanderLocation()
{
NavMeshHit hit;
// Vector3 NavMesh.SamplePosition(Vector3 sourcePosition, out NavMeshHit hit, float maxDistance, int areaMask)
// Vector3 sourcePosition : 일정 영역 지정
// out NavMeshHit hit : 최단 경로에 관련된 값 반환
// float maxDistance : 최대 거리
// int areaMask : 탐색할 영역의 마스크
// Random.onUnitSphere : 반지름이 1인 구
// NPC의 현재 위치 + 반지름이 minWanderDistance와 maxWanderDistance인 구 사이의 랜덤한 값만큼 이동한 위치
// 원본
/*
NavMesh.SamplePosition(
transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)),
out hit,
maxWanderDistance,
NavMesh.AllAreas);
int i = 0;
// detectDistance 보다는 먼 거리로 이동하고자 함
while(Vector3.Distance(transform.position, hit.position) < detectDistance)
{
NavMesh.SamplePosition(
transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)),
out hit,
maxWanderDistance,
NavMesh.AllAreas);
i++;
if (i == 30) break;
}
*/
// Do While 문으로 변경한 버전
int i = 0;
do
{
NavMesh.SamplePosition(
transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)),
out hit,
maxWanderDistance,
NavMesh.AllAreas);
i++;
} while (i >= 30);
return hit.position;
}
void AttackingUpdate()
{
/// 나의 예상
/*
만약에 플레이어와의 거리가 attackDistance보다 멀면 플레이어 쪽으로 이동
아니라면 플레이어를 공격 시도 (시야각 내로 들어왔다는 전제 하에)
*/
// 플레이어와의 거리가 공격 범위 내에 있고, 시야각 내부에 있을 때
if(playerDistance < attackDistance && IsPlayerInFieldOfView())
{
agent.isStopped = true;
if(Time.time - lastAttackTime > attackRate)
{
lastAttackTime = Time.time;
CharacterManager.Instance.Player.controller.GetComponent<IDamagable>().TakePhysicalDamage(damage);
animator.speed = 1f;
animator.SetTrigger("Attack");
}
}
else // 아닐 때
{
// 그럼에도 플레이어가 공격 범위 내에 있을 때
if(playerDistance < detectDistance)
{
// 플레이어에게 가는 새로운 길을 또 만들어서 가려는 시도를 함
agent.isStopped = false;
NavMeshPath path = new NavMeshPath();
// 플레이어한테 갈 수 있으면 감
// NavMeshAgent.CalculatePath(Vector3 targetPosition, NavMeshPath path) : targetPosition으로 이동 가능한지 불가능한지 반환함
if(agent.CalculatePath(CharacterManager.Instance.Player.transform.position, path))
{
// 여기 path 안에는 다양한 정보가 있음.
// 애초에 길을 못찾았는지, 아니면 장애물 때문에 갈 수 없는지 등등 자세하게 써있으니까
// 나중에 찾아보기
agent.SetDestination(CharacterManager.Instance.Player.transform.position);
}
else // 갈 수 없으면 추적을 멈추고 다시 Wandering 상태로 바꿈
{
agent.SetDestination(transform.position);
agent.isStopped = false;
SetState(AIState.Wandering);
}
}
else // 플레이어가 공격 범위 내에 있지 않을 때 추적을 멈추고 Wandering 상태로 바꿈
{
agent.SetDestination(transform.position);
agent.isStopped = true;
SetState(AIState.Wandering);
}
}
}
bool IsPlayerInFieldOfView()
{
// 시야각 내로 들어왔는지 여부
// 1. 몬스터가 플레이어를 바라보는 방향의 벡터를 만듬 : 플레이어 위치 - 몬스터 위치
Vector3 directionToPlayer = CharacterManager.Instance.Player.transform.position - transform.position;
// 2. 몬스터 위치와 몬스터->플레이어 방향 벡터 간의 각도를 구함
float angle = Vector3.Angle(transform.position, directionToPlayer);
// 3. 그 각도가 몬스터의 시야각보다 작으면 true, 아니면 false
// 의문 : 근데 fieldOfView가 항상 양수로만 나오나?
return angle < fieldOfView * 0.5f;
}
}
- Vector3.Angle()은 항상 양수가 나오는지 궁금해서 찾아봤는데, 그렇다고 함 공식 문서
- 왼쪽/오른쪽 등의 방향을 알고싶으면 Vector3.SignedAngle을 사용해야 한다고 함
// 몬스터가 데미지를 입도록 만들기
public void TakePhysicalDamage(int damage)
{
health -= damage;
if(health <= 0)
{
// 죽어야됨
Die();
}
// 데미지 효과
StartCoroutine(DamageFlash());
}
void Die()
{
for(int i = 0; i < dropOnDeath.Length; i++)
{
Instantiate(dropOnDeath[i].dropPrefab, transform.position + Vector3.up * 2, Quaternion.identity);
}
Destroy(gameObject);
}
IEnumerator DamageFlash()
{
for(int i = 0; i < meshRenders.Length; i++)
{
meshRenders[i].material.color = new Color(1.0f, 0.6f, 0.6f);
}
yield return new WaitForSeconds(0.1f);
for(int i = 0; i < meshRenders.Length; i++)
{
meshRenders[i].material.color = Color.white;
}
}
// NPC에게 데미지를 넣도록 스크립트 수정
// EquipTool.cs
public void OnHit()
{
Ray ray = camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2, 0));
RaycastHit hit;
if(Physics.Raycast(ray, out hit, attackDistance))
{
if(doesGatherResources && hit.collider.TryGetComponent(out Resource resource))
{
resource.Gather(hit.point, hit.normal);
}
// 추가 된 부분
if(doesDealDamage && hit.collider.TryGetComponent(out IDamagable damagable))
{
damagable.TakePhysicalDamage(damage);
}
}
}
발자국 소리 만들기
public class Footsteps : MonoBehaviour
{
public AudioClip[] footstepClips;
private AudioSource audioSource;
private Rigidbody _rigidBody;
public float footstepThreshold; // 속도의 크기가 임계값 이상이어야 재생
public float footstepRate;
private float footStepTime;
void Start()
{
_rigidBody = GetComponent<Rigidbody>();
audioSource = GetComponent<AudioSource>();
}
void Update()
{
if(Mathf.Abs(_rigidBody.velocity.y) < 0.1f)
{
if(_rigidBody.velocity.magnitude > footstepThreshold)
{
if(Time.time - footStepTime > footstepRate)
{
footStepTime = Time.time;
audioSource.PlayOneShot(footstepClips[Random.Range(0, footstepClips.Length)]);
}
}
}
}
}
노래가 나오는 뮤직존 설정
public class MusicZone : MonoBehaviour
{
public AudioSource audioSource;
public float FadeTime;
public float maxVolume;
private float targetVolume;
// Start is called before the first frame update
void Start()
{
targetVolume = 0;
audioSource = GetComponent<AudioSource>();
audioSource.volume = targetVolume;
audioSource.Play();
}
// Update is called once per frame
void Update()
{
// Mathf.Approximately : 두 소수가 비슷한지 여부.
if(!Mathf.Approximately(audioSource.volume, targetVolume))
{
// 오디오소스의 볼륨이랑 목표하는 볼륨이 눈에 띄게 다르다면
// 점진적으로 오디오소스의 볼륨을 목표하는 볼륨에 다다르도록 변경함
audioSource.volume = Mathf.MoveTowards(audioSource.volume, targetVolume, (maxVolume / FadeTime) * Time.deltaTime);
}
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
targetVolume = maxVolume;
}
}
private void OnTriggerExit(Collider other)
{
if (other.CompareTag("Player"))
{
targetVolume = 0;
}
}
}
챌린저 반 강의 (주제 : 디자인 패턴)
디자인 패턴?
- 원래 디자인 패턴은 건축 아이디어였음.
- 사람들이 직관적으로 좋다고 느끼는 공간에는 공통된 패턴이 있다고 함.
- 이 패턴들은 반복적인 문제에 대한 해결책으로 재사용 된다고 했음.
- 각 패턴은 문제 > 해결 방법 > 적용 방식으로 설명됨
- 각 패턴은 상호 연겨되어 언어처럼 조합할 수 있음
- 공간 설계라는 물리적 세계에서 시작된 인간 주심의 디자인 철학을 추상화해서 코드 세계로 옮긴 것임.
왜 써야함?
- 협업 중에서 타인의 코드를 파악/수정하고 새 기능을 더하는 일이 필요함
- 근데 이 과정에서 버그가 생기거나 성능이 떨어지는 경우가 생김. (복잡할수록)
- 이럴 때 디자인 패턴이 해결 도구가 될 수 있음.
- 패턴 이름만으로도 핵심을 공유할 수 있음.
- 개발 중 마주치는 문제의 대부분은 이미 수많은 개발자들이 경험하고 해결책을 만들어낸 문제임.
- 디자인 패턴은 축적된 경험의 산물임.
- 그러나 무조건적인 적용이 해결책은 아님.
- 패턴은 도구일 뿐 목적이 아님.
- 이 상황에서 정말 필요한지 고민해보아야 함.
싱글톤 패턴
- 프로그램 전체에서 하나의 인스턴스만 존재하도록 보장하는 디자인 패턴
- 주로 객체 하나로 유지되어야 할 매니저 단위의 객체에서 사용함
장점
- 전역 접근
- 메모리 절약
- 설치/초기화 간단
단점
- 테스트가 어려움
- 사용처의 추적이 어려움
- 전역 상태 오염
싱글톤에 적합한 클래스
- GameManager, AudioManager, SceneLoader, DataManager, PoolManager
- 공통 특징 : 여러 시스템에서 공통으로 사용되고, 상태를 유지해야 함. 전체 수명주기 동안 살아있는 객체들
싱글톤에 부적합한 클래스
- 개별 인스턴스가 여러 개 존재해야 하는 경우 - Player, Enemy, Item
- 여러 개 뜨거나, 상태가 독립적이어야 하는 경우 - InventorySlot, UIWindow, Popup
- 동시 다발적으로 생기고 사라지는 경우 - Skill, Bullet, Effect
코드 사용 예제
// 일반 클래스용 싱글톤 제네릭
public abstract class Singleton<T> where T : class, new()
{
protected static T _instance;
public static T Instance
{
get
{
if(_instance == null)
{
_instance = new T();
}
return _instance
}
}
public static bool IsCreatedInstance()
{
return (_instance != null)
}
}
// 모노비헤이비어 상속용 싱글톤 제네릭
public abstract class SingletonWithMono<T> : Monobehaviour where T : Component
{
protected static T _instance;
public static T Instance
{
get
{
if(_instance == null)
{
_instance = FindObjectOfType<T>();
if(_instance == null)
{
GameObject go = new GameObject(typeof(T).ToString() + "(Singleton)");
_instance = go.AddComponent<T>();
if(!Application.isBatchMode)
{
if (Application.isPlaying)
DontDestroyOnLoad(go);
}
}
}
return _instance;
}
}
public static bool IsCreatedInstance()
{
return (_instance != null);
}
}
팩토리 패턴
- 객체를 직접 만들지 않고, 공장에서 대신 만들어주는 구조.
- 객체를 만들어 반환하는 함수를 제공해 초기화 과정을 외부에서 보지 못하게 숨기고 반환 타입을 제어하는 방법
- 객체를 new / Instantiate를 이용해 직접 만들지 않고 팩토리에게 객체 생성을 전담함
사용 이유
- 직접 new를 하지 않고 팩토리 내부에서 생성하기 때문에 확장이 쉬움
- 이름, 상태 등 여러 조건에 따라 적절한 객체를 생성할 수 있음
- 생성 방식이 바뀌어도 외부 코드는 그대로이기 때문에 결합도가 감소함
언제 사용하는지?
- 어떤 객체를 생성할 지 상위 클래스는 모르고, 하위 클래스에서 결정해야 할 때
- 객체의 종류가 자주 바뀌거나 추가될 수 있을 때
코드 사용 예제
// 기본
// 제품 인터페이스
interface ICoffee
{
void Drink();
}
// 구체적인 제품
class Latte : ICoffee
{
public void Drink() => Console.WriteLine("라떼 마시기.");
}
// 팩토리
class CoffeeFactory
{
public ICoffee MakeCoffee(string type)
{
if(type == "Latte") return new Latte();
// 기타 다른 커피들도 있을 수 있겠지...
// 그러나 무엇이 되었든 반환하는 내용은
// ICoffee를 상속해 Drink()가 구현된 커피일 것!
return null;
}
}
// 오브젝트 풀링을 더해서!
public class CoffeeFactory
{
private readonly ObjectPool<Latte> lattePool = new();
private readonly ObjectPool<Americano> americanoPool = new();
public ICoffee MakeCoffee(string type, string customer)
{
// 각 풀에서 가져와가지고 넘겨줌
ICoffee coffee = type switch
{
"Latte" => lattePool.Get(),
"Americano" => americanoPool.Get(),
_ => throw new ArgumentException("알 수 없는 커피 종류입니다.")
};
coffee.Serve(customer);
return coffee;
}
public void ReturnCoffee(ICoffee coffee)
{
switch (coffee)
{
// 각 풀에다가 반환해줌
case Latte latte :
lattePool.Return(latte);
break;
case Americano americano:
americanoPool.Return(americano);
break;
}
}
}
옵저버 패턴
- 어떤 객체의 상태가 바뀌면, 이를 구독한 다른 객체들이 자동으로 알림을 받는 구조
언제 사용하는지?
- 상태 변경이 여러 객체에 영향을 줄 때
- UI 이벤트 시스템, 게임 내 알림 시스템, 데이터 바인딩, 이벤트 리스너 등에 활용함
코드 사용 예시
// 옵저버 인터페이스
public interface IHealthObserver
{
void OnHealthChanged(int currentHealth, int maxHealth)
}
// Subject - 실제로 값이 바뀌는 주체
// 플레이어
public class Player : MonoBehaviour
{
[Header("Health Settings")]
public int MaxHealth = 100;
private int currentHealth;
public int CurrentHealth
{
get { return currentHealth;}
set { currentHealth = value;}
}
// observers : 구독자들
private List<IHealthObserver> observers = new();
private void Awake()
{
currentHealth = maxHealth;
}
// 구독자 추가
public void AddObserver(IHealthObserver observer)
{
// 구독자들 리스트에 매개변수로 받은 옵저버가 없으면 추가해줌
if(!observers.Contains(observer)) observers.Add(observer);
}
// 구독자 삭제
public void RemoveObserver(IHeatlhObserver observer)
{
// 구독자들 리스트에 매개변수로 받은 옵저버가 있으면 삭제해줌
if(observers.Contains(observer)) observers.Remove(observer);
}
// 구독자들에게 알림 보내기
private void NotifyObservers()
{
foreach(var observer in observers)
observer.OnHealthChanged(currentHealth, MaxHealth)
}
// 데미지 받음
public void TakeDamage(int amount)
{
currentHealth = Mathf.Clamp(currentHealth - amount, 0, MaxHealth);
NotifyObservers();
}
// 체력 회복
public void Heal(int amount)
{
currentHealth = Mathf.Clamp(currentHealth + amount, 0, MaxHealth);
NotifyObservers();
}
}
// Observer - 옵저버 / 관측자
// 체력바
public class HealthBar : MonoBehaviour, IHealthObserver
{
public Slider healthSlider;
private Player player;
private void Start()
{
player = FindObjectOfType<Player>();
if(player != null)
{
// 플레이어를 구독함
player.AddObserver(this);
OnHealthChanged(player.CurrentHealth, player.MaxHealth);
}
}
public void OnHealthChanged(int currentHealth, int maxHealth)
{
float ratio = (float) currentHealth / maxHealth;
healthSlider.value = ratio;
}
private void OnDestroy()
{
if(player != null)
// 삭제될 때 구독 취소
player.RemoveObserver(this);
}
}
- 변형이 많기 때문에 아주 중요한 패턴임.
중재자 패턴
- 모든 객체가 서로 말하는 대신 중앙 관리자를 통해 소통함
- 객체들끼리 서로 모르게 만듬
- 여러 컴포넌트 간의 직접적인 참조를 피하고 중앙에서 상호작용을 조정하도록 만드는 구조
사용 이유
- 객체들끼리 직접 연결되면 너무 복잡해짐
- 유지 보수가 어렵고 객체 간 결합도가 너무 높아지기 때문임
코드 사용 예시
- 기본 방식
// 1. Player가 Mediator에게 알림
public class Player : MonoBehaviour
{
private int hp = 100;
public int GetHP() => hp;
public void TakeDamage(int amount)
{
hp = Mathf.Max(hp - amount, 0);
// Mediator에게 알리기
Mediator.BroadCast("PlayerDamaged");
}
}
// 2. UIManager가 Mediator를 구독함
public class UIManager : MonoBehaviour
{
public TextMeshProGUI hpText;
private Player player;
private void OnEnable()
{
Mediator.Subscribe("PlayerDamaged", OnPlayerDamaged);
}
private void OnDisable()
{
Mediator.Unsubscribe("PlayerDamaged", OnPlayeDamaged);
}
private void Start()
{
player = FindObjectOfType<Player>();
UpdateHPText();
}
private void OnPlayerDamaged()
{
UpdateHPText();
}
private void UpdateHPText()
{
if(player != null)
hpText.text = $"HP : {player.GetHP()}";
}
}
// 3. Mediator의 구조
public static class Mediator
{
// 이벤트 목록들
private static Dictionary<string, Action> eventTable = new();
public static void Subscribe(string eventName, Action callback)
{
// 만약에 이벤트 목록들에 처음 들어오는 이름의 경우
// 그 이름을 받았을 때 실행할 함수들을 델리게이트로 지정해 놓아야 하니까
// 빈 델리게이트를 하나 만들어줌
if(!eventTable.ContainsKey(eventName))
eventTable[eventName] = delegate { };
eventTable[eventName] += callback;
}
public static void Unsubscribe(string eventName, Action callback)
{
if(eventTable.ContainsKey(eventName))
eventTable[eventName] -= callback;
}
public static void BroadCast(string eventName)
{
// 이벤트와 연결된 모든 함수들을 실행시켜주기
if(eventTable.TryGetValue(eventName, out var callback))
callback.Invoke();
}
}
- 옵저버 + 중재자로 이벤트 버스 만들기
- 특징 : 여기서는 Action이 그냥 액션이 아니고 Object를 반환하는 Action으로 변경됨
// 이벤트 버스 (중재자 역할)
public static class EventBus
{
private static Dictionary<string, Action<object>> listeners = new();
public static void Subscribe(string eventKey, Action<object> callback)
{
if (!listeners.ContainsKey(eventKey))
listeners[eventKey] = delegate { };
listeners[eventKey] += callback;
}
public static void Unsubscribe(string evnetKey, Action<object> callback)
{
if(listeners.ContainsKey(eventKey))
listeners[eventKey] -= callback;
}
public static void Publish(string eventKey, object data = null)
{
if (listeners.TryGetValue(eventKey, out var action))
action.Invoke(data);
}
}
// 관측 가능한 데이터
public class Observable<T>
{
private T value;
private readonly string eventKey;
public Observable(string key, T initValue = default)
{
eventKey = key;
value = initValue;
}
public T Value
{
get => value;
set
{
// 데이터가 변경되었으면 키에 맞는 델리게이트를 발생시킴
if (!Equals(this.value, value))
{
this.value = value;
EventBus.Publish(eventKey, value);
}
}
}
}
// 데이터 보관소
public static class UserDataManager
{
public static Observable<int> PlayerLevel = new("PlayerLevelChanged", 1);
public static Observable<float> PlayerExp = new("PlayerExpChanged", 0f);
}
// 옵저버를 활용한 UI 클래스
public class PlayerUI : MonoBehaviour
{
public TextMeshProUGUI levelText;
public TextMeshProUGUI expText;
private void OnEnable()
{
EventBus.Subscribe("PlayerLevelChanged", OnPlayerLevelChanged);
EventBus.Subscribe("PlayerExpChanged", OnPlayerExpChanged);
}
private void OnDisable()
{
EventBus.Unsubscribe("PlayerLevelChanged", OnPlayerLevelChanged);
EventBus.Unsubscribe("PlayerExpChanged", OnPlayerExpChanged);
}
private void OnPlayerLevelChanged(object data)
{
int level = (int)data;
levelText.text = $"Level : {level}";
}
private void OnPlayerExpChanged(object data)
{
float exp = (float)data;
expText.text = $"Exp : {exp:F1}";
}
}
상태 패턴
- 상태 자체를 객체로 만들어서 분리하는 패턴
언제 사용하는지?
- 상태가 자주 바뀌고 행동이 달라지는 경우
코드 사용 예시
// 상태 구현 예시
public interface IState
{
void Enter();
void Update();
void Exit();
}
public class StateManager
{
private IState currentState;
public void ChangeState(IState newState)
{
currentState.Exit();
currentState = newState;
currentState.Enter();
}
public void Update()
{
currentState.Update();
}
}
FSM : 유한 상태 기계
-
하나의 시점에 하나의 상태만을 가지는 시스템
-
입력이나 조건에 따라 상태 간 전이가 발생함
-
FSM 구성 요소
요소 | 설명 |
---|---|
상태 | 현재 시스템의 동작/상황 (Idle , Move , Attack , 등) |
전이 | 특정 조건 또는 이벤트가 발생하면 상태를 바꿈 |
이벤트 | 전이 조건이 되는 입력 |
초기 상태 | 시스템이 시작될 때 머무는 상태 |
- FSM은 보통 Switch-case로 분기함
- 따라서 상태가 많아지면 관리하기가 어려움
- 상태 패턴은 클래스를 따로 만듬
FSM + 상태 패턴 예시
// 상태 인터페이스
public interface IState
{
void Enter();
void Execute();
void Exit();
}
// 전이 클래스
// FromState에서 특정 조건 (condition)을 만족하면 ToState로 변경하도록 하는 전이 구현
public class Transition
{
public IState FromState {get;}
public IState ToState {get;}
public Func<bool> Condition {get;}
public Transition(IState from, IState to, Func<bool> condition)
{
FromState = from;
ToState = to;
Condition = condition;
}
}
// FSM 클래스
public class FSM
{
private IState currentState;
private List<Transition> transitions = new();
public void SetInitialState(IState state)
{
currentState = state;
currentState.Enter();
}
public void AddTransition(IState from, IState to, Func<bool> condition)
{
transitions.Add(new Transition(from, to, condition));
}
public void Update()
{
currentState.Execute();
foreach(var t in transitions)
{
if(t.FromState == currentState && t.Condition())
{
currentState.Exit();
currentState = t.ToState();
currentState.Enter();
// 한 번 상태전이를 했으면 멈춤
break;
}
}
}
}
- 애니메이터와 비슷함.
커맨드 패턴
- 명령을 객체로 만들어 실행을 나중에 하거나 저장할 수 있게 하는 구조
- 행동 로그, Undo, 리플레이 등에 자주 씀
언제 사용하는지?
- 행동 자체를 저장하거나 나중에 실행 해야 할 때
- 기록 가능한 명령이 필요할 때
구성요소
구성 요소 | 역할 |
---|---|
Command 인터페이스 | 실행될 명령 정의 (Execute() , Undo() 등) |
ConcreteCommand | 실제 명령을 구현한 클래스 |
Invoker | 명령을 실행하는 주체 (보통 UI, 버튼 등) |
Receiver | 명령의 실제 기능을 수행하는 대상 객체 |
코드 사용 예시
// 인터페이스
public interface ICommand
{
void Execute();
void Undo();
}
// ConcreteCommand
public class MoveCommand : ICommand
{
private Player player;
public MoveCommand(Player player) => this.player = player;
public void Execute() => player.Move();
public void Undo() => Debug.Log("이동 취소");
}
// Invoker
public class InputHandler
{
public ICommand moveCommand;
public void HandleInput()
{
if (Input.GetKeyDown(KeyCode.W)) moveCommand.Execute();
}
}
// Invoker를 부르기
public class GameManager : MonoBehaviour
{
public Player player;
private InputHandler input;
void Start()
{
input = new InputHandler
{
moveCommand = new MoveCommand(player);
};
}
void Update()
{
input.HandleInput();
}
}
전략 패턴
- 행동 방식을 객체로 만들어서 필요에 따라 교체할 수 있는 구조
언제 사용하는지?
- if/switch 동작 분기 코드가 많을때
코드 사용 예시
// 스킬 베이스
public abstract class BaseSkill
{
void Use(GameObject caster);
}
// 다양한 스킬들
public class FireBall : BaseSkill
{
public void Use(GameObject caster)
{
// 파이어볼 이펙트 / 데미지 계산 등등
}
}
public class IceSpear : BaseSkill
{
public void Use(GameObject caster)
{
// 얼음창 이펙트 / 슬로우 효과 등등
}
}
public class Heal : BaseSkill
{
public void Use(GameObject caster)
{
// 회복 이펙트 / 체력 증가 등등
}
}
// 캐릭터
public class Character : MonoBehaviour
{
private BaseSkill currentSkill;
public void SetSkill(BaseSkill skill)
{
currentSkill = skill;
}
public void UseSkill()
{
if (currentSkill != null)
currentSkill.Use(gameObject);
else
Debug.Log("스킬이 설정되지 않았습니다.");
}
}
커맨드 패턴과 전략 패턴의 차이
- 커맨드 패턴 : 무엇을 할지를 객체로 만듬
- 전략 패턴 : 어떻게 할지를 객체로 만듬
챌린저 반 강의 (주제 : 프로젝트 구조)
1. 로직에서 고정값을 제거하자
- 고정된 값을 쓰는 것 보다 변수를 쓰자.
- 예시 : 속도를 바로 3.6f 이렇게 쓰지 말고, float speed = 3.6f; 이렇게 쓰자.
- 테스트를 하면서 수치가 바뀔 수 있기 때문임.
2. UI 코드에 로직을 넣지 말자.
- UI 코드가 많아지면 UI에 기능이 너무 종속됨
- 다른 UI에서 동일한 로직이 필요하게 될 수도 있다.
- 그리고 UI는 언제든지 파괴될 수 있다.
- 실제 로직이 동작할 컨트롤러나 매니저, 시스템을 만들어서 쓰는게 좋다.
3. 변수 타입을 잘 정하자.
- 변수를 선언할 때 GameObject로 선언하지 말고, 가장 자주 사용될 타입으로 설정하기
- GameObject
- 활성화/비활성화가 주 목적일 때
- 특별한 기능을 활용하지 않을 때에는 기본값으로 좋음
- Transform
- 위치 / 회전 / 크기 조정
- 부모 / 자식 계층 관리용
4. 상수를 쓰자.
- 튜터님 피셜 가장 가성비가 좋은 작업! 꼭 진행하길 권장함
- 고정값을 제거하자와 이어짐
- 상수들을 모아둔 클래스를 만들어두면 유용함 (Enum 포함)
5. 프리팹 구조
- 프리팹 루트에 모델링이나 이미지를 적용하지 마셈
- 빈 GameObject를 권장함
- 모델링에 컴포넌트를 추가하여 만들게 되면 이후 모델링 변경할 때 세팅 다시 해야됨
- 루트가 빈 GameObject면은 임시 모델링을 나중에 실제 캐릭터로 바꾸기 용이함
- 그리고 인스펙터에 미리 연결해두는 데이터는 해당 프리팹 내부에 있는 오브젝트들로만 구성하셈
6. 데이터 설정
- 데이터가 자동으로 알아서 초기화 되도록 설계하기
- 재활용 하기 좋음
- 정보를 받아서 그거에 맞게 세팅해주는 구조가 있으면 아주 편리함.
실제 프로젝트 만드는 순서
- SingletoneBase<T>
- Constants, Define, Global Data
- UIManager
- SceneManager
- 여기까지가 우선으로 개발하는 부분
-
DataManager
-
ResourceManager
-
NetworkManager
-
우선 개발 부분이 동적 생성을 위한 준비를 위해 필요함.
-
여기서 좀 더 가능하다면 MVC 패턴을 적용해서 사용하는 것
7. 싱글톤
- 베이스 싱글톤 클래스를 만들어서 하는걸 추천함
- 장점
- 매번 싱글톤 구성요소를 안만들어도 된다.
- 씬에 올려두지 않아도 호출시 자동으로 생성됨
- 단점
- 매니저 인스펙터에 오브젝트를 미리 연결해둘 수 없음.
// 베이스 싱글톤 클래스 예제
public class SingletoneBase<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _Instance;
public static T Instance
{
get
{
if (_Instance == null)
{
_Instance = (T)FindObjectOfType(typeof(T));
if(_Instance == null)
{
var singletonObject = new GameObject();
_Instance = singletonObject.AddComponent<T>();
singletonObject.name = $"[{typeof(T)}];
singletonObject.tag = "Singleton";
}
}
return _Instance
}
}
}
- 씬 전환 시에도 유지되는 인스턴스의 경우 수동 릴리즈 기능을 만들어 두면 좋음
// 수동 릴리즈 예시
public virtual void Release()
{
if(_Instance == null) return;
if(_Instance.gameObject) Destroy(_Instance.gameObject);
_instance = null;
}
8. Scene 관리
- 씬 기능에 Enter / Exit 를 관리하는 지점이 있으면 씬 전환 시 흐름 제어가 용이함
- 기본 씬 클래스를 만들어놓고 다른 씬들이 그걸 상속하도록 하기
// 예시 코드
// 기본 씬 클래스
public abstract class SceneBase
{
public virtual void OnEnter() { }
public virtual void OnExit() { }
}
// 기본 씬 클래스를 상속받은 구현부
public class IntroScene : SceneBase
{
public override void OnEnter()
{
base.OnEnter();
UserManager.Instance.Init();
UIManager.Instance.ShowUI<UILogin>();
}
public override void OnExit()
{
base.OnExit();
UserManager.Instance.Release();
}
}
IEnumerator LoadScene()
{
yield return null;
AsyncOperation op = SceneManager.LoadSceneAsync(NextScene.ToString());
op.allowSceneActivation = false;
float timer = 0.0f;
while (!op.isDone)
{
yield return null;
OnLoadingProgressUpdate?.Invoke(op.progress);
if (op.progress < 0.9f)
{
}
else
{
op.allowSceneActivation = true;
PrevScene = CurrentScene;
CurrentScene = NextScene;
// 씬이 넘어가는 시점에 실행할 함수들을 설정하고 그걸 부름
_scenes[PrevScene].OnExit();
_scenes[CurrentScene].OnEnter();
break;
}
}
}
- 지금 씬 정보과 이전 씬 정보를 알고 있다면 작업에 용이함
// SceneLoadManager 예시
public class SceneLoadManager : Singletonebase<SceneLoadManager>
{
public static Scene CurrentScene {get; private set;} = Scene.Intro;
public static Scene PrevScene {get; private set;}
// NextScene은 선택사항
public static Scene NextScene {get; private set;}
private Dictionary<Scene, SceneBase> _scenes;
private Action<float> OnLoadingProgressUpdate;
public override void Init()
{
base.Init();
_scenes.Add(Scene.Intro, new IntroScene());
_scenes.Add(Scene.Main, new GameScene());
}
}
9. MVC 구조
- Model : 데이터
- View : UI
- Controller : Manager, Controller, System, Handler → 실제 로직을 처리하는 부분들
10. UI Manager
- UIManager에 UI를 각각 변수로 들고 있는건 안좋음
- List / Dictionary 등 컬렉션을 이용한 구조를 고려
- Dictionary<string, GameObject> UIList = new(); 이런식으로
- UI를 닫을 때 비활성화 하는 방법과 파괴하는 방법에 대해 고민할 수 있음
- 비활성화 : 최적화에 도움이 됨. 다만 비활성화가 임시로 된건지 닫기위해 된건지 모호함
- 파괴 : 규모는 고려해야겠지만, 요즘 UI 만들고 파괴하는게 큰 무리는 없어서 그게 관리가 편하면 그렇게 하셈
11. 동적 로딩
-
씬에 오브젝트를 미리 올리고 로드하는 경우의 단점들
- 로딩이 오래걸림
- 필요없는 데이터도 우선 로딩하게 됨
- 준비된 오브젝트 간의 초기화 순서를 지정하기 어려움
-
유니티 입문에서 50% 이상은 A 라는 오브젝트가 B에게 접근할 수 있게 만드는거에 시간이 걸림
- 보통은 인스펙터에서 연결
- 근데 프로젝트가 커지면 감당이 안됨
-
오브젝트를 코드에서 생성하면 인스펙터 연결 없이 변수에 저장해둘 수 있음.
- 이 작업을 매니저(싱글톤) 에서 준비하면 전역적으로 접근하기 쉬워짐
-
서로 다른 클래스에서 접근이 필요한 경우, 직접 접근은 권장하지 않음.
- 매니저를 통해 할당하는게 편함
튜터님의 첨언
- Find라는 기능은 그냥 없다고 생각해라.
- 성능적으로도 그렇고 문제가 많음
- FindByType 까지는 괜찮은데 Find는 하지마라 진짜
유니티 입문 팀 프로젝트 피드백
- 데이터 다룰 때에는 null 예외처리를 꼭 하기
- 아이템 데이터의 원본과 실제 사용되는 사용본을 구분하기
- string 하드코딩 하지 마세요!!
- obstaclePreset을 간소화 할 수 있을 것으로 보임
- 장애물 타입을 위한 딕셔너리, 아이템 타입을 위한 딕셔너리를 따로 구현