오늘 학습 키워드

유니티 숙련 강의, 챌린저 반 강의, 유니티 입문 팀 프로젝트 피드백 정리

오늘 학습 한 내용을 나만의 언어로 정리하기

유니티 숙련 강의

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. 기본 방식
// 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(); 
	}
 
}
  1. 옵저버 + 중재자로 이벤트 버스 만들기
    • 특징 : 여기서는 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을 간소화 할 수 있을 것으로 보임
    • 장애물 타입을 위한 딕셔너리, 아이템 타입을 위한 딕셔너리를 따로 구현