支援中斷事件的Coroutine

簡介

今天在處理上一代社長Prefab的遺產。

這是一個用Unity作的簡單的GalGame引擎,不過目前只有播放五句話的功能。
分別是「AAAAAAA」、「BBBBBBB」、「CCCCCCCCC」、「DDDDDD」
以及「EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE」

為什麼最後一句特別長呢?因為我想要測試一個功能,那就是在播放台詞時如果玩家按了按鍵,
就會中斷文字的動畫,直接顯示整句話。所以要有一句特別長來讓我不耐煩。

顯示文字的動畫是一個coroutine。Prefab的做法是在StartCoroutine時把coroutine存在對話框的class裡面,
然後玩家如果在顯示到一半時按空白鍵,就直接呼叫StopCoroutine,然後直接顯示整句話。

這個方法確實可以做到,不過仔細想想,玩家輸入按鍵時中斷動畫這個功能在遊戲中實在太常用了。
因此我們會希望這個這個函式不是對話框獨有的,而是一個在函式庫裡的函式。
它可以把一個coroutine變成能夠靠按鍵中斷。並且在中斷時,會執行某個特定的函式來直接跳躍過程,直達結果。

跳躍過程,直達結果。

如果我們有緋紅之王的話,就可以很輕鬆地做到這件事情。可惜我們沒有,那就只好寫code了。

用StopCoroutine實作

我們來寫個class叫叫InputEventListener。顧名思義,就是一個會監聽玩家輸入的class。
它提供一個函式叫Run,玩家只要將coroutine跟中斷事件函式丟給它,它就會給你一個包裝好的,能中斷的coroutine。

Run的實作方式基本上就是把對話框裡的code照搬過來。
首先就是呼叫StartCoroutine並把Coroutine存起來…

…欸等一下,StartCoroutine是MonoBehaviour的函式,這邊沒辦法直接執行。

嗯,好吧。這邊必須新增一個MonoBehaviour的輸入參數。看來函數的樣子沒辦法像當初想得那樣簡潔。

1
public IEnumerator Run(MonoBehaviour mono, IEnumerator e, Action onInput);

接下來我們就在MonoBehaviour身上呼叫StartCoroutine,然後每個frame都檢查玩家是否有按下空白鍵。
如果玩家按下空白鍵,那麼我們就呼叫StopCoroutine,然後呼叫OnInput,也就是我們的中斷回呼函式。
如果沒有,就一直執行到coroutine結束為止。

這也很簡單,我們只要用一個while區段,然後一直看coroutine.IsFinished是否為true就好…

…欸等一下。

…Coroutine好像沒有這個變數。

……
冷靜,一定是我孤陋寡聞,記錯變數的名稱了。
畢竟Coroutine應該是包裝IEnumerator的class,理論上應該要有能夠知道是否結束的功能。
所以這時候當然就是要拿出工程師的兩大救星,google跟
StackOverflow(平抬)。

(google中)

結論。

還真他媽沒有。

…沒辦法了,只能自己開一個變數來偵測coroutine是否結束了。
這邊我們得再寫一個coroutine,在開始時將IsFinished設成false,然後結束時再設成true。
這樣就能用while迴圈偵測IsFinished然後進行處理了。

最後的實現如下:

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

public class InputEventListener
bool _isFinished = false;
Coroutine _coroutine = null;

public InputEventListener(){}

private IEnumerator _Run(IEnumerator e){
_isFinished = false;
yield return e;
_isFinished = true;
}

public IEnumerator Run(MonoBehaviour mono, IEnumerator e, Action onInput){
_coroutine = mono.StartCoroutine(_Run(e));
while(!_isFinished){
if(Input.GetKeyDown(KeyCode.Space)){
mono.StopCoroutine(_coroutine);
onInput.Invoke();
}
else{
yield return null;
}
}
}
}

總覺得複雜度有點超出一開始的想像了。

首先,需要的變數比想像中還要多。本來以為拿一個coroutine跟一個函式就好,
現在不但還要吃MonoBehaviour,還要自己處理IsFinished。

而且這個方法還需要一個class,變成我們在呼叫Run之前還得作出一個class才行。
這邊不得不用class是因為IsFinished我們要讓Run裡面能取得IsFinished,但我們會希望每個可中斷coroutine有自己的IsFinished。

可能有人想說用把_Run新增一個輸出參數就好了:

1
2
3
4
5
private IEnumerator _Run(IEnumerator e, out bool isFinished){
isFinished = false;
yield return e;
isFinished = true;
}

但問題是coroutine是不能用out的。所以如果真的想要用輸入參數來取得IsFinished的話,我們還得用自己把bool包起來:

