精華區beta GameDesign 關於我們 聯絡資訊
終於要進入正題了。之所以花了許多篇幅介紹 Lua 以及 coroutine,關鍵就在 於 Lua 是遊戲程式中常見的腳本語言(scripting language),而 coroutine 對遊戲程式來說則是相當便利的語言功能。本文將會介紹如何利用 coroutine 改寫遊戲程式中常見的狀態機(state machine)結構,讓程式邏輯清楚而容易 維護。 在這篇文章中,我會使用 Corona SDK 來示範。Corona SDK 是手機平台上極受 歡迎的一套商用遊戲開發套件,它使用了 Lua 作為腳本語言,並提供模擬器讓 開發者在 PC 上即可預覽遊戲畫面。更重要的是,只要在 Corona 的網站上註冊 帳號即可無限期免費試用,非常適合獨立開發者或小型工作室使用。如果想進一 步了解 Corona,可以參考他們的教學文件。 [Corona SDK]: http://www.coronalabs.com/products/corona-sdk/ 遊戲引擎的 Frame Listener 現代的遊戲引擎通常具有相當複雜的功能,在更新每個畫面時,它需要動態地更 新場景結構、把多邊形及材質貼圖資料送進顯示晶片、把音樂送進音效晶片、取 得玩家輸入資料等等。一般遊戲引擎都把這些複雜的部份包裝起來,藉由讓開發 者註冊 frame listener 的方式以創造出不同的遊戲形式。所謂的 frame listener 就是遊戲引擎在每次畫面更新時,都會呼叫的使用者函式。Corona 也 使用了這樣的架構,開發者可以用 Runtime:addEventListener() 來註冊 frame listener。 -- 在畫面上方正中間畫一個圓形 local circle = display.newCircle(display.contentWidth/2, 0, 50) -- 要更新每個畫面時: function update(event) -- 根據時間算出圓形的新位置 -- Corona 的坐標原點在左上角,y 值向下增加 -- 所以圓形會往下移動,超出邊界則回到最上方 circle.y = math.fmod(event.time, display.contentHeight) end -- 註冊 update 為 frame listener Runtime:addEventListener("enterFrame", update) 實際跑起來的樣子如下圖所示: http://goo.gl/hgoPo Frame Listener 與狀態機 Frame listener 的機制雖然能讓開發者創造出動態的遊戲畫面,但卻很難做出 複雜的行為。因為 frame listener 是每次畫面更新時固定呼叫的函式,若想 在裡面描述跨越多格畫面的行為就顯得礙手礙腳。 舉例來說,許多遊戲在一開始時,會出現標題畫面以及「PRESS TO START」的文 字,按下後會進入遊戲模式。最簡單的寫法是這樣: local storyboard = require("storyboard") local text = display.newText("PRESS TO START", 0, 0, nil, 40) -- 把文字放到畫面正中央 text.x = display.contentWidth/2 text.y = display.contentHeight/2 function onPress() -- 進入遊戲模式 storyboard.gotoScene("GamePlay") end -- 按下時呼叫 onPress() text:addEventListener("touch", onPress) 不過,這樣的遊戲介面屬於粗製濫造的等級。幾乎所有的遊戲在秀出標題畫面時 都會有個進場動畫特效,接著「PRESS TO START」的文字會開始閃爍吸引玩家點 擊,點下後並不會馬上進入遊戲模式,而是播放淡出的動畫特效(或許再加上音 效)之後才會進入遊戲模式。 Corona 提供了一些好用的函式來播放動畫特效,但動畫有其播放時間,我們沒 辦法在 frame listener 中做到「暫停直到動畫結束」之類的事。因為 frame listener 函式結束後,遊戲引擎才能畫出下一格畫面。若我們使用 sleep() 之 類的方式暫停,整個遊戲引擎也會隨之停擺。 -- 錯誤的方法 function update(event) -- 開始播放 0.5 秒的進場動畫 transition.from(text, {time=500, xScale=2, yScale=2, alpha=0}) -- 等待 0.5 秒的動畫時間 -- 但這樣會導致遊戲整個卡死 repeat until system.getTimer() > event.time+500 end 最常見的解法之道,是使用狀態機去記錄目前操作介面的狀態。這個操作介面的 流程畫成狀態機後是長這樣: 動畫結束 點擊 動畫結束 進場 ---→ 待機 ---→ 離場 ---→ 遊戲模式 轉換為程式碼後會長這樣: local STATE = { ENTER = 0, -- 進場動畫 WAIT = 1, -- 等待點擊 LEAVE = 2 -- 離場動畫 } -- state 記錄目前操作介面的狀態 local state -- 記錄動畫開始的時間 local animation_start function update(event) if state == STATE.ENTER then -- event.time 的單位是毫秒 (millisecond) if event.time > animation_start + 500 then state = STATE.WAIT end elseif state == STATE.WAIT then -- 以 0.8 秒為週期閃爍 local cycle, phase = math.modf((event.time-500)/400) if cycle%2 == 0 then -- 不透明轉透明 text.alpha = 1-phase else -- 透明轉不透明 text.alpha = phase end elseif state == STATE.LEAVE then if event.time - animation_start > 400 then -- 動畫已播完,開始遊戲模式 storyboard.gotoScene("GamePlay") end end end function onPress(event) -- 只有在閃爍狀態時點擊才有效 if state == STATE.WAIT then state = STATE.LEAVE animation_start = event.time -- 0.4 秒的離場動畫 -- 放大三倍並淡出 transition.to(text, {time=400, alpha=0, xScale=3, yScale=3}) end end -- 註冊處理函式 text:addEventListener("touch", onPress) Runtime:addEventListener("enterFrame", update) -- 開始進場動畫 state = STATE.ENTER animation_start = system.getTimer() -- 0.5 秒的進場動畫,讓文字從 2 倍大小縮至正常大小, -- 同時從全透明變成不透明(淡入) transition.from(text, {time=500, alpha=0, xScale=2, yScale=2}) 狀態機是常見的解法,但並不漂亮。這樣的寫法至少有兩個問題: 1. 流程並不直覺,因為我們大量使用 if-else 判斷,誰先誰後顯得很難理解。 2. 我們要使用外部變數 state 和 animation_start 來儲存狀態。儘管宣告為 local,但它的作用範圍仍然比 update 大得多。在流程更加複雜的時候, 外部變數的數量也會大幅增加。 另外,上面這段程式碼有個隱藏的小 bug,而且為了修正這個 bug 會需要增加 另一個 state 使得程式碼更為難懂。如果你喜歡玩推理遊戲,可以試著找找看。 使用 Coroutine 進行改寫 在這系列的第一篇文章中提到,coroutine 是「可中斷及繼續執行的函式呼叫」。 若我們把上述的 frame listener 改成對 coroutine 的持續呼叫(resume), 可以讓程式變得非常漂亮。 先來看看如何做到文字的進場特效: local co = coroutine.create( function(timer) -- 開始播放動畫 transition.from(text, {time=500, alpha=0, xScale=2, yScale=2}) -- 當時間小於 500 毫秒時 while timer < 500 do -- 中斷 coroutine 等待下一格畫面 timer = coroutine.yield() end end) Runtime:addEventListener("enterFrame", function(event) if coroutine.status(co) == "suspended" then coroutine.resume(co, event.time) end end) 播放動畫的程式碼是一樣的,但是下面的 while 迴圍很明確地表達出等待 500 毫秒這個意圖。我們來看看要如何加入待機時的閃爍效果: local co = coroutine.create( function(timer) -- 播放動畫並等待 -- 和前面一樣所以先省略 transition.from(...) while timer < 500 do ... end local clicked = false -- 當使用者還沒點選時 while not clicked do timer = coroutine.yield() -- 以 0.8 秒為週期閃爍 local cycle, phase = math.modf((timer-500)/400) if cycle%2 == 0 then -- 不透明轉透明 text.alpha = 1-phase else -- 透明轉不透明 text.alpha = phase end end end) 同樣地,這段程式碼也明確表達出在接收到玩家點擊前,要不斷地閃爍文字。同 時,因為這段程式碼就放在進場動畫的下面,清楚說明了進場動畫結束後要閃爍 文字這件事。 加入使用者點擊的處理並不會太難: local clicked = false local function onPress() clicked = true end text:addEventListener("touch", onPress) -- 閃爍 while not clicked do -- 同上,省略 end text:removeEventListener("touch", onPress) 我們可以在開始閃爍前註冊 event listener,結束後移除它,這麼一來就不需 要在 event listener 中檢查狀態,因為玩家只有在閃爍的狀態才會觸發它。 最後是離場動畫。但這邊有個小狀況,也就是前面提到的 bug:若玩家在文字閃 爍至全透明的狀態時點擊,動畫效果會變成已經透明的文字放大淡出,實際上是 看不見的。儘管這並不影響遊戲過程,但這類小小的動畫瑕疵卻是遊戲精緻與否 的關鍵。因此我們要先讓他淡入至不透明,再進行放大加淡出的特效。 -- 前略 text:removeEventListener("touch", onPress) -- 計算淡入至 alpha=1 所需毫秒數 local delta = (1-text.alpha) * 300 transition.to(text, {time=delta, alpha=1}) local animation_end = timer + delta while timer < animation_end do timer = coroutine.yield() end -- 放大、淡出 transition.to(text, {time=400, alpha=0, xScale=3, yScale=3}) animation_end = timer + 400 while timer < animation_end do timer = coroutine.yield() end -- 結束,進入遊戲模式 storyboard.gotoScene("GamePlay") 注意到我們的程式碼中有許多重覆的 while 橋段,這是等待某段時間的意思, 我們可以放到另一個函式: local function sleep(msec) local wakeup = system.getTimer() + msec repeat until coroutine.yield() > wakeup end 完整的程式碼如下: http://codepad.org/T9iw2QUa 使用 coroutine 的寫法,並不一定會比較短(這段程式還多了上述 bug 的處理 部份),但意圖比較清楚,因為我們可以用控制結構來表達流程: * 程式碼的先後順序就代表流程上的先後順序。 * while 迴圈表示我們要等待到某個條件。 * yield 表示等待一格 frame。 此外,唯一的外部變數只剩下 co,也就是 coroutine 本身。其它的狀態都被封 裝在 co 內部,即使加入新的流程也不會增加外部變數。 * * * * * * 原本我想在這篇文章就結束 coroutine 系列,不過顯然需要的篇幅超出了預期。 在下一篇文章中,我會談到當程式規模開始龐大時,coroutine 如何協助我們寫 出更容易維護、擴充的程式碼。 -- ※ 發信站: 批踢踢實業坊(ptt.cc) ◆ From: 220.135.3.139
KanoLoa:感謝教學m(_ _)m 06/29 13:44
cowbaying:我在考慮要每篇m還是弄到精華區保存... 06/29 17:09
cowbaying:因為每篇寫是很好 階段分離 但是文章會蠻多的 06/29 17:10
Hevak:m跟精華區不衝突呀 06/29 17:24
Hevak:可以板上m,精華區再開一個目錄收 06/29 17:24
Hevak:咱板水量不大不用擔心m過頭.... 06/29 17:25
cowbaying:也是 06/29 17:34