티스토리 뷰
0. 코드 전문
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class Command { public float delay = 0; public Action command; public float interval = 0; public Command(float delay, Action action, float interval) { this.delay = Mathf.Max(0, delay); // 음수 방지 command = action; this.interval = Mathf.Max(0, interval); } } public class Commander : MonoBehaviour { private List<Command> commands = new List<Command>(); private Coroutine coroutine = null; private bool autoClear = true; private bool autoKill = true; private int loopCount = 1; #region Add //액션 추가 public Commander Add(Action action) { if (IsPlaying) { Debug.Log("커맨드 실행 중"); return this; } commands.Add(new Command(0, action, 0)); return this; } /// <summary> /// 대기 시간 추가 /// </summary> public Commander Add(float interval) { if (IsPlaying) { Debug.Log("커맨드 실행 중"); return this; } commands.Add(new Command(0, null, interval)); return this; } /// <summary> /// 실행 지연 시간, 액션 추가 /// </summary> public Commander Add(float delay, Action action) { if (IsPlaying) { Debug.Log("커맨드 실행 중"); return this; } commands.Add(new Command(delay, action, 0)); return this; } /// <summary> /// 실행 지연 시간, 액션, 다음 액션 호출 지연 /// </summary> public Commander Add(float delay, Action action, float interval) { if (IsPlaying) { Debug.Log("커맨드 실행 중"); return this; } commands.Add(new Command(delay, action, interval)); return this; } /// <summary> /// 액션, 다음 액션 호출 지연 /// </summary> public Commander Add(Action action, float interval) { if (IsPlaying) { Debug.Log("커맨드 실행 중"); return this; } commands.Add(new Command(0, action, interval)); return this; } #endregion /// <summary> /// 0 보다 작은 수를 넣으면 무한히 반복합니다. /// </summary> public Commander SetLoop(int loopCount = -1) { if (loopCount == 0) loopCount = 1; this.loopCount = loopCount; return this; } public Commander AutoClear(bool autoClear) { this.autoClear = autoClear; return this; } public Commander AutoKill(bool autoKill) { this.autoKill = autoKill; return this; } public Commander SetClear() { commands.Clear(); return this; } public bool IsPlaying => coroutine != null; public int GetCount => commands.Count; /// <summary> /// 저장한 커맨드를 모두 실행합니다. /// </summary> public void Play(bool autoKill = true) { if (commands.Count == 0) { Debug.Log("저장된 커맨드가 없습니다."); return; } if (IsPlaying) { Debug.Log("커맨더가 실행 중입니다."); return; } this.autoKill = autoKill; coroutine = StartCoroutine(Playing_co()); } /// <summary> /// 실행을 중단 합니다. /// </summary> public void Stop() { if (IsPlaying) { StopCoroutine(coroutine); coroutine = null; } } private IEnumerator Playing_co() { int l = loopCount; while (l != 0) { for (int i = 0; i < commands.Count; i++) { yield return new WaitForSeconds(commands[i].delay); commands[i].command?.Invoke(); OnComplete?.Invoke(); yield return new WaitForSeconds(commands[i].interval); } OnCompleteAll?.Invoke(); l--; } if (autoKill) { Destroy(this); } else { coroutine = null; if (autoClear) commands.Clear(); } } public event Action OnComplete; public event Action OnCompleteAll; } | cs |
1. 매개변수 Action
Action을 매개 변수로 사용할 수 있다는 것을 알게 되었을 때 떠올린 것은
"DOTween의 OnComplete() 함수에 사용할 익명메서드를 Action으로 전달해볼까?" 였다.
public void In(float duration = 1, Action onFadeIn = null)
{
StartFade(Color.black, 0, duration, onFadeIn);
}
StartFade는 내부적으로 이미지의 색을 검은색에서 투명한 색으로 바꿔주는 Tween이 구현되어있다.
그래서 선택적으로 마지막 인자값에 Action을 추가할 수 있게 만들었는데,
UIMaster.Fade.In(1.5f, () =>
{
UIMaster.LineMessage.PopUp("게임 시작", 3f, () =>
{
OnGameStartEvent?.Invoke();
});
});
이것을 실제로 사용해보니 가독성이 좋지 않았다.
어떻게하면 가독성을 높일 수 있을지 고민해보니
DOTween처럼 메서드 체인을 사용하면 좋을 것 같았다.
2. 메서드 체인
public class Deck
{
public Deck Shuffle()
{
//셔플 알고리즘을 실행
return this;
}
}
메서드 체인이란, 메서드를 void 형식으로 선언하는게 아니라,
객체 자신을 반환하는 함수로 만들어서
객체의 함수를 계속 사용할 수 있게 만드는 것이다.
함수 내부에서 실행하고 싶은 메서드를 실행한 후
결과적으로 다시 객체를 반환하므로
다시 객체 내부의 메서드를 불러올 수 있게 되는 것이다.
DOTween이 그런 형식인 것이다. (ex. SetEase, SetDelay...)
3. List<Action>
이제 이것들을 바탕으로 Action을 List로 관리하고
for문을 사용하여 리스트의 Action을 순차적으로 실행하는 class를 만들기로 하였다.
제작 의도는, Action을 순서대로 실행하되,
지연시킬 수도 있어야 했다.
n초 후에 Action1을 실행하거나
Action1을 실행하고 m초 후에 Action2를 실행할 수 있도록 말이다.
이를 위해 실행시킬 Action과 실행을 지연시키고 싶은 만큼의 시간을 저장하고 있는 객체를 만들어야 했다.
public class Command
{
public float delay = 0;
public Action command;
public float interval = 0;
public Command(float delay, Action action, float interval)
{
this.delay = Mathf.Max(0, delay); // 음수 방지
command = action;
this.interval = Mathf.Max(0, interval);
}
}
new 생성자로 command 객체의 지연시간과 실행할 Action을 지정하도록 하였다.
public class Commander : MonoBehaviour
{
private List<Command> commands = new List<Command>();
}
이 Command를 리스트로 관리하는 Commander class를 만들어 필요한 메서드를 구현하였다.
private IEnumerator Playing_co()
{
for (int i = 0; i < commands.Count; i++)
{
yield return new WaitForSeconds(commands[i].delay);
commands[i].command?.Invoke();
yield return new WaitForSeconds(commands[i].interval);
}
Destroy(this);
}
이것을 for문을 사용하여 코루틴으로 실행하면 Action들을 원하는 간격으로 순서대로 실행할 수 있다.
4. Add
리스트에 Command를 추가하는 기능이 필요하다.
public Commander Add(float delay, Action action, float interval)
{
commands.Add(new Command(delay, action, interval));
return this;
}
Command를 추가하는데에 필요한 것은 실행 지연 시간, 실행할 Action, 다음 Action 실행 대기 시간 세 가지다.
private Coroutine coroutine = null;
public void Play(bool autoKill = true)
{
if (commands.Count == 0)
{
Debug.Log("저장된 커맨드가 없습니다.");
return;
}
if (IsPlaying)
{
Debug.Log("커맨더가 실행 중입니다.");
return;
}
this.autoKill = autoKill;
coroutine = StartCoroutine(Playing_co());
}
public bool IsPlaying
{
get
{
return coroutine != null;
}
}
public Commander Add(float delay, Action action, float interval)
{
if (IsPlaying)
{
Debug.Log("커맨드 실행 중");
return this;
}
commands.Add(new Command(delay, action, interval));
return this;
}
다만 코루틴을 실행하는 중에 갑자기 리스트가 늘어나면 예상치 못한 문제가 생길 수 있다.
때문에 코루틴이 실행하는 중간엔 Command를 추가할 수 없게 예외처리를 해야한다.
이를 위해 실행 중인 코루틴을 캐싱할 Coroutine 변수를 만들고
코루틴이 실행 중이 아닐 때는 coroutine을 null로 관리하는 것이다.
coroutine이 null이라면 커맨더는 실행 중이 아닌 것이고,
coroutine이 null이 아니라면 커맨더는 실행 중인 것이다.
5. 결과물
사용 방법...
커맨더 자체는 new 생성이 아니라 AddComponent로 추가해야한다.
위가 Commander를 사용한 것이고
아래가 매개변수로 Action을 사용할 뿐인 DOTween 실행 메서드이다.
가독성이 많이 좋아졌다!
추가적으로 Action 리스트를 재사용 할 수 있게
실행을 마치면 Commander를 Destroy 하지 않도록 하는 bool 값을 만들었고,
캐싱을 유지하기 위해 List<Command>만 Clear() 할 수 있는 bool 값 또한 만들었다.
각각의 Command가 실행되고 난 후의 콜백 액션과
모든 Command가 실행되고 난 후의 콜백 액션도 준비하였다.
private IEnumerator Playing_co()
{
int l = loopCount;
while (l != 0)
{
for (int i = 0; i < commands.Count; i++)
{
yield return new WaitForSeconds(commands[i].delay);
commands[i].command?.Invoke();
OnComplete?.Invoke();
yield return new WaitForSeconds(commands[i].interval);
}
OnCompleteAll?.Invoke();
l--;
}
if (autoKill)
{
Destroy(this);
}
else
{
coroutine = null;
if (autoClear)
commands.Clear();
}
}
커맨더의 핵심인 코루틴 전문이다.
코드를 완성하고 나서보니 이것이 커맨드 패턴과 유사한 형태라는 것을 알게 되었다.
(커맨드 패턴 자체도 무엇인지 이번에 알게 되었다...)
특별히 대단한 아니고, 명령을 캡슐화하여 관리한다는 정도?
UniTask를 사용하여 개선한 버전을 추가합니다.
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using System.Collections.Generic;
using UnityEngine;
#region Command
public class Command
{
public Action Execute { get; }
public float Delay { get; }
public Func<bool> WaitUntilCondition { get; }
public Func<bool> WaitWhileCondition { get; }
public Command(Action execute, float delay = 0, Func<bool> waitUntil = null, Func<bool> waitWhile = null)
{
Execute = execute;
Delay = delay;
WaitUntilCondition = waitUntil;
WaitWhileCondition = waitWhile;
}
}
#endregion
public class Commander
{
private readonly List<Command> commands = new List<Command>();
private bool autoClear = true;
private int loopCount = 1;
private CancellationTokenSource cancellationTokenSource;
private Action onComplete;
private Action onCompleteAll;
private Action onUpdate;
private Action onCanceled;
private Func<bool> cancelTrigger = () => false;
private bool isCanceled = false;
public bool IsPlaying => cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested;
public int CommandCount => commands.Count;
public float TaskProgress => CommandCount == 0 ? 0 : Mathf.Clamp((float)currentTaskIndex / CommandCount, 0, 1);
private int currentTaskIndex = 0;
public Commander(bool autoClear = true, int loopCount = 1)
{
this.autoClear = autoClear;
SetLoop(loopCount);
}
#region Event Handlers
public Commander OnComplete(Action callback)
{
onComplete = callback;
return this;
}
public Commander OnCompleteAll(Action callback)
{
onCompleteAll = callback;
return this;
}
public Commander OnUpdate(Action callback)
{
onUpdate = callback;
return this;
}
public Commander OnCanceled(Action callback)
{
onCanceled = callback;
return this;
}
public Commander CancelTrigger(Func<bool> trigger)
{
cancelTrigger = trigger;
return this;
}
public Commander ClearCancelTrigger()
{
cancelTrigger = () => false;
return this;
}
#endregion
#region Command Management
public Commander Add(Action action, float delay = 0, Func<bool> waitUntil = null, Func<bool> waitWhile = null)
{
if (IsPlaying)
{
Debug.Log("Commander is already running.");
return this;
}
commands.Add(new Command(action, delay, waitUntil, waitWhile));
return this;
}
public Commander WaitSeconds(float delay)
{
return Add(null, delay);
}
public Commander WaitUntil(Func<bool> condition)
{
return Add(null, waitUntil: condition);
}
public Commander WaitWhile(Func<bool> condition)
{
return Add(null, waitWhile: condition);
}
public Commander SetLoop(int count = -1)
{
loopCount = count == 0 ? 1 : count;
return this;
}
public Commander SetAutoClear(bool autoClear)
{
this.autoClear = autoClear;
return this;
}
public Commander Clear()
{
commands.Clear();
return this;
}
#endregion
#region Execution Control
public Commander Play()
{
if (commands.Count == 0)
{
Debug.Log("No commands to execute.");
return this;
}
if (IsPlaying)
{
Debug.Log("Commander is already running.");
return this;
}
isCanceled = false;
cancellationTokenSource = new CancellationTokenSource();
ExecuteCommands(cancellationTokenSource.Token).Forget();
if (cancellationTokenSource != null && !cancellationTokenSource.IsCancellationRequested)
MonitorUpdate(cancellationTokenSource.Token).Forget();
return this;
}
public void Cancel()
{
if (IsPlaying)
{
isCanceled = true;
cancellationTokenSource.Cancel();
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
onCanceled?.Invoke();
}
}
public Commander Refresh(bool cleanUp = true)
{
if (IsPlaying)
{
Cancel();
}
if (cleanUp)
{
Cleanup();
}
return this;
}
public Commander Cleanup()
{
if (IsPlaying)
{
Debug.Log("Cannot clean up while running.");
return this;
}
commands.Clear();
onComplete = null;
onCompleteAll = null;
onUpdate = null;
onCanceled = null;
cancelTrigger = () => false;
return this;
}
#endregion
#region Execution Methods
private async UniTask ExecuteCommands(CancellationToken token)
{
try
{
bool infiniteLoop = loopCount < 0;
int remainingLoops = loopCount;
while ((infiniteLoop || remainingLoops > 0) && !token.IsCancellationRequested)
{
currentTaskIndex = 0;
foreach (var command in commands)
{
command.Execute?.Invoke();
if (command.Delay > 0)
await UniTask.Delay(TimeSpan.FromSeconds(command.Delay), cancellationToken: token);
if (command.WaitUntilCondition != null)
await UniTask.WaitUntil(command.WaitUntilCondition, cancellationToken: token);
if (command.WaitWhileCondition != null)
await UniTask.WaitWhile(command.WaitWhileCondition, cancellationToken: token);
if (token.IsCancellationRequested)
return;
currentTaskIndex++;
onComplete?.Invoke();
}
if (!infiniteLoop)
remainingLoops--;
}
if (autoClear)
{
Cleanup();
}
}
finally
{
if (!token.IsCancellationRequested && !isCanceled)
{
isCanceled = true;
cancellationTokenSource.Cancel();
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
onCompleteAll?.Invoke();
}
}
}
private async UniTask MonitorUpdate(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
onUpdate?.Invoke();
if (cancelTrigger())
{
Cancel();
}
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken: token);
}
}
finally
{
if (!token.IsCancellationRequested && !isCanceled)
{
isCanceled = true;
cancellationTokenSource.Cancel();
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
}
}
}
#endregion
}
커맨더는 Action과 대기시간을 캡슐화한 커맨드를 List로 저장하여
입력한 순서대로 간격을 두고 실행합니다.
Action을 실행하고 설정한 시간만큼 다음 작업을 대기하거나
특정 조건이 될 때까지(Until) 대기할 수도 있고
특정 조건 동안(While) 대기할 수 있습니다.
커맨더는 작업을 시작하면 자체적으로 Update()를 가집니다.
커맨더는 특정한 조건일 때 하던 작업을 중단할 수 있습니다.
'유니티 > C# Code' 카테고리의 다른 글
[DOTween] Sequence의 상태에 대하여 (0) | 2024.09.14 |
---|---|
[Object Pooling] Image Pop Up (0) | 2024.09.13 |
Mesh Bake를 배웠습니다. (0) | 2024.08.31 |
RaycastAll과 Array.Sort (0) | 2024.08.31 |
(Player Input) 캐릭터의 이동 구현 코드 (0) | 2024.07.11 |