GameDesign 板


LINE

图文版 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)







like.gif 您可能会有兴趣的文章
icon.png[问题/行为] 猫晚上进房间会不会有憋尿问题
icon.pngRe: [闲聊] 选了错误的女孩成为魔法少女 XDDDDDDDDDD
icon.png[正妹] 瑞典 一张
icon.png[心得] EMS高领长版毛衣.墨小楼MC1002
icon.png[分享] 丹龙隔热纸GE55+33+22
icon.png[问题] 清洗洗衣机
icon.png[寻物] 窗台下的空间
icon.png[闲聊] 双极の女神1 木魔爵
icon.png[售车] 新竹 1997 march 1297cc 白色 四门
icon.png[讨论] 能从照片感受到摄影者心情吗
icon.png[狂贺] 贺贺贺贺 贺!岛村卯月!总选举NO.1
icon.png[难过] 羡慕白皮肤的女生
icon.png阅读文章
icon.png[黑特]
icon.png[问题] SBK S1安装於安全帽位置
icon.png[分享] 旧woo100绝版开箱!!
icon.pngRe: [无言] 关於小包卫生纸
icon.png[开箱] E5-2683V3 RX480Strix 快睿C1 简单测试
icon.png[心得] 苍の海贼龙 地狱 执行者16PT
icon.png[售车] 1999年Virage iO 1.8EXi
icon.png[心得] 挑战33 LV10 狮子座pt solo
icon.png[闲聊] 手把手教你不被桶之新手主购教学
icon.png[分享] Civic Type R 量产版官方照无预警流出
icon.png[售车] Golf 4 2.0 银色 自排
icon.png[出售] Graco提篮汽座(有底座)2000元诚可议
icon.png[问题] 请问补牙材质掉了还能再补吗?(台中半年内
icon.png[问题] 44th 单曲 生写竟然都给重复的啊啊!
icon.png[心得] 华南红卡/icash 核卡
icon.png[问题] 拔牙矫正这样正常吗
icon.png[赠送] 老莫高业 初业 102年版
icon.png[情报] 三大行动支付 本季掀战火
icon.png[宝宝] 博客来Amos水蜡笔5/1特价五折
icon.pngRe: [心得] 新鲜人一些面试分享
icon.png[心得] 苍の海贼龙 地狱 麒麟25PT
icon.pngRe: [闲聊] (君の名は。雷慎入) 君名二创漫画翻译
icon.pngRe: [闲聊] OGN中场影片:失踪人口局 (英文字幕)
icon.png[问题] 台湾大哥大4G讯号差
icon.png[出售] [全国]全新千寻侘草LED灯, 水草

请输入看板名称,例如:Soft_Job站内搜寻

TOP