2022년 12월 28일 수요일

UniRx

UniRx

UniRx 란

한글UniRx해설

https://moongtaeng.net/moniwiki/UniRx

UniRx는 기존의 Rx라이브러리를 neuecc 가 유니티용으로 만든 Reactive Extensions for Unity 라이브러리이다.

Reactive Extensions for Unity, 프로그램은 리액티브(?) 하게 짤수 있도록 도와주는 라이브러리이다.
기존의 방식은 클릭이벤트, 다운로드 이벤트등을 필요한 부분에서 호출하여 처리하여 다시 그 값을 콜백등에 반영하도록 하여 화면의 갱신등을 하는데 반해 리액티브하게 짠다는 의미는 데이터를 중심으로 어떤 변화가 있을때 관련 프로세서가 자동으로 이루어 지도록 개발하는방식이다.

Rx 라이브러리의 요점은 다음과 같다.
・MicrosoftResearch가 개발하고 있던 C#용 비동기 처리용 라이브러리이다.
・Observer 패턴을 베이스로 설계되어 있다
・이벤트, 시간에 관련된 처리, 실행 타이밍이 중요한 처리를 간단하게 기술할 수 있고 완성도가 높아서 Java, JavaScript Swift 등 다양한 언어로 포팅 되었다.

UniRx는 이 Rx를 베이스로 Unity에 이식된 라이브러리이며, 본가.NET Rx와 비교해 이하와 같은 차이가 있습니다.

・Unity의 C #에 최적화되어 작성되었습니다.
・Unity 의 사이틀에 맞게 개발에 편리한 기능과 오퍼레이터가 추가 구현되어 있다.
・ReactiveProperty 등이 추가되었습니다.
・성능 튜닝이 잘되었있고 원래의 .NET Rx보다 메모리 성능이 우수하다.

기본적으로 C#에서 구현된 event 와 호환되는 기술로서, UniRx는 event를 좀더 사용하기 편하게 개선된라이브러리라고 생각해도된다.

C# event
클래스내에서 이벤트 정의 및 등록 -> 사용측 클래스에서 이벤트의 구체적인 활동을 구현 -> 클래스에서 이벤트 통지(발행) -> 사용측 클래스에서 구현된 기능수행

UniRx
클래스내에서 주제를 등록 -> 사용측 클래스에서 구둑을 등록 -> 클래스에서 주제에 대한 내용을 발행 -> 사용측 클래스에서 발행내용을 수집하여 활용

정의(Subject) 와 구독(Subscribe)

Subject는 이벤트를 발행하는 주체로서, 쉽게 생각하면 뉴스발행처이다.
Subscribe는 이벤트 구족자로서 발행받은 내용을 알아서 사용하고 처리하는 역활이다.

Subject는 IObserver 와 IObservable로 구현(또는 구성) 된다.

  • IObserver 는 -er 이 붙은것처럼, 발행자의 행동을 정의한다. 3가지 행동이 있다.
    • OnCompleted : 발행이 끝날때 발행자가 하는행동
    • OnError : 발행시 에러가 발생했을때
    • OnNext : 발행이 되어서 구독자에게 내용을 발행하는 행동
  • IObservable 은 -able에서 알수있듯이, Subject를 발행이 가능한 상태를 따로 정의한다. Subject가 여러가지 형식으로 발행을 할때, 발행내용(형식)에 따라서 각기 다른 구독자에게 내용을 전달하는 등의 구체적인 작업이 가능하다.

간단한 발행-구독 관계의 예이다.

//UniRx의 Subject 객체를 생성, 발행형식은 string이다.  
var sub = new Subject<string>();  
  
//Subscribe함수를 이용하여 발행처가 발행한 값(string)을 receivvedText에 받아서 필요한 처리를 함.  
sub.Subscribe(receivvedText => Debug.Log(receivvedText));  
  
//발행처(Subject)인 sub 의 OnNext함수로 발행. sub.OnNext("abcd");  
sub.OnNext("efg");  
  
//abcd 와 efg 가 출력된다.


