作者littleshan (我要加入剑道社!)
看板GameDesign
标题[程式] 使用 Coroutine 改写状态机
时间Fri Jun 29 10:05:03 2012
图文版
http://goo.gl/vrYF9
终於要进入正题了。之所以花了许多篇幅介绍 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
1F:推 KanoLoa:感谢教学m(_ _)m 06/29 13:44
2F:推 cowbaying:我在考虑要每篇m还是弄到保存... 06/29 17:09
3F:→ cowbaying:因为每篇写是很好 阶段分离 但是文章会蛮多的 06/29 17:10
4F:→ Hevak:m跟不冲突呀 06/29 17:24
5F:→ Hevak:可以板上m,再开一个目录收 06/29 17:24
6F:→ Hevak:咱板水量不大不用担心m过头.... 06/29 17:25
7F:推 cowbaying:也是 06/29 17:34
※ 编辑: littleshan 来自: 202.39.238.241 (07/08 17:56)