유니티

옵저버 패턴 - 플레이어 상태 UI

미역제자 2024. 9. 22. 14:50
옵저버 패턴이란?

옵저버 패턴은 객체의 상태 변화를 감지하고, 그 변화를 다른 객체들에게 자동으로 통보하는 디자인 패턴입니다. 이를 통해 주체(Subject)옵저버(Observer) 간의 관계를 정의할 수 있습니다.

 

옵저버 패턴의 기본 요소:

  1. Subject(주체): 상태 변화를 알리고 관리하는 객체입니다. 여러 옵저버를 등록하거나 해제할 수 있으며, 상태가 변화하면 옵저버들에게 이를 통지합니다.
  2. Observer(옵저버): 주체의 상태 변화를 감지하는 객체입니다. 주체로부터 통지를 받아 상태를 갱신하거나 특정 작업을 수행합니다.

사용한 옵저버 패턴 내용 정리

 

사용하고자 한 내용

플레이어의 체력, 목마름, 배고픔, 스테미나와 같은 여러 수치들이 변하는 것을 UI에서 관찰하고, 값이 변하면 스스로 UI가 변하도록 만들고 싶어서 사용.

 

주체와 옵저버

  • Observer = UI_PlayerCondition (또 다른 옵저버 추가 가능)
  • Subject = PlayerCondition

스크립트 역할

  • PlayerCondition
    • 옵저버 관리 : Attach(), Detach()
    • 옵저버에 변경정보 알림: Notify()
  • PlayerStatusUpdater
    • 시간당 감소하는 배고픔 목마름 :TimeToDecrease()
    • 배고픔, 목마름 0일때 체력 감소: HealthDecrease()
    • 스테미나 회복, 감소: StaminaUpdate()
  • UI_PlayerCondition
    • 변하는 플레이어의 State를 반영하는 상태 UI.
  • PlayerStatus
    • 플레이어의 여러 정보, 수치

 

옵저버 패턴의 흐름

  1. PlayerCondition(주체)는 플레이어 상태(배고픔, 목마름, 스테미나 등)가 변경될 때, 옵저버로 등록된 객체들( UI_PlayerCondition 등)에 상태 변화를 통지.
  2. UI_PlayerCondition (옵저버)는 이 변화를 감지하고, 상태를 반영하여 UI를 업데이트.
  3. 이를 통해 플레이어의 상태가 변동될 때마다 자동으로 UI가 갱신되고, 게임 흐름을 끊김 없이 유지할 수 있다

상세 코드

더보기

PlayerCondition

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;

#region Condition 옵저버
// 상태 변화를 관찰하는 옵저버가 구현해야 하는 메서드를 선언.
public interface IObserver
{
    void OnPlayerStateChanged(PlayerState state);
}

#endregion

#region Condition 주체(Subject)
// 옵저버를 관리하고 상태 변화를 알리는 역할.
public interface ISubject
{
    public void Attach(IObserver observer);
    public void Detach(IObserver observer);
    public void Notify();
}
#endregion


// "ISubject" 인터페이스를 구현하고, 상태 변화를 알리는 역할.
public class PlayerCondition : MonoBehaviour, ISubject
{
    private List<IObserver> _observers = new List<IObserver>();
    private PlayerState _state;
    private float _maxValue = 100f;

    private void Start()
    {
        // TODO: 저장 시점에 어떤 값을 가지고 있는지, 게임 시작시 초기화.
        _state = new PlayerState(_maxValue, _maxValue, _maxValue, _maxValue);

        UI_PlayerCondition uI_PlayerCondition = FindObjectOfType<UI_PlayerCondition>();
        if (uI_PlayerCondition != null)
        {
            Attach(uI_PlayerCondition);
        }

        // PlayerStateUpdater를 추가하여 시간에 따른 상태 감소를 처리.
        PlayerStateUpdater updater = gameObject.AddComponent<PlayerStateUpdater>();
        updater.Initialize(this, _state);
    }

    #region 옵저버 관리
    public void Attach(IObserver observer)
    {
        _observers.Add(observer);
    }

    public void Detach(IObserver observer)
    {
        _observers.Remove(observer);
    }
    #endregion

    public void Notify()
    {
        // 모든 옵저버들에게 공지.
        foreach (var observer in _observers)
        {
            observer.OnPlayerStateChanged(_state);
        }
    }

    #region 플레이어 State 수치 Update

    public void UpdateHunger(float amount)
    {
        _state.Hunger += amount;
        Notify();
    }

    public void UpdateHealth(float amount)
    {
        _state.Health += amount;
        Notify();
    }

    public void UpdateThirst(float amount)
    {
        _state.Thirst += amount;
        Notify();
    }

    public void UpdateStamina(float amount)
    {
        _state.Stamina += amount;
        Notify();
    }

    #endregion
}