/* 구독자와 발행처가 각각의 클래스에 존재할때는 구독을 하기위해서 Subject자체를 이용하는 것은 좋지않다. 
이때 이용하는것이 IObservable 인터페이스이다. 
발행처의 Subject변수는 private 로 하고, IObservable 인터페이스를 이용하여 공개를 하고 이용하여 발행처의 존재를 몰라도 구독을 신청할수 있도록 해준다.
Class A {
	private  Subject<int>  subject  =  new  Subject<int>();  

	public  IObservable<int>  OnTimeChanged  {  
	    get  {  return  subject;  }  
	}
}

Class B {
	timeCounter.OnTimeChanged.Subscribe
	void b {
		timeCounter.OnTimeChanged.Subscribe(count  =>  {
			Debug.Log(count);
		});
	}	
}
*/

Operator

Rx 에서 발행메시지를 효율적으로 제어하는 여러가지 기능들이 있는데 이를 오퍼레이터라고 한다.
오퍼레이터를 이용하여 구독자는 발행메시지를 필터링 하거나 발행메시지를 추가로 가공하여 구독메시지의 형태를 따로 만들수도 있다.
오퍼레이터수가 하도 많아서 다 기재하기는 힘드니까 몇몇 대표적인 것들만 예를 들겠다.
필터계열

  • Where (x => x == "Player)
    (발행받은 메시지 => {true, false 로 해당메시기가 원하는 메시지 인지 필터링}).
  • Select
    Javascript나 Kotlin 에서 map 으로 불리는 것과 동일한 기능을 한다. 각각의 객체를 반복하며 필요한 처리를 한 결과객체를 돌려준다.
  • Distinct
    같은 메시지가 반복될때, 한개만 발행하도록 한다.
  • Buffer
    버퍼를 지정하고 버퍼에 메시지가 찰때까지 발행하지 않는다. 메시지 개수가 다 차면 발행한다…
//필터링하여 구독 신청
sub.Where(count => count > 3) //3보다 메시지만 모아서 
  .Select(count => count * 10)   //모두 10을 곱해
  .Subscribe(count => Debug.Log(count)); // 출력

//발행하기
sub.OnNext(1)  
sub.OnNext(3)
sub.OnNext(2)
sub.OnNext(4)

//구독처가 받은 메시지를 처리된 결과를 출력
30
40

그래서, Unity 에는 어떻게 활용하는데?

UniRx를 사용하면 Update 에서 조건문으로 이런 저런 처리를 하던거를 좀더 간단하게 할수 있다. 또한 버튼의 까다로운 이벤트처리도 간단하게 할수 있다.

충돌제어

UniRx로 GameObject에 onCollisionEnter를 코드로추가

using UniRx.Triggers;
ball.gameObject.OnCollisionEnterAsObservable().Subscribe(collision => { 충돌처리 });

충돌종류

→ 入ったとき(OnTriggerEnterAsObservable)
→ 入ってるとき(OnTriggerStayAsObservable)
→ 出たとき(OnTriggerExitAsObservable)

입력이벤트 제어

예를 들어보자.

프리펩이 화면에 나온후 2초후 뭔가액션.일때.

private void Start(){
  StartCoroutine(DelayMethod(2f, () =>{
    Debug.Log("2초 되었다.");
  }));
}
    
//코루틴
private IEnumerator DelayMethod(float waitTime, Action action){
  //WaitForSeconds로 기다림
  yield return new WaitForSeconds(waitTime);

  //전달받은 람다 실행
  action();
}

UniRx의 마법을 보자

Observable.Timer(TimeSpan.FromSeconds(2)).Subscribe(_=>Debug.Log("2초 되었다."));
//단, Timer는 UniRx에서 간편하게 제공하는 시간 오퍼레이터이다.(반칙인가?)

UniRx의 스트림 관련 기능이 제일 빛나는 예제는 역시 버튼의 중복 클릭, 동반클릭 처리이다.
UniRx에는 여러가지 오퍼레이터가 있는데 그중에 Buffer, Skip, ThrottleFirst 등 순서대로 발생하는 이벤트를 스트림에 흘려보내 그중에 조건에 맞는 상태만 간단하게 골라서 처리할수 있도록 해준다.
버튼을 두번 클릭해야 되는 처리를 한다고 생각하자. 일단 버튼클릭에 대한 count변수를 넣어서 2번이 되었는지 if문등으로 체크를 해야한다.
UniRx의 마법을 보자

GetComponent<Button>()
  .OnClickAsObservable() // OnClickAsObservable 이것은 UniRx에서 간단하게 제공해주는 클릭옵서저, 물론 직접 만들어도 된다.
  .Buffer(2) //버퍼오퍼레이터는 지정한 숫자만큼이 되야 발행메시지를 얻을수있다.
  .Subscribe(_=>Debug.Log("두번 눌렀네."));

두개의 버튼이 클릭이 되었는지 판단

button2.OnClickAsObservable()  
 .Zip(button.OnClickAsObservable(), (b1, b2) => "Two Clicked")  
 .First() // 현재의 Obsevable 을 Single 로 바꾸어 onSuccess로 바꾸고 종료한다.  
  .Repeat() //다시 클릭을 받기 위해서  onComplete 된 현재의 Obserable을 반복시킨다.  
  .Subscribe(msg => { Debug.Log(msg); });

마우스로 객체 회전제어

void Start ()   
   {  
  
  this.OnMouseDownAsObservable()  
 .SelectMany(_ => this.UpdateAsObservable())  
 .TakeUntil(this.OnMouseUpAsObservable()) // Until the mouse is released  
  .Select(_ =>                                 // get the amount of mouse movement.  
  new Vector2(Input.GetAxis("Mouse X"),Input.GetAxis("Mouse Y")))  
  //.Repeat()                                 // Repeat causes infinite repeat subscribe at GameObject   
                                                       // was destroyed. which leads, if in UnityEditor, Editor goes to freeze.  
  .RepeatUntilDestroy(this) // Since the stream is completed TakeUntil re Subscribe  
  .Subscribe(move =>  
           {  
  this.transform.rotation =  
  Quaternion.AngleAxis(move.y * rotationSpeed * Time.deltaTime, Vector3.right) *  
  Quaternion.AngleAxis(-move.x * rotationSpeed * Time.deltaTime, Vector3.up) *  
  transform.rotation;  
  ;  
  });

변수 값이변경될때를 감지해보자

변수형식 Int, Float, String, Bool, 또는 사용자 클래스 의 속성을 바인드하여 값이 변경될때 적절한 처리를 한다든지 해보자.
예를 들어 공격을 당할때 HP가 줄어들어서 피를 토하게 한다든가, 잠시 유형모드를 킨다든가 할수 있다.

ReactiveProperty 로 Hp 값의 변화를 감지하자.

  • HP, MP 소모를 바로바로 감지하자.
// 정수값 myHp 가 변경될때 마다 로그를 찍는다.
Class A {
	public ReactiveProperty<int> myHp = new ReactiveProperty<int>(1);  
	public A() {
		myHp.Subscribe( x =>  
		    Debug.Log(x)  
		);   
		/*
		myHp
		.Where(hp => (hp>5)) //Where추가하면 5이상일때만 출력된다.
		.Subscribe( x =>  
		    Debug.Log(x)  
		);   
		*/
	}
}

Class B {  
	private readonly A _a = new A();
	void Start()  
	{
		_a.myHp.Value++;
	}	
}

ReactiveProperty 로 Hp 값의 변화를 감지하여 10이상일떄는 true를 감지해보자

  • MP의 숫자에 따라서 공격아이템을 다르게 해보자
  • HP가 줄어들면(공격을당하면) 잠시 무적상태를 ON으로 하자

Select 오퍼레이터를 이용하면 원래의 값을 이용하여 다른 형식의 데이터로 변환된 Observable을 반환해줄수있다.
isHpFull은 hp가 10이상일때만 true이고 나머진 false이다.

ReadOnlyReactiveProperty<bool> isHpFull = myHp    
 .Select(hp => (hp>10))  // 값이 10이상일때는  true를 반환하여 준다.
 .ToReadOnlyReactiveProperty();

isHpFull은 hp가 10이상일때만 true이고 나머진 false이다.

ReadOnlyReactiveProperty<String> isHpFull = myHp    
 .Select(hp => "myHp" + hp) // myHp 1 처럼 문자열을 리턴한다.
 .ToReadOnlyReactiveProperty();
 // 구독을 추가하여 변경시에 출력이 되도록 한다.
 isHpFull.Subscribe(str => Debug.Log(str)); 

ReactiveProperty 로 두값의 변화중 최종합만을 감지해보자

  • 두개의 아이템을 획득해야 특수공격이 가능하도록 해보자.
  • 오른쪽, 왼쪽이던 공격하면 전체의 HP가 줄어들게 해보자.

두합이 100이 될때까지 아이템을 찾는 다는 combineLatest 오퍼레이터를 활용하자. 또는

var a = new ReactiveProperty<int>(1);  
var b = new ReactiveProperty<int>(2);  
var c = Observable.CombineLatest(a, b, (x, y) => x + y).ToReadOnlyReactiveProperty();  
  
Debug.Log(c);  //3
a.Value= 98;  
Debug.Log(c);  //100
b.Value = 0;  
Debug.Log(c); //98

ReactiveProperty 로 캐릭터의 속성값들을 유지한채 캐릭터를 바꾸어보자

Select와 Switch 를 이용하여, Select로 현재선택된 캐릭터의 속성을 읽고, Switch 로 발행처를 바꾸어 캐릭터를 변경해보자.

ReactiveProperty<Charactor> currentCharacter = new ReactiveProperty<Charactor>();  
  
var character1 = new Charactor();  
currentCharacter.Value = character1; //처음에 선택한 마법사 캐릭터  
  
ReadOnlyReactiveProperty<int> currentAttack =  
 currentCharacter .Select(c => (IObservable<int>)c.Attack)  
 .Switch()  
 .ToReadOnlyReactiveProperty();  
  
  character1.Attack.Value = 100;  
Debug.Log(currentAttack.Value); // 100  
  
var character2 = new Charactor();  
currentCharacter.Value = character2;  //자동진행되던 동료캐릭터를 선택
character2.Attack.Value = 200;  
Debug.Log(currentAttack.Value); // 200  
  
character2.Attack.Value = 220;  
Debug.Log(currentAttack.Value); // 220  
  
character1.Attack.Value = 50;  //자동진행되던 마법사 캐릭터가 데미지를 심각하게 입음
Debug.Log(currentAttack.Value); // 50  
  
currentCharacter.Value = character1;  // 마법사 캐릭터를 선택함.
Debug.Log(currentAttack.Value); // 50

NPC 제어

WhenAll 오퍼레이터로 NPC들이 자기 할일을 다 끝내면, 완료 라는 메시지를 출력하는 비동기 처리를 해보자

각각의 NPC들을 Observable로 하여 , 각역활이 끝날때 onComplete를 호출하면 완료! 라는 메시지를 출력하자.

// 특정 비동기 처리 스트림 2  
var stream2 = Observable.Start(() =>  
{  
  Debug.Log("stream2 start");  
});  
  
// 버튼을 클릭했을 때 흐르는 스트림  
var stream3 = button.onClick.AsObservable().Take(1);  
stream3.Subscribe(x =>  
{  
  Debug.Log("stream3 start");  
});  
  
// 버튼2을 클릭했을 때 흐르는 스트림  
var stream4Wrapper = Observable.Create<Unit>(observer =>  
{  
  var stream4 = button2.onClick.AsObservable();  
  var subscription = stream4.Subscribe(x =>  
    {  
  Debug.Log("stream4 start");  
 observer.OnCompleted();  
 });  return subscription;  
});  
  
// 그들이 모두 완료되면 완료! 를 출력한다.  
Observable.WhenAll(stream1, stream2, stream3, stream4Wrapper).Subscribe(x => Debug.Log("all done"));

//*** Merge + FOrEachAsynce로 하면 각각의 로드가 끝난후에 메시지를 띄울수 있다.
  
    .Merge(stream1, stream2, stream3, stream4Wrapper)  
 .ForEachAsync(x => Debug.Log("Loaded : " + x))

카메라 제어

마우스드래그 해서 카메라 이동하기

마우스로 화면을 클릭하여 드래그 하면 이벤트를 감지하여 이전드래그위치와 4발행이후의 위치를 감지하여 카메라를 자연스럽게 이동시킨다.
먼저 카메라에 RidigeBody를 추가하고 Gravity 를 0으로 한다…

  
    public class CameraRx : MonoBehaviour  
  {  
  public Camera MainCamera;  
  
  void Update()  
 {#if UNITY_EDITOR  
            if (Input.GetMouseButtonUp(0))  
 { }  else if (Input.GetMouseButton(0))  
 {  MapSwipe();  
 }#elif UNITY_IOS || UNITY_ANDROID  
        if (Input.touchCount == 0) {  
 } else if (Input.touchCount == 1) { MapSwipe(); }#endif  
  }  
  
  public void MapSwipe()  
 {#if UNITY_EDITOR  
  
            var drug = Observable.EveryUpdate().Select(pos => Input.mousePosition);  
  var stop = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonUp(0));  
#elif UNITY_IOS || UNITY_ANDROID  
        var drug = Observable.EveryUpdate ().Select (pos => Input.GetTouch(0).position);  
 var stop = Observable.EveryUpdate ().Where(_ => Input.touchCount != 1);#endif  
  
  IDisposable onDrug = drug  
 .Zip(drug.Skip(4), (pos1, pos2) => new { x = pos2.x - pos1.x, z = pos2.y - pos1.y })  
 .TakeUntil(stop)  
 .Subscribe(deltaPosition =>  
                    {  
  Debug.Log(deltaPosition.x + ":" + deltaPosition.z);  
  MainCamera.gameObject.GetComponent<Rigidbody>().velocity =  
  new Vector3(deltaPosition.x, 0, deltaPosition.z) * -5;  
 } ); 

 // 버퍼를 사용한 방법 //움직임이 이상하다.
 IDisposable onDrug = drug.Buffer (3)  
 .TakeUntil(stop)  
 .Subscribe (colPos => {  
  
  float delPosx = colPos.Last().x - colPos.First().x;  
  float delPosz = colPos.Last().y - colPos.First().y;  
  float Speed = 0 -(5 * (MainCamera.fieldOfView / 60));  
  MainCamera.gameObject.GetComponent<Rigidbody>().velocity = new Vector3(delPosx, 0, delPosz) * Speed;  
  
 });
} 
}

카메라 Pinch를 하기

      void Update () {

       #if UNITY_EDITOR
        if (Input.GetMouseButtonUp(0)) {
        }else if(Input.GetKey(KeyCode.Space) && Input.GetMouseButton(0)){        
            MapPinch();
        }

       #elif UNITY_IOS || UNITY_ANDROID
        if (Input.touchCount == 0) {
        } else if (Input.touchCount == 1) {
            MapSwipe();
        } else if (Input.touchCount >= 2) {
            MapPinch();
        }
       #endif
    }
 public void MapPinch()  
  {  
#if UNITY_EDITOR  
            var pinch = Observable.EveryUpdate ().Select (pos_dist => Input.mousePosition.x);  
  var stop = Observable.EveryUpdate ().Where(_ => Input.GetMouseButtonUp(0));  
#elif UNITY_IOS || UNITY_ANDROID  
        var pinch = Observable.EveryUpdate ()  
 .Select (pos_dist => Vector2.Distance(Input.GetTouch (0).position, Input.GetTouch (1).position)); var stop = Observable.EveryUpdate ().Where (_ => Input.touchCount != 2);#endif  
  IDisposable onPinch = pinch.Zip(pinch.Skip(1), (dist1, dist2) => new { diff = dist2 - dist1})  
 .TakeUntil(stop)  
 .Subscribe(distanceParam => {  
  MainCamera.fieldOfView -= distanceParam.diff / 30;  
  if(MainCamera.fieldOfView < 4){  
  MainCamera.fieldOfView = 4;  
 }  if(MainCamera.fieldOfView > 70){  
  MainCamera.fieldOfView = 70;  
 } });  }

코루틴

Observable을 잉요해서 코루틴에서 응답을 기다리게 해보자.

public  class Example : MonoBehaviour { [SerializeField]
    private Button _buttonA; [SerializeField]
    private Button _buttonB;
    public  void Start() { StartCoroutine(ExampleCoroutine()); }
    private IEnumerator ExampleCoroutine() { Debug.Log( "A가 눌러질 때까지 기다립니다." ); yield  return _buttonA .OnClickAsObservable() .FirstOrDefault() .ToYieldInstruction();
 Debug.Log( "B가 밀릴 때까지 기다립니다." ); yield  return _buttonB .OnClickAsObservable() .FirstOrDefault() .ToYieldInstruction();
         Debug.Log( "둘 다 눌렀습니다." ); } }

Observable.FromCoroutine

FromCoroutine을 이용하면 코루틴종료시 onNext와 onCompleted호출되는 옵서버블을 만들수 있다.

public  class Example : MonoBehaviour {
    public  void Start() { Observable.FromCoroutine(ExampleCoroutine) .Subscribe(_ => Debug.Log( "OnNext" ), () => Debug.Log( "OnCompleted" )) .AddTo( this ); }
    private IEnumerator ExampleCoroutine() {
        yield  return  new WaitForSeconds( 1.0f ); }
}
//OnNext를 원하는 타이밍에 발행하고 싶은 경우는 아래와 같이 한다.
public  class Example : MonoBehaviour {
    public  void Start() { Observable.FromCoroutine < int > (ExampleCoroutine) .Subscribe(i => Debug.Log( "OnNext : " + i), () => Debug.Log( "OnCompleted" )) .AddTo( this ); }
    private IEnumerator ExampleCoroutine(IObserver< int > observer) {
        for ( int i = 0 ; i < 3 ; i++) { yield  return  new WaitForSeconds( 1.0f ); observer.OnNext(i); } observer.OnCompleted(); }
 }

Observable.FromCoroutineValue

Observable.FromCoroutineValue를 사용하면 코루틴 내에서
yield return x로 반환된 값을 OnNext()로 발행할 수 있다.

코루틴의 끝에 도달하면 OnCompleted()도 발행된다.

public  class Example : MonoBehaviour {
    public  void Start() { Observable.FromCoroutineValue< int >(ExampleCoroutine) .Subscribe(i => Debug.Log( "OnNext : " + i), () => Debug.Log( "OnCompleted" )) .AddTo( this ); }
    private IEnumerator ExampleCoroutine() {
        for ( int i = 0 ; i < 3 ; i++) { yield  return i; } }
 }

리소스로드

여러개의 내부 리소스를 전부 로드할때 까지 기다리자.

using System;  
using UniRx;  
using UnityEngine;  
  
public class ResourceLoader : MonoBehaviour  
{  
  private ResourceRequest _resourceRequestBox;  
  private ResourceRequest _resourceRequestCircle;  
  
  void Start()  
 {  // Observable for AsyncOperation  
  var cubeLoad = Resources.LoadAsync<GameObject>("Prefabs/Cube")  
 .AsAsyncOperationObservable()  
 .Last();  
  
  // Observable for AsyncOperation  
  var circleLoad = Resources.LoadAsync<GameObject>("Prefabs/Circle")  
 .AsAsyncOperationObservable().Delay(TimeSpan.FromSeconds(3))  
 .Last();  
  
  // Observable for AsyncOperation  
  var cylinderLoad = Resources.LoadAsync<GameObject>("Prefabs/Cylinder")  
 .AsAsyncOperationObservable().Delay(TimeSpan.FromSeconds(1))  
 .Last();  
  
  Observable.WhenAll(cubeLoad, circleLoad, cylinderLoad)  
 .Subscribe(_ =>  
                {  
  if (_[0].asset != null)  
 {  GameObject Cube = Instantiate(_[0].asset) as GameObject;  
 }  
  if (_[1].asset != null)  
 {  GameObject Circle = Instantiate(_[1].asset) as GameObject;  
 Circle.transform.position += new Vector3(5, 0, 0);  
 }  if (_[2].asset != null)  
 {  GameObject Cylinder = Instantiate(_[2].asset) as GameObject;  
 Cylinder.transform.position += new Vector3(10, 0, 0);  
 } } ); }}

관련 링크

LINQ 백과사전 고라니 유니티2D https://goraniunity2d.blogspot.com/2021/01/linq.html

외부강

공굴리기 유니티 기본 튜토리얼을 UniRx로 다시 쓰기

https://qiita.com/sensuikan1973/items/740ba95f17dc57f9655c
ReactiveCollection으로 아이템명 획득표시
UpdateAsObservable로 획득아이템 뷰에반영, 사용자키보드입력처리,적들이 쫓아오는 처리
ReactiveCollection랑.ObserveAdd().Subscribe으로 값에 여러개 추가
Subject을 이용하여 플레이어 죽음 통지
OnTriggerEnterAsObservable로 충돌처리체크

ReactiveCommand

https://qiita.com/toRisouP/items/c6fba9f01e6d15dabd79

List 감시

https://kan-kikuchi.hatenablog.com/entry/ReactiveCollection_ReactiveDictionary

lifeCycle

https://psh10004okpro.tistory.com/entry/unity-Script-lifecycle-유니티-라이프-사이클-면접문제?category=892860

0 comments:

댓글 쓰기