UniRx 플러그인을 사용하면 유니티에서 MVP 패턴을 좀 더 쉽게 구현할 수 있다. (MVP 패턴에서 구현해야 되는 이벤트 기반 코드들을 더 쉽게 사용) 2020년 10월 9일 기준 v7.10(19년 7월 1일) 까지 나와 있는 상태이고, 원래는 Reactive Programming을 유니티에서 쉽게 사용 하기 위해 만들어진 플러그인이다.
UniRx를 사용하면 MVP (MVRP) 패턴을 구현할 수 있습니다.
MVVM 대신 MVP를 사용해야하는 이유?
유니티는 UI 바인딩을 제공하지 않으며, 바인딩 레이어를 만드는 것은 복잡하며, 오버헤드가 크다.
MVP 패턴을 사용하는 Presenter는 View의 구성요소를 알고 있으며 업데이트 할 수 있다. 실제 바인딩을 하지 않지만, View를 구독(Observable)하여 바인딩 하는 것과 유사하게 동작하게 할 수 있다. (복잡하지 않고, 오버 헤드도 적게 사용 가능)
이 패턴을 Reactive Presenter라고 한다.
// Presenter는 씬의 canvas 루트에 존재.
public class ReactivePresenter : MonoBehaviour
{
// Presenter는 View를 알고 있다(인스펙터를 통해 바인딩 한다)
public Button MyButton;
public Toggle MyToggle;
// Model의 변화는 ReactiveProperty를 통해 알 수 있다.
Enemy enemy = new Enemy(1000);
void Start()
{
// Rx는 View와 Model의 사용자 이벤트를 제공한다.
MyButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -= 99);
MyToggle.OnValueChangedAsObservable().SubscribeToInteractable(MyButton);
// Model들은 Rx를 통해 Presenter에게 자신의 변화를 알리고, Presenter은 Viw를 업데이트 한다.
enemy.CurrentHp.SubscribeToText(MyText);
enemy.IsDead.Where(isDead => isDead == true)
.Subscribe(_ =>
{
MyToggle.interactable = MyButton.interactable = false;
});
}
}
// Model. 모든 프로퍼티는 값의 변경을 알려 준다. (ReactiveProperty)
public class Enemy
{
public ReactiveProperty<long> CurrentHp { get; private set; }
public ReactiveProperty<bool> IsDead { get; private set; }
public Enemy(int initialHp)
{
// 프로퍼티 정의
CurrentHp = new ReactiveProperty<long>(initialHp);
IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
}
}
View는 하나의 Scene이며, Unity의 hierarchy이다. (하나의 개체 혹은 객체?)
View는 초기화시 Unity 엔진에 의해 Presenter와 연결된다.
XxxAsObservable 메서드를 사용하면 오버 헤드없이 이벤트 신호를 간단하게 생성 할 수 있습니다. SubscribeToText 및 SubscribeToInteractable은 간단한 바인딩 처럼 사용할 수 있게 하는 helper 클래스 입니다. 이것은 단순한 도구 일 수 있지만 매우 강력합니다. Unity 환경에서 자연스럽게 느껴지며 고성능과 깨끗한 아키텍처를 제공합니다.
V-> RP-> M-> RP-> V가 완전히 Reactive(반응적인)한 방법으로 연결되었다.
GUI 프로그래밍은 ObservableTrigger의 이점도 제공합니다. ObservableTrigger는 Unity 이벤트를 Observable로 변환하므로이를 사용하여 MV(R)P 패턴을 구성 할 수 있습니다. 예를 들어 ObservableEventTrigger는 uGUI 이벤트를 Observable로 변환합니다.
이벤트란, “무언가의 조건을 만족했을 때, 그 때의 정보를 통지해 다른 장소에서 처리를 실행한다”라고 하는 구조를 가리킵니다. 이벤트를 이용하면 “조건을 판정하는 부분”과 “실제로 처리를 실시하는 부분”을 분리해 구현할 수 있게 됩니다.
이벤트의 사용법으로서는 다음의 패턴을 생각할 수 있습니다.
비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다.
실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다
컴포넌트간의 종속성 구성
“비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다”는 Unity에서는 OnTriggerEnter등의 이벤트를 예로 들 수 있습니다. “언제 일어날지 모르지만, 발생했을 때는 즉시 대응한 처리를 실행하고 싶다”라고 하는 때에 이벤트의 개념을 사용할 수 있습니다.
“실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다”는, 예를 들면 “Input”을 들 수 있습니다. 게임을 하고 있는 사람(플레이어)으로부터의 Input을 받아들이고 캐릭터를 조작하게 됩니다. 이 때 아무것도 생각하지 않고 어리석게 구현하면 다양한 컴포넌트와 if(Input.GetKeyDown("Attack"))같은 처리가 흩어져 버립니다. 이러한 문제도 이벤트 개념을 사용하여 스마트하게 구현할 수 있습니다.
using UniRx;
using UnityEngine;
namespace Events
{
public sealed class InputEventProvider : MonoBehaviour
{
/// <summary>
/// 공격 버튼 입력
/// </summary>
public IReadOnlyReactiveProperty<bool> Attack => _attack;
/// <summary>
/// 이동 방향 입력
/// </summary>
public IReadOnlyReactiveProperty<Vector3> MoveDirection => _moveDirection;
/// <summary>
/// 점프 입력
/// </summary>
public IReadOnlyReactiveProperty<bool> Jump => _jump;
// 구현
private readonly ReactiveProperty<bool> _attack = new BoolReactiveProperty();
private readonly ReactiveProperty<bool> _jump = new BoolReactiveProperty();
private readonly ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();
private void Start()
{
// Destroy시 Dispose()
_attack.AddTo(this);
_jump.AddTo(this);
_moveDirection.AddTo(this);
}
private void Update()
{
// 다양한 입력을 ReactiveProperty에 반영
_jump.Value = Input.GetButton("Jump");
_attack.Value = Input.GetButton("Attack");
_moveDirection.Value = new Vector3(
x:Input.GetAxis("Horizontal"),
y:0,
z:Input.GetAxis("Vertical"));
}
}
}
이 컴퍼넌트를 준비하면, 실제로 입력 이벤트를 사용해 처리를 실시하는 컴퍼넌트로부터 이것을 참조시킵니다. 이제 “UniRx를 사용하여 입력 이벤트를 처리”할 수있었습니다.
using System;
using UniRx;
using UnityEngine;
namespace Events
{
/// <summary>
/// 예: Input을 보고 이동
/// </summary>
public class PlayerMove : MonoBehaviour
{
[SerializeField] private float _moveSpeed = 1.0f;
[SerializeField] private InputEventProvider _inputEventProvider;
private CharacterController _characterController;
private void Start()
{
_characterController = GetComponent<CharacterController>();
// 점프
// 점프 버튼 입력 이벤트 결정
_inputEventProvider.Jump
// 버튼을 눌렀을 때,
.Where(x => x)
// 접지 중이며,
.Where(_ => _characterController.isGrounded)
// 마지막으로 점프한 지 1초 이상 경과하면,
.ThrottleFirst(TimeSpan.FromSeconds(1))
.Subscribe(_ =>
{
// 점프 처리 수행
Jump();
});
// 이동 처리
_inputEventProvider
.MoveDirection
// 일정값 이상 입력하면
.Where(x=>x.magnitude > 0.5f)
.Subscribe(x =>
{
// 그쪽으로 이동
_characterController.Move(x.normalized * _moveSpeed);
});
}
private void Jump()
{
// 점프 처리(생략)
}
}
}
일반적인 이벤트 처리에 있어서는, 일반적으로는 “누구로부터 메세지가 보내 오는지”를 구독측이 어느 정도는 의식할 필요가 있습니다. 그것을 Pub/Sub 있어서는 “이벤트 메세지 그 자체”에 주목해, “누구로부터 보내져 왔는지는 신경쓰지 않는다”라고 하는 모델이 되고 있습니다. (같이 송신측도 “누가 수신하고 있는지는 신경쓰지 않는다”라고 하는 형태가 됩니다)
Pub/Sub를 사용하면 송신자와 수신자를 더 느슨하게 결합할 수 있습니다. 컴퍼넌트간의 참조 관계나 의존관계를 정리해 휘두르는 일 없이, 보다 데이터 플로우를 중심으로 한 구현을 실시하는 것이 가능해집니다. 그러나 다른 한편으로는 올바르게 메시지를 관리 할 수 없으면 스파게티 코드가 가속된다는 문제점도 Pub/Sub있습니다. 초보자에게 추천할 수 있는 기능은 아닙니다만, 이런 것도 있으면 머리의 한쪽 구석에서 두면 어느 것이 도움이 될 때가 올 것입니다.
Pub/Sub을 가볍게 시도하고 싶다면 UniRx MessageBroker를 쉽게 사용할 수 추천합니다. 거기에서 한층 더 밟아, “DI와 조합해 사용하고 싶다” “서버 통신을 얽힌 Pub/Sub것을 실시하고 싶다”라고 하는 경우는 MessagePipe를 사용해 보면 좋을 것입니다.
앞에서 결론에서 언급하면 비동기 처리에 UniRx를 사용하는 것은 더 이상 권장 하지 않습니다. 과거 2017년 이전의 Unity에서는 비동기 처리의 선택사항으로서 괜찮은 것이 UniRx 정도밖에 없었습니다. 그러나 현대에서는 async/await나 UniTask의 등장에 의해, 굳이 UniRx를 사용해 비동기 처리를 취급할 필요성이 없어졌습니다.
(원래 「비동기 처리」란 무엇을 가리키는지입니다만, 이쪽은 말하면 길어지기 때문에 다른 기사에서 다시 투고 예정입니다)
“비동기 처리에 UniRx를 사용하는 것은 비추천”이지만 일부 Operator가 매우 편리합니다. 특히 OnErrorRetry는 에러 발생시 지정 횟수까지 재 시도를 해주는 것입니다. 이런 Operator를 async/await 함께 try-catch 사용하면 상당히 복잡해지기 때문에, async/await와 함께 Observable을 같이 사용하는 것도 어려울 수 있지만 존재합니다.
단, 어리석게 ToObservable().OnErrorRetry()만 해도 잘 움직이지 않고, Observable.Defer()와 병용할 필요가 있거나 합니다. 이 함수들은 상당히 까다롭기 때문에, 왜 Observable.Defer() 필요한지 모르는 사람은, 이 테크닉은 사용하지 않는 편이 안전할지도 모릅니다.
private async UniTaskVoid SampleAsync(string uri, CancellationToken token)
{
var result =
// Observable.Defer로 감싸서 Retry 발화시 GetAsync를 다시 실행하도록 합니다.
await Observable.Defer(() => GetAsync(uri, token).ToObservable())
// 에러 발생시 1초 기다린 후 총 3회까지 시도
.OnErrorRetry((UnityWebRequestException ex) => Debug.LogException(ex), retryCount: 3, TimeSpan.FromSeconds(1));
Debug.Log(result);
}
private async UniTask<string> GetAsync(string uri, CancellationToken token)
{
using (var uwr = UnityWebRequest.Get(uri))
{
await uwr.SendWebRequest().WithCancellation(token);
return uwr.downloadHandler.text;
}
}
UniRx에 존재하는 UpdateAsObservable()와 Observable.EveryUpdate() 애용하고 있는 분은 많을 것입니다. 여기에 대해서는 UniRx를 그대로 사용해도 되지만, 경우에 따라서는 async/await쪽이 간단하게 구현 가능한 경우도 있다고 기억해 두면 좋을 것입니다.
조건이 충족되면 처리를 실행한 후 몇 초 동안 쿨타임에 들어갑니다. (공격을 내면 그 후 몇 초간은 다시 공격을 할 수 없다, 같다)
이것을 UniRx로 작성하면 다음과 같습니다.
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
namespace Samples
{
public sealed class SimpleLogicUniRx : MonoBehaviour
{
private void Start()
{
// 매 프레임 실행
this.UpdateAsObservable()
// 공격 버튼을 누르면
.Where(_ => Input.GetButtonDown("Attack"))
// 처리를 1회 실행시킨 후, 1초간 쿨타임
.ThrottleFirst(TimeSpan.FromSeconds(1))
.Subscribe(_ => Action())
.AddTo(this);
}
/// <summary>
/// 정기적으로 실행되는 처리
/// </summary>
private void Action()
{
// 공격
Debug.Log("Action!");
}
}
}
이러한 「오퍼레이터를 조합하는 것만으로 구현할 수 있는 처리」에 대해서는 UniRx를 사용해 써도 문제 없습니다.
UniRx의 장점은 「풍부한 오퍼레이터를 사용할 수 있다」입니다만, 반대로 단점으로서 오퍼레이터의 범위 외의 처리는 굉장히 구현하기 어려워 집니다.
조금 전의 예의 「조건을 만족하면 뭔가 처리를 실행해 그 후 몇 초간 쿨타임에 들어간다」를 조금 확장해, 다음과 같은 처리를 생각해 봅시다.
조건 A를 만족하면 처리 X를 호출하고 N 초 동안 쿨타임에 들어갑니다.
조건 B를 만족하면 처리 Y를 호출하고 M 초 동안 쿨타임에 들어갑니다.
처리 X와 Y는 각각 배타적이며, 서로의 쿨타임 중에는 서로의 처리가 차단된다.
알기 쉽게 바꿔 말한다면, “강 공격을 내면 쿨타임이 길다. 약공격을 내면 쿨타임이 짧다. 쿨타임 중에는 공격을 일절 할 수 없다”같은 패턴입니다.
자, 이것을 UniRx만으로 작성하려고하면 어떻게 될까요? 오퍼레이터의 단순한 연결만으로는 구현할 수 없고, 상당히 얽힌 코드로 구현 코드가 나올 것 같습니다. 이러한 도중에 조건 분기가 들어가거나 조건에 따라 처리 내용이 크게 바뀌는 것을 UniRx는 매우 구현하기 힘듭니다.
이러한 경우는 UniRx를 사용하지 않고 async/await(또는 코루틴)으로 써 버리는 쪽이 결과적으로 깨끗하게 구현할 수 있습니다.
using System.Collections;
using UnityEngine;
namespace Samples
{
public sealed class ComplexLogicCoroutine : MonoBehaviour
{
private void Start()
{
StartCoroutine(LogicLoop());
}
private IEnumerator LogicLoop()
{
// Destroy 될 때까지 무한 루프
while (true)
{
if (Input.GetButtonDown("AttackA"))
{
// 입력 A가 실행되면 처리 X를 호출하여 1초 대기
ActionX();
yield return new WaitForSeconds(1);
}
else if (Input.GetButtonDown("AttackB"))
{
// 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
ActionY();
yield return new WaitForSeconds(2);
}
else
{
// 입력이 없으면 1프레임 대기
yield return null;
}
}
}
private void ActionX()
{
Debug.Log("do X!");
}
private void ActionY()
{
Debug.Log("do Y!");
}
}
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Samples
{
public sealed class ComplexLogicUniTask : MonoBehaviour
{
private void Start()
{
// CancellationToken생성
var token = this.GetCancellationTokenOnDestroy();
// 루프 시작
LogicLoopAsync(token).Forget();
}
private async UniTaskVoid LogicLoopAsync(CancellationToken ct)
{
// Destroy 될 때까지 무한 루프
while (!ct.IsCancellationRequested)
{
if (Input.GetButtonDown("AttackA"))
{
// 입력 A가 실행되면 처리 X를 호출하여 1초 대기
ActionX();
await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
}
else if (Input.GetButtonDown("AttackB"))
{
// 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
ActionY();
await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: ct);
}
else
{
// 입력이 없으면 1프레임 대기
await UniTask.Yield();
}
}
}
private void ActionX()
{
Debug.Log("do X!");
}
private void ActionY()
{
Debug.Log("do Y!");
}
}
}
역주
GetCancellationTokenOnDestroy은 MonoBehaviour의 라이프 사이클과 동일하게 CancellationToken이 생성된다.
using UnityEngine;
namespace Samples
{
public sealed class SamplePlayer : MonoBehaviour
{
// 여기에 많은 필드 변수가 정의
private void Update()
{
// 공격 처리에 따른 처리
CheckAttack();
// Player의 위치에 따른 처리
CheckPosition();
// Player의 체력에 따른 처리
CheckHealth();
// 움직임 처리
Move();
}
// ↓ 메서드가 줄지어 정의되어있다가 가정한다.
}
}
그런데, 이 Update()문을 보고 느끼는 것은 없을까요. “이 처리의 순서는 바꿔도 문제 없을까?”, “어떤 처리와 어떤 처리는 연관된 처리가 있는가?” 라는 의문이 나올 수 있습니다.
이러한 처리 순서에 따라 제대로 동작하지 않는 메서드가 있을 수 있고, 처리 순서가 무방한 메서드가 있을 수 있습니다. 이러한 부분이 주석에 써 있을지도 모르고, 전혀 주석이 없는 코드일지도 모릅니다. Update()라는 하나의 메소드에 나란히 쓰고 있는 이상은, 항상 이러한 것을 의식해 코드를 작성/읽어야 합니다.
이러한 문제는 UniRx를 사용하면 다소 개선할 수 있습니다.
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
namespace Samples
{
public sealed class SamplePlayer : MonoBehaviour
{
private void Start()
{
// Player의 위치에 따른 처
this.UpdateAsObservable()
.Subscribe(_ => CheckPosition())
.AddTo(this);
// Player의 체력에 따른 처리
this.UpdateAsObservable()
.Subscribe(_ => CheckHealth())
.AddTo(this);
// 공격 처리 확인 및 이동 처리
this.UpdateAsObservable()
.Subscribe(_ =>
{
CheckAttack();
Move();
})
.AddTo(this);
}
// ↓ 메서드가 줄지어 있다고 가정한다.
/*
* 생략
*/
}
}
이와 같이, 각 처리마다 따로 따로 Observable 저장하는 것으로 처리 끼리의 스코프를 명확하게 구분 할 수 있었습니다. 또 처리마다 Observable가 분리되어 있기 때문에, 일부 처리만 오퍼레이터를 추가하거나 조정도 쉬워집니다.
using UnityEngine;
namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의 Prefab
[SerializeField] private Player _playerPrefab;
// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;
private void Start()
{
CreatePlayer();
}
public void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;
// 새로운 플레이어 생성
CreatePlayer();
}
private void CreatePlayer()
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);
// Player에게 Manager를 가르치기
_currentPlayer.Initialize(this);
}
}
}
using UnityEngine;
namespace Samples2
{
public class Player : MonoBehaviour
{
private PlayerManager _playerManager;
public void Initialize(PlayerManager playerManager)
{
// 초기화 시 Manager 유지
_playerManager = playerManager;
}
private void OnDestroy()
{
// 이번에는 OnDestroy되면 '사망'이라는 취급으로
_playerManager.OnPlayerDead();
}
}
}
이 코드에는 하나의 큰 문제가 있습니다. 그것은 Player와 PlayerManager가 상호 참조하는 것입니다.
상호 참조는 나중에 스파게티 코드로 발전 할 위험이 매우 높습니다. 따라서 가능한 한 상호 참조를 제거해야합니다.
using UniRx;
using UnityEngine;
namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의Prefab
[SerializeField] private Player _playerPrefab;
// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;
private void Start()
{
CreatePlayer();
}
private void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;
// 새로운 플레이어 생성
CreatePlayer();
}
private void CreatePlayer()
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);
// 생성된 플레이어를 모니터링하고
// 사망 이벤트가 오면 OnPlayerDead 실행
_currentPlayer
.PlayerDeadAsync
.Subscribe(_ => OnPlayerDead())
.AddTo(this);
}
}
}
using System;
using UniRx;
using UnityEngine;
namespace Samples2
{
public class Player : MonoBehaviour
{
/// <summary>
/// 플레이어가 사망한 통지를 발행하는 Observable
/// </summary>
public IObservable<Unit> PlayerDeadAsync => _playerDeadSubject;
// 1회만 통지를 발행하는 경우에는 AsyncSubject가 편리
private readonly AsyncSubject<Unit> _playerDeadSubject = new AsyncSubject<Unit>();
private void OnDestroy()
{
// 플레이어가 사망한 통지 발행
_playerDeadSubject.OnNext(Unit.Default);
_playerDeadSubject.OnCompleted();
_playerDeadSubject.Dispose();
}
}
}
UniRx Observable를 사용하여 알림 흐름을 정리하고 참조 관계를 일방통행으로 만들 수 있습니다. 원래 Observable는 “불특정 다수에게 자신의 상태를 감시하게 한다”라는 용도를 위한 기능입니다. 그러므로 이러한 “뭔가 상태가 변화했을 때 그것을 상대에게 전달한다”는 경우에서는 Observable 강력하게 작용합니다.
Observable의 “불특정 다수에게 자신의 상태를 감시시킨다”라고 하는 용도를 좀 더 생각해 보겠습니다.
예를 들어 액션 게임에서 “뭔가 이벤트가 일어났을 때 그에 따라 복수의 처리를 동시에 실행시킨다”라는 패턴은 자주 존재합니다. 이러한 처리를 구현하는 경우, 이벤트 발행측이 통지처를 모두 파악하는 코드는 매우 파악하기 어렵게 됩니다.
예로서 다음과 같은 구현이 있다고 합니다.
플레이어가 데미지를 받으면 다음 처리가 실행됩니다.
체력 감소
넉백 효과
데미지 애니메이션 재생
파티클 이펙트가 나온다
효과음 재생
디버깅 할 때만 UI에 숫자를 표시하고 싶습니다.
이것을 “데미지를 받았을 때에 하나하나 통지처의 메소드를 호출해 간다”라고 하는 구현으로 하면 이렇게 됩니다.
이 구현의 약점은 “PlayerCore 모두를 관리하지 않으면 안된다”라고 하는 점에 있습니다. 통지처를 모든 PlayerCore 것이 알고 있는 상태로 해, 게다가 상황에 따라서 통지한다/안한다 판단을 PlayerCore하지 않으면 안됩니다. 이에 대한 PlayerCore 책임이 커지고, 점점 복잡한 코드로 성장해 버립니다.
이러한 문제는 Observable를 사용하여 해결할 수 있습니다.
PlayerCore에 Observable 정의하고 각 컴포넌트가 필요에 따라 Subscribe 하는 형식으로 해 줍니다. 이렇게 하면 PlayerCore 책임이 크게 줄어들고 “이벤트 알림을 처리하는 방법”의 책임이 각 구성 요소에 분산됩니다.
UniTask 및 async/await를 사용하여 종속성을 구성할 수 있습니다. 단, UniRx와 달리 UniTask는 “이벤트 통지 횟수가 1회에 한정한다” 경우에만 이용 가능합니다.
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Samples2
{
public class Player : MonoBehaviour
{
/// <summary>
/// 플레이어가 사망하면 완료되는 UniTask
/// </summary>
public UniTask PlayerDeadAsync => _playerDeadUtc.Task;
private readonly UniTaskCompletionSource _playerDeadUtc = new UniTaskCompletionSource();
private void OnDestroy()
{
// 플레이어가 사망하면 UniTask 완료
_playerDeadUtc.TrySetResult();
}
}
}
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의Prefab
[SerializeField] private Player _playerPrefab;
// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;
private void Start()
{
SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
}
private void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;
// 새로운 플레이어 생성
SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
}
private async UniTaskVoid SetupPlayerAsync(CancellationToken token)
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);
// 생성된 플레이어를 모니터링하고
// 사망하면 OnPlayerDead 실행
await _currentPlayer.PlayerDeadAsync;
token.ThrowIfCancellationRequested();
OnPlayerDead();
}
}
}
UniRx를 사용하면 종속성을 정리하고 이벤트 흐름을 개선할 수 있습니다. 또 통지 회수가 1회에 한한 경우에 있어서는 “UniRx”나 “UniTask+async/await”의 어느 쪽에서도 기술하는 것도 가능합니다.
덧붙여 개인적으로는, 통지 회수가 1회만이라면 UniTask+async/await, 몇번이나 통지한다면 Observable로 구분해서 사용 하고 있습니다. Observable라고 하는 형태만을 보았을 때에, “이것은 Observable 몇번 이벤트를 발행하는 것일까”를 모릅니다. 그렇다면 “UniTask 그렇다면 절대 많아도 1회 밖에 발행되지 않는다”, “Observable 그렇다면 복수회 발행될 것이다” 라고 나누는 것이 편하기 때문입니다. (상대측의 구현 내용을 상상해 쓰는, 같은 불필요한 일 하지 않아도 된다)
Unity에는 Script Execution Order 라는 스크립트 실행 순서를 조정하는 메커니즘이 있습니다. Script Execution Order은 마지막 보루로 남겨 두어야 할 정도로 안이하게 만져서는 안되는 기능입니다.
그렇다면 실행 순서를 관리하고 싶을 때 어떻게해야할까요? UniRx를 사용하여 이벤트에서 작동하도록 해 버리는 것입니다. 즉, “전의 컴퍼넌트가 끝나면 다음의 컴퍼넌트가 연쇄해 실행된다”라고 하는 구조로 합니다. 이렇게 하면 컴포넌트 간의 호출 순서 Update()나 FixedUpdate() 생각하지 않아도 됩니다.
“각 컴퍼넌트가 Update()를 실행해, 조건을 만족하면 각 처리를 실행한다” 라고 하는 어리석은 구현도 가능합니다. 하지만 이 방법의 경우, 실행 순서가 어긋나서 처리가 1프레임 늦어지거나, 상태 체크를 흘리는 등의 문제가 일어날 수 있습니다.
그 때문에 그러한 것을 Update() 사용하는 코드를 멈추고 UniRx를 이용해 처리가 연쇄하는 구현 으로 해 보겠습니다.
using UniRx;
using UnityEngine;
namespace Samples3
{
/// <summary>
/// 입력 이벤트 관리
/// </summary>
public sealed class PlayerInput : MonoBehaviour
{
/// <summary>
/// 점프 버튼의 입력 상태를 나타내는 ReactiveProperty
/// </summary>
public IReadOnlyReactiveProperty<bool> JumpButton => _jumpButton;
private readonly ReactiveProperty<bool> _jumpButton = new ReactiveProperty<bool>(false);
/*
* 그 밖에도 여러 가지 이벤트가 줄지어 있지만 생략
*/
private void Start()
{
// Destroy 시에 Dispose 되도록(듯이) 한다
_jumpButton.AddTo(this);
}
private void Update()
{
// 버튼 상태를 반영하여 알림
_jumpButton.Value = Input.GetButton("Jump");
}
}
}
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
namespace Samples3
{
public sealed class PlayerMoveController : MonoBehaviour
{
/// <summary>
/// 접지 되었습니까?
/// </summary>
public IReadOnlyReactiveProperty<bool> IsGrounded => _isGrounded;
private readonly ReactiveProperty<bool> _isGrounded = new ReactiveProperty<bool>(false);
/// <summary>
/// 점프 이벤트
/// </summary>
public IObservable<Unit> OnJump => _jumpSubject;
private readonly Subject<Unit> _jumpSubject = new Subject<Unit>();
[SerializeField] private LayerMask _groundLayerMask;
[SerializeField] private PlayerInput _playerInput;
private Rigidbody _rigidbody;
private void Start()
{
_isGrounded.AddTo(this);
_jumpSubject.AddTo(this);
_rigidbody = GetComponent<Rigidbody>();
// 접지 상태 확인
this.FixedUpdateAsObservable()
.Subscribe(_ =>
{
// 레이를 발밑으로 날리다
_isGrounded.Value = Physics.SphereCast(origin: transform.position + Vector3.up * 0.04f,
radius: 0.02f,
direction: Vector3.down, hitInfo: out var _, maxDistance: 0.05f, _groundLayerMask);
})
.AddTo(this);
// 입력 이벤트 처리
_playerInput.JumpButton
// 점프 버튼을 누른 순간에 접지하면 실행
.Where(x => x && _isGrounded.Value)
// Input 이벤트는 Update() 타이밍이므로,
// 다음 FixedUpdate 타이밍으로 타이밍 조정
.ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)
.Subscribe(_ =>
{
// 점프실행
Jump();
})
.AddTo(this);
}
private void Jump()
{
_rigidbody.AddForce(Vector3.up * 10.0f, ForceMode.VelocityChange);
// 점프 이벤트 발행
_jumpSubject.OnNext(Unit.Default);
}
}
}
using UniRx;
using UnityEngine;
namespace Samples3
{
public sealed class PlayerAnimation : MonoBehaviour
{
[SerializeField] private Animator _animator;
[SerializeField] private PlayerMoveController _moveController;
/// <summary>
/// 점프 애니메이션 재생
/// </summary>
private bool IsJumping
{
set => _animator.SetBool("Jumping", value);
}
private void Start()
{
// 점프 이벤트가 오면 점프 애니메이션 시작
_moveController.OnJump.Subscribe(_ => IsJumping = true).AddTo(this);
// 접지하면 점프 애니메이션 해제
_moveController.IsGrounded.Where(x => x).Subscribe(_ => IsJumping = false).AddTo(this);
}
}
}
UniRx를 사용하면 3개의 컴포넌트가 충돌 없이 순서대로 동작시킬 수 있게 되었습니다. 이와 같이 각 컴퍼넌트로부터 Update()와 FixedUpdate()를 최대한 배제하는 것으로, 동작 순서를 완전하게 제어할 수 있게 됩니다. (정확히 반응)
역주
ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)을 사용하여 FixedUpdate에서 점프 처리를 실행하도록 처리하였다.
실행 순서의 조정 Update()와 FixedUpdate() 어떠한 것에 한정되지 않고, 초기화(Start()나 Awake())의 순서도 중요하게 되는 일도 있습니다. 이러한 상황에서도 UniRx를 사용할 수 있습니다. 그렇지만, “초기화”라고 하는 기본적으로 1회 밖에 실행되지 않는 처리에 대해서는 “async/await+UniTask”로 쓰는 편이 깨끗해집니다.
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Samples3
{
public sealed class EnemyManager : MonoBehaviour
{
[SerializeField] private Enemy _enemyPrefab;
/// <summary>
/// 적을 복수체 생성하여 초기화
/// </summary>
public async UniTask InitializeAsync(CancellationToken token)
{
// 10개 생성하고 모든 초기화가 끝날 때까지 기다린다
// (UniTask.WhenAll을 암시 적으로 호출 중)
await Enumerable.Range(0, 10).Select(x => CreateEnemyAsync(token));
}
/// <summary>
/// 적을 생성한다
/// </summary>
private async UniTask CreateEnemyAsync(CancellationToken token)
{
var enemy = Instantiate(_enemyPrefab);
// 초기화가 끝날 때까지 기다리다
await enemy.InitializedAsync;
// 초기화 중에 취소되면 중지
token.ThrowIfCancellationRequested();
}
}
}
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
namespace Samples3
{
/// <summary>
/// 적
/// </summary>
public sealed class Enemy : MonoBehaviour
{
/// <summary>
/// 초기화가 완료되었는지 여부
/// </summary>
public UniTask InitializedAsync => _initialTaskCompletionSource.Task;
private readonly UniTaskCompletionSource _initialTaskCompletionSource = new UniTaskCompletionSource();
private AsyncOperationHandle<GameObject> _operationHandle;
/// <summary>
/// 이 적 객체의 자식 요소 (비동기로 초기화됨)
/// </summary>
private GameObject _child;
/// <summary>
/// Start에서 초기화 처리가 실행됨
/// </summary>
private void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
InitializeAsync(ct).Forget();
}
/// <summary>
/// 초기화 처리
/// </summary>
private async UniTaskVoid InitializeAsync(CancellationToken token)
{
_operationHandle = Addressables.InstantiateAsync("Child");
await _operationHandle;
_child = _operationHandle.Result;
}
private void OnDestroy()
{
Addressables.Release(_operationHandle);
// Destroy가 먼저 실행되 취소합니다.
_initialTaskCompletionSource.TrySetCanceled();
}
}
}
예를 들어, 방금 전의 CountDownTimer을 이용하여 다음과 같은 기능을 구현하려고 합니다.
구현 목록
Start() 시점에서 60초를 카운트 한다.
타이머의 숫자를 UnityEngine.UI.Text에 그린다.
카운트가 10초 이하가 되면 위의 Unity.UI.Text의 글자의 색상을 붉은 색으로 변하게 한다.
카운트가 10초 이하가 되면 카운트마다 효과음을 울린다.
계산이 끝나면 Unity.UI.Text의 문자를 지운다.
계산이 끝나면 효과음을 울린다.
RxCountDownTimer.cs
/// <summary>
/// 카운트 구성 요소
/// </summary>
public class RxCountDownTimer : MonoBehaviour
{
/// <summary>
/// 카운트 다운 스트림
/// 이 Observable을 각 클래스가 Subscribe 한다.
/// </summary>
public IObservable<int> CountDownObservable => _countDownObservable.AsObservable();
private IConnectableObservable<int> _countDownObservable;
// 60초 카운트 스트림을 생성
// Publish로 Hot 변환
private void Awake() =>
_countDownObservable = CreateCountDownObservable(60).Publish();
// start시 카운트 시작
private void Start() =>
_countDownObservable.Connect();
/// <summary>
/// countTime 만큼 카운트 다운하는 스트림
/// </summary>
/// <param name="countTime"></param>
/// <returns></returns>
private IObservable<int> CreateCountDownObservable(int countTime) =>
Observable
.Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))
.Select(x => (int) (countTime - x))
.TakeWhile(x => x > 0);
}
CountDownTextComponent.cs
/// <summary>
/// 타이머의 시간을 기초로 Text를 업데이트 할 컴포넌트
/// </summary>
public class CountDownTextComponent : MonoBehaviour
{
/// <summary>
/// UnityEditor에서 할당 한다.
/// </summary>
[SerializeField] private RxCountDownTimer _rxCountDownTimer;
/// <summary>
/// uGUI의 Text
/// </summary>
private Text _text;
private void Start()
{
_text = GetComponent<Text>();
// 타이머의 남은 시간을 표시한다.
_rxCountDownTimer
.CountDownObservable
.Subscribe(time =>
{
// onNext에서 시간을 표시한다.
_text.text = $"남은 시간 : {time}";
}, () =>
{
// onComplete에서 문자를 지운다.
_text.text = string.Empty;
}).AddTo(gameObject);
// 타이머가 10초 이하로 되는 타이밍에 색을 붉은 색으로 한다.
_rxCountDownTimer
.CountDownObservable
.First(timer => timer <= 10)
.Subscribe(_ => _text.color = Color.red);
}
}