UI_PlayerCondition

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UI_PlayerCondition : UI_Scene, IObserver
{

    [HideInInspector] public Image health;
    [HideInInspector] public Image hunger;
    [HideInInspector] public Image thirsty;
    [HideInInspector] public Image stamina;

    public Transform ConditionParent;

    private void Start()
    {
        //ConditionParent = GameManager.Instance._UI.transform.Find("HUD_Canvas/PlayerCondition");
        //GameObject condition = Instantiate(ConditionPrefab, ConditionParent); //시작시 condition추가
        //PlayerController.instance.heartAnim = condition.transform.Find("Health/Image").GetComponent<Animator>();
        SetImage();
    }

    private void SetImage()
    {
        GameObject[] go = GameObject.FindGameObjectsWithTag("ConditionUI");
        for (int i = 0; i < go.Length; i++)
        {
            switch (go[i].name)
            {
                case "HungerImage":
                    hunger = go[i].GetComponent<Image>();
                    break;
                case "ThirstyImage":
                    thirsty = go[i].GetComponent<Image>();
                    break;
                case "StaminaImage":
                    stamina = go[i].GetComponent<Image>();
                    break;
                case "HealthImage":
                    health = go[i].GetComponent<Image>();
                    break;
            }
        }
    }

    public void OnPlayerStateChanged(PlayerState state)
    {
        hunger.fillAmount = state.Hunger / 100f;
        thirsty.fillAmount = state.Thirst / 100f;
        stamina.fillAmount = state.Stamina / 100f;
        health.fillAmount = state.Health / 100f;
    }
}

PlayerStatus 

public class PlayerStatus
{
    public float Hunger { get; set; }
    public float Health { get; set; }
    public float Thirst { get; set; }
    public float Stamina { get; set; }

    public PlayerStatus(float hunger, float health, float thirst, float stamina)
    {
        Hunger = hunger;
        Health = health;
        Thirst = thirst;
        Stamina = stamina;
    }
}

PlayerStatusUpdater

public class PlayerStatusUpdater : MonoBehaviour
{
    #region Field
    private PlayerCondition _playerCondition;
    private PlayerStatus _status;

    [HideInInspector] public float hungerDecreaseRate = 0.5f;  // 배고픔 감소 속도
    [HideInInspector] public float thirstDecreaseRate = 0.5f; // 목마름 감소 속도
    [HideInInspector] public float healthDecreaseRate = 1f;    // 체력 감소 속도 
    [HideInInspector] public float staminaDecreaseRate = 1f;    // 체력 감소 속도 

    [HideInInspector] public bool isRun = false; //TODO 달리기 상태일때 is Run값 바꿔주기.
    [HideInInspector] public bool canRun = false; //TODO 달리기 상태일때 is Run값 바꿔주기.
    private bool _isStaminaLock = false;
    #endregion
    private void Start()
    {
        GetInputController();
    }

    private void GetInputController()
    {
        // PlayerInputController의 OnRunStateChanged 이벤트 구독.
        PlayerInputController inputController = GetComponent<PlayerInputController>();
        if (inputController != null)
        {
            inputController.OnRunStateChanged += HandleRunStateChanged;
        }
    }

    private void HandleRunStateChanged(bool isRunning)
    {
        isRun = isRunning;  // 이벤트를 통해 isRun 업데이트
    }

    public void Initialize(PlayerCondition playerCondition, PlayerStatus state)
    {
        this._playerCondition = playerCondition;
        this._status = state;
    }

    private void Update()
    {
        TimeToDecrease();
        HealthDecrease();
        StaminaUpdate(isRun);
        IsPlayerDead();
    }

    private void TimeToDecrease()
    {
        // 시간에 따라 배고픔과 목마름이 감소.
        _playerCondition.UpdateHunger(-hungerDecreaseRate * Time.deltaTime);
        _playerCondition.UpdateThirst(-thirstDecreaseRate * Time.deltaTime);
    }

    private void HealthDecrease()
    {
        // 배고픔이 0이면 체력 감소.
        if (_status.Hunger <= 0)
        {
            _playerCondition.UpdateHealth(-healthDecreaseRate * Time.deltaTime);
        }
        // 목마름이 0이면 체력 감소.
        if (_status.Thirst <= 0)
        {
            _playerCondition.UpdateHealth(-healthDecreaseRate * 2f * Time.deltaTime);
        }
    }

    private void StaminaUpdate(bool isRun = false)
    {
        // 스태미나 회복 시도 (달리기 키를 누르고 있지 않을 때, 달리키를 누르더라도 달릴 수 없는 상황일 때.)
        if (!isRun || _isStaminaLock)
        {
            // 스태미나 회복.
            if (_status.Stamina <= 100)
            {
                // 배고픔에 따라 다른 스태미나 회복량.
                if (_status.Hunger >= 50)
                    _playerCondition.UpdateStamina(staminaDecreaseRate * Time.deltaTime);
                else
                    _playerCondition.UpdateStamina(staminaDecreaseRate * 0.5f * Time.deltaTime);
            }
        }
        else
        {
            // 달리기 중이고 스태미나가 0 이상일 때만 감소
            if (CheckStamina())
            {
                // 스태미나 감소.
                _playerCondition.UpdateStamina(-staminaDecreaseRate * Time.deltaTime);
            }
        }
    }

    private bool CheckStamina()
    {
        if (_status.Stamina <= 0) // 스태미나가 0 이하일 때
        {
            _isStaminaLock = true;
            return false;
        }

        if (_status.Stamina >= 20) // 스태미나가 20 이상일 때
        {
            _isStaminaLock = false;
            return true;
        }

        // 스태미나가 0과 20 사이일 때
        return !_isStaminaLock;
    }

    public bool IsPlayerDead()
    {
        // TODO 죽을 때 화면 등등..
        if(_status.Health <= 0)
        {
            return true;
        }
        else
        {
            return false;
        }
    }
}