1
2
3
4
5
6
7
8
9
10
11
public class ResultHolder<T>{
public T Result { get; set;}
}

...

private IEnumerator _Run(IEnumerator e, ResultHolder<bool> isFinished){
isFinished.Result = false;
yield return e;
isFinished.Result = true;
}

怎麼感覺越寫越亂了,嘔嘔嘔嘔嘔。

這麼簡單的功能真的需要這麼複雜的方法實現嗎?

自己手動跑Coroutine來實現

我們回顧一下剛才碰到的麻煩。

  1. 輸入牽扯到MonoBehaviour,增加依賴。日後要擴增功能會有隱憂。
  2. 使用前還得實體化一個class,增加管理麻煩和記憶體負擔。

追根究柢,就是Unity內建的Coroutine太雞巴了。不但StartCoroutine強制綁在MonoBehaviour身上,
而且Coroutine看起來明明像一個把IEnumerator包好的class,卻連「是否已結束」這種基本的功能都沒實作。
氣氣氣氣氣。

為了尋求究極的解決方法,我只好去研究Unity是怎麼把IEnumerator拿來當Coroutine用的了。
然後經過一番研究,終於總結出了它的用法:

  1. IEnumerator有MoveNext這個函式和Current這個變數。
  2. Unity會不斷呼叫MoveNext來執行coroutine的程式碼,直到碰到yield敘述。
    MoveNext如果回傳為true就代表coroutine還沒結束,反之就代表已經結束。
  3. Unity會根據Current來決定要休息多久才繼續coroutine。

欸?…

MoveNext如果回傳為true就代表coroutine還沒結束,反之就代表已經結束。

這不就是我們要的嗎?

唉,果然上層沒有提供的功能還是得自己從底層實作啊,沒想到都到Unity9102了我還要說這句話。
不過總之,一個究極的可中斷coroutine總算誕生了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static IEnumerator RunAsInputEventListener(this IEnumerator coroutine, Action onConfirm)
{
while (coroutine.MoveNext())
{
if (Input.GetKeyDown(KeyCode.Space))
{
onConfirm.Invoke();
break;
}
else
{
yield return coroutine.Current;
}
}
yield return null;
}

我們透過自行控制coroutine,來避免呼叫StartCoroutine的種種麻煩。
有了MoveNext後,我們就能確認coroutine是否結束了,不用自己實作IsFinished。除此之外邏輯都差不多。

最重要的,我們要完全模仿Unity使用coroutine的方式,才能讓我們要包裝的coroutine正常執行。
而模仿的最高境界就是叫Unity自己處理,所以yield return的地方我們直接回傳Current,讓Unity根據回傳值來判斷要休息多久。
例如最常用的yield return null,Unity會休息到下一個frame再繼續coroutine。
如果亂傳一通的話,有可能會發生永遠到達不了coroutine結束的事實。

最後一個細節就是結尾那個yield return null,這樣寫是為了不要按一次按鍵觸發兩個事件。
因為這個函式結束後可能還會有其他輸入偵測,但這時KeyDown還是true,
所以這邊只能多等一個frame,不然直接跑下去就會繼續觸發下一個對話。

以下是這個函式的一個使用範例:

1
2
3
4
5
6
7
8
IEnumerator PlayDialog(string name, string content)
{
IEnumerator speaking = dialog.Speaking(content, name);
yield return speaking.RunAsInputEventListener(() => {
dialog.printAll(content, name);
});
yield return InputEvent.WaitForInputEvent();
}

dialog.Speaking是顯示對話的動畫coroutine,它會一個字一個字的顯示對話。
呼叫RunAsInputEventListener後,會得到一個可中斷的coroutine,並且在中斷時直接printAll。

至於InputEvent.WaitForInputEvent則是單純的等待,在玩家輸入前不會做任何事。

玩家如果乖乖地等文字跑完,我們就等玩家按下空白鍵才播放下一個對話。
如果玩家在文字跑到一半就性急的按下空白鍵,則我們就直接將文字跑完,然後再等一次空白鍵才播放下一個對話。

這個實現方法既不用牽扯到MonoBehaviour,也不用實體化一個class,簡潔了很多。
而且用法起來也方便,它就只是把coroutine包裝成另一個coroutine,底層的coroutine長甚麼樣子都無所謂。
而且牽扯到的class不多,所以以後不太需要回來改。只有Input看起來是最危險的,但這個可以等日後再擴增。

於是,我們總算讓所有動畫都能使用中斷事件,Prefab的在天之靈也得已安息了。

參考資料

StackOverflow: Yield and coroutines, how do they actually work?
Unity協程(Coroutine)原理深入剖析再續