由于咱们的地图类是能够本身控制大小的, 在无心中用了一个比较大的数字 500*500
后, 结果花了挺长时间来生成地图, 而在这段时间里, 屏幕黑乎乎的什么也不显示, 若是咱们的游戏最终发布时也是这样, 那就太不专业了, 因此如今须要在地图生成过程当中在屏幕上显示一些提示信息, 告诉用户尚未死机...网络
这个问题看似简单, 可是在 Codea
的程序架构下却没办法简单地实现, 须要用到 Lua
的另外一项比较有趣的特性 协程-coroutine
.多线程
咱们知道, Codea
的运行机制是这样的:架构
setup()
只在程序启动时执行一次draw()
在程序执行完 setup()
后反复循环执行, 每秒执行 60
次touched()
跟 draw()
相似, 也是反复循环执行简单说, 就是相似于这样的一个程序结构:框架
setup() while true do ... draw() touched(touch) ... end
而咱们生成地图的函数只须要执行一次, 也就是说它们会被放在 setup()
中执行, 而在 Codea
中, setup()
没有执行完是不会去执行 draw()
的, 也就是说咱们没办法在 setup()
阶段绘图, 若是咱们的 setup()
执行的时间比较长的话, 咱们就只能面对黑乎乎的屏幕傻等了.dom
怎么办呢? 幸运的是, Lua
还有 协程-coroutine
这个强大的特性, 利用它咱们能够更灵活地控制程序的执行流程.函数
先稍微了解下协程.工具
Lua
的协程
全名为协同式多线程
(collaborative multithreading
). Lua
为每一个 coroutine
提供一个独立的运行线路。然而和多线程不一样的地方就是,coroutine
只有在显式调用 yield
函数后才被挂起,再调用 resume
函数后恢复运行, 同一时间内只有一个协程正在运行.动画
Lua
将它的协程函数都放进了 coroutine
这个表里,其中主要的函数以下:spa
使用 coroutine.create(f)
能够为指定函数 f
新建一个协程 co
, 代码以下:.net
-- 先定义一个函数 f function f () print(os.time()) end -- 为这个函数新建一个协程 co = coroutine.create(f)
一般协程的例子都是直接在 coroutine.create()
中使用一个匿名函数做为参数, 咱们这里为了更容易理解, 专门定义了一个函数 f
.
为何要经过协程来调用函数呢? 由于若是咱们直接调用函数, 那么从函数开始运行的那一刻起, 咱们就只能被动地等待函数里的语句彻底执行完后返回, 不然是没办法让函数在运行中暂停/恢复
, 而若是是经过协程来调用的函数, 那么咱们不只可让函数暂停在它内部的任意一条语句处, 还可让函数随时从这个位置恢复运行.
也就是说, 经过为一个函数新建协程, 咱们对函数的控制粒度从函数级别精细到了语句级别.
咱们能够用 coroutine.status(co)
来查看当前协程 co
的状态
> coroutine.status(co) suspended >
看来新建的协程默认是被设置为 挂起-suspended
状态的, 须要手动恢复.
执行 coroutine.resume(co)
, 代码以下:
> coroutine.resume(co) 1465905122 true >
咱们再查看一下协程的状态:
> coroutine.status(co) dead >
显示已经死掉了, 也就是说函数 f
已经执行完了.
有人就问了, 这个例子一会儿就执行完了, 协程只是在最初被挂起了一次, 咱们如何去手动控制它的挂起/恢复
呢? 其实这个例子有些太简单, 没有很好地模拟出适合协程发挥做用的使用场景来, 设想一下, 咱们有一个函数执行起来要花不少时间, 若是不使用协程的话, 咱们就只能傻傻地等待它执行完.
用了协程, 咱们就能够在这个函数执行一段时间后, 执行一次 coroutine.yield()
让它暂停, 那么如今问题来了, 运行控制权如何转移? 这个函数执行了一半了, 控制权还在这个函数那里, 办法很简单, 就是把 coroutine.yield()
语句放在这个函数里边(固然, 咱们也能够把它放在函数外面, 不过那是另一个使用场景).
咱们先把函数 f
改写成一个须要执行很长时间的函数, 而后把 coroutine.yield()
放在循环体中, 也就是让 f
每执行一次循环就自动挂起:
function f () local k = 0 for i=1,10000000 do k = k + i print(i) coroutine.yield() end end
看看执行结果:
> co = coroutine.create(f) > coroutine.status(co) suspended > coroutine.resume(co) 2 true > coroutine.status(co) suspended > coroutine.resume(co) 3 true > coroutine.status(co) suspended > coroutine.resume(co) 4 true >
很好, 完美地实现了咱们的意图, 可是实际使用中咱们确定不会让程序这么频繁地 暂停/恢复
, 通常会设置一个运行时间判断, 好比说执行 1
秒钟后暂停一次协程, 下面是改写后的代码:
time = os.time() timeTick = 1 function f () local k = 0 for i=1,10000000 do k = k + i print(i) -- 若是运行时间超过 1 秒, 则暂停 if (os.time() - time >= timeTick) then time = os.time() coroutine.yield() end end end co = coroutine.create(f) coroutine.status(co) coroutine.resume(co)
代码写好了, 可是运行起来表现有些不太对劲, 刚运行起来还正常, 但以后开始手动输入 coroutine.resume(co)
恢复时感受仍是跟以前的同样, 每一个循环暂停一下, 认真分析才发现是由于咱们手动输入的时间确定要大于 1
秒, 因此每次都会暂停.
看来咱们还须要修改一下代码, 那就再增长一个函数来负责自动按下恢复键, 而后把段代码放到一个无限循环中, 代码以下:
time = os.time() timeTick = 1 function f () local k = 0 for i=1,10000000 do k = k + i -- print(i) -- 若是运行时间超过 timeTick 秒, 则暂停 if (os.time() - time >= timeTick) then local str = string.format("Calc is %f%%", 100*i/10000000) print(str) time = os.time() coroutine.yield() end end end co = coroutine.create(f) function autoResume() while true do coroutine.status(co) coroutine.resume(co) end end autoResume()
鉴于 os.time()
函数最小单位只能是 1
秒, 虽然使用 1
秒做为时间片有助于咱们清楚地看到暂停/恢复
的过程, 可是若是咱们想设置更小单位的时间片它就无能为力了, 因此后续改成使用 os.clock()
来计时, 它能够精确到毫秒级, 固然也能够设置为 1
秒, 把咱们的时间片设置为 0.1
, 代码以下:
time = os.clock() timeTick = 0.1 print("timeTick is: ".. timeTick) function f () local k = 0 for i=1,10000000 do k = k + i -- print(i) -- 若是运行时间超过 timeTick 秒, 则暂停 if (os.clock() - time >= timeTick) then local str = string.format("Calc is %f%%", 100*i/10000000) print(str) time = os.clock() coroutine.yield() end end end co = coroutine.create(f) function autoResume() while true do coroutine.status(co) coroutine.resume(co) end end autoResume()
执行记录以下:
Lua 5.3.2 Copyright (C) 1994-2015 Lua.org, PUC-Rio timeTick is: 0.1 Calc is 0.556250% Calc is 1.113390% Calc is 1.671610% Calc is 2.229500% Calc is 2.787610% Calc is 3.344670% Calc is 3.902120% Calc is 4.459460% Calc is 5.017040% ...
好了, 关于协程, 咱们已经基本了解了, 有了以上基础, 咱们就接下来就要想办法把它放到 Codea
里去了.
为方便使用, 以上面代码为基础将其改写为一个线程类, 具体代码以下:
Threads = class() function Threads:init() self.threads = {} self.time = os.clock() self.timeTick = 0.1 self.worker = 1 self.task = function() end end -- 切换点, 可放在准备暂停的函数内部, 通常选择放在多重循环的最里层, 这里耗时最多 function Threads:switchPoint() -- 切换线程,时间片耗尽,而工做尚未完成,挂起本线程,自动保存现场。 if (os.clock() - self.time) >= self.timeTick then self.time = os.clock() -- 挂起当前协程 coroutine.yield() end end -- 计算某个整数区间内全部整数之和,要在本函数中设置好挂起条件 function Threads:taskUnit() -- 可在此处执行用户的任务函数 self.task() -- 切换点, 放在 self.task() 函数内部耗时较长的位置处, 以方便暂停 self:switchPoint() end -- 建立协程,分配任务,该函数执行一次便可。 function Threads:job () local f = function () self:taskUnit() end -- 为 taskUnit() 函数建立协程。 local co = coroutine.create(f) table.insert(self.threads, co) end -- 在 draw 中运行的分发器,借用 draw 的循环运行机制,调度全部线程的运行。 function Threads:dispatch() local n = #self.threads -- 线程表空了, 表示没有线程须要工做了。 if n == 0 then return end for i = 1, n do -- 记录哪一个线程在工做。 self.worker = i -- 恢复"coroutine"工做。 local status = coroutine.resume(self.threads[i]) -- 线程是否完成了他的工做?"coroutine"完成任务时,status是"false"。 ---[[ 若完成则将该线程从调度表中删除, 同时返回。 if not status then table.remove(self.threads, i) return end --]] end end -- 主程序框架 function setup() print("Threads...") myT = Threads() myT.task = needLongTime myT:job() end function needLongTime() local sum = 0 for i=1,10000000 do sum = sum + i -- 在此插入切换点, 提供暂停控制 myT:switchPoint() end end function draw() background(0) myT:dispatch() sysInfo() end -- 显示FPS和内存使用状况 function sysInfo() pushMatrix() pushStyle() fill(255, 255, 255, 255) -- 根据 DeltaTime 计算 fps, 根据 collectgarbage("count") 计算内存占用 local fps = math.floor(1/DeltaTime) local mem = math.floor(collectgarbage("count")) text("FPS: "..fps.." Mem:"..mem.." KB",650,740) popStyle() popMatrix() end
使用方法也简单, 先在 setup()
中初始化, 再肯定要建立协程的函数, 而后建立协程:
... myT = Threads() myT.task = needLongTime myT:job() ...
接着就是在 draw()
中运行分发器:
... myT:dispatch() ...
最后就是把切换点判断控制函数 myT:switchPoint()
插入到 myT.task
函数中的循环最里层:
... for i=1,10000000 do sum = sum + i -- 在此插入切换点, 提供暂停控制 myT:switchPoint() end ...
剩下的工做就是把这个线程类用到地图生成类中, 保证在生成地图的同时还能够在屏幕上显示一些提示信息.
通过分析, 地图生成类主要是 createMapTable()
函数花时间, 须要把它从 init()
函数中拿出来, 在主程序框架的 setup()
内用 task
来加载调用, 记得要把它封装成一个匿名函数的形式, 同时须要在 createMapTable()
的多重循环最内层放一个 switchPoint()
函数, 再写一个加载过程提示信息显示函数 drawLoading()
, 具体以下:
function setup() ... -- 初始化地图 myMap = Maps() -- 使用线程类 myT = Threads() myT.task = function () myMap:createMapTable() end myT:job() ... end function draw() ... myT:dispatch() ... drawLoading() ... end -- 加载过程提示信息显示 function drawLoading() pushStyle() fontSize(60) fill(255,255,0) textMode(CENTER) text("程序加载中...",WIDTH/2,HEIGHT/2) popStyle() end -- 新建地图数据表, 插入地图上每一个格子里的物体数据 function Maps:createMapTable() --local mapTable = {} for i=1,self.gridCount,1 do for j=1,self.gridCount,1 do self.mapItem = {pos=vec2(i,j), plant=self:randomPlant(), mineral=self:randomMinerial()} --self.mapItem = {pos=vec2(i,j), plant=nil, mineral=nil} table.insert(self.mapTable, self.mapItem) -- 插入切换判断点 myT:switchPoint() end end print("OK, 地图初始化完成! ") self:updateMap() end
好消息是咱们的线程类起做用了, 能够在程序加载过程当中显示提示信息, 坏消息是好像显示得有些乱.
原来咱们以前的程序框架只考虑了一个场景: 游戏运行时, 没考虑运行以前的加载, 加载以前的游戏启动画面, 以及其余不一样场景, 换句话说就是只有一个视图, 因此把全部的绘图代码都一股脑放在 draw()
里了, 如今咱们在游戏运行场景外多了一个加载场景, 都放在一块儿显然是不行了, 这就须要对主程序框架作一些修改, 让它支持多个视图(场景)互不影响.
接下来开始作这部分功能, 实际上要想用更清晰的代码逻辑来使用协程, 也须要咱们把游戏场景的各类状态转换逻辑写到主程序框架中.
在 setup()
中设置一个状态机表, 专门用于存放各类状态(场景), 同时设置好初始状态, 以下:
states = {startup = 0, loading = 1, playing = 2, about = 3} state = states.loading
其中各状态含义以下:
startup
游戏启动场景, 显示片头启动画面;loading
游戏加载场景, 处理游戏初始化/地图生成/资源加载等工做, 也就是 setup
干的事;playing
游戏运行场景, 玩家控制角色进行游戏操做的场景, 也就是咱们以前默认使用的那个;about
显示游戏相关信息的场景.在 draw()
中使用多条选择语句来切换, 增长相关状态的处理函数 drawLoading()
, drawPlaying()
等, 在 drawLoading()
内部的末尾设置当前状态为 states.playing
, 另外要把咱们原来在 draw()
中的代码所有移到函数 drawPlaying()
中, 以下:
function draw() background(32, 29, 29, 255) -- 根据当前状态选择对应的场景 if state == states.loading then drawLoading() elseif state == states.playing then drawPlaying() end end -- 绘制加载 function drawLoading() pushMatrix() pushStyle() fontSize(60) fill(255,255,0) textMode(CENTER) text("游戏加载中...",WIDTH/2,HEIGHT/2) popStyle() popMatrix() -- 切换到下一个场景 state = states.playing end -- 绘制游戏运行 function drawPlaying() pushMatrix() pushStyle() -- spriteMode(CORNER) rectMode(CORNER) -- 增长移动的背景图: + 为右移,- 为左移 --sprite("Documents:bgGrass",(WIDTH/2+10*s*m.i)%(WIDTH),HEIGHT/2) --sprite("Documents:bgGrass",(WIDTH+10*s*m.i)%(WIDTH),HEIGHT/2) -- sprite("Documents:bgGrass",WIDTH/2,HEIGHT/2) if ls.x ~= 0 then step = 10 *m.i*ls.x/math.abs(ls.x) else step = 0 end --sprite("Documents:bgGrass",(WIDTH/2 - step)%(WIDTH),HEIGHT/2) --sprite("Documents:bgGrass",(WIDTH - step)%(WIDTH),HEIGHT/2) -- 绘制地图 myMap:drawMap() -- 绘制角色帧动画 m:draw(50,80) -- 绘制状态栏 myStatus:drawUI() -- 绘制游戏杆 ls:draw() rs:draw() -- 增长调试信息: 角色所处的网格坐标 fill(249, 7, 7, 255) text(ss, 500,100) sysInfo() popStyle() popMatrix() end
试着运行一下, 发现仍是有些不太对, 仔细想一想, 原来问题出在 drawLoading()
中的这一句:
-- 切换到下一个场景 state = states.playing
由于咱们在 draw()
里使用了协程分发函数 dispatch()
, 它的存在直接致使了运行流程的变化, 没有使用协程时, drawLoading()
函数只会执行一次, 用了 dispatch()
会在加载过程当中(此时加载还未完成)反复屡次执行 drawLoading()
.
实际上在咱们这个程序中, 在 draw()
里调用了 dispatch()
后, 程序的控制权就会反复在 setup()
中的 createMapTable()
和 draw()
之间切换, 基本上是这样一个流程:
第一步:
首次执行时, 先顺序执行一次 setup()
, 执行到其中的 job()
函数里时调用 coroutine.create(function () createMapTable() end)
为函数 createMapTable()
建立一个新协程 co
, 而后把它挂起, 函数 job()
把程序控制权交还给系统的正常流程;第二步:
此时程序顺序执行 job()
语句后面的语句, 也就是从 setup()
顺序执行到 draw()
, 接着顺序执行到 draw()
里 dispatch()
语句;第三步:
接着由 dispatch()
中的 coroutine.resume(co)
把 co
恢复, 也就是程序控制权再次跳转回 setup()
中的 job()
里的 createMapTable()
中的 switchPoint()
语句处, 若是 createMapTable()
尚未执行完, 则从新申请一个时间片, 而后从 createMapTable()
上次暂停的位置恢复执行;第四步:
由插入到 createMapTable()
中的 switchPoint()
判断时间片是否耗尽, 等时间片用完了, 就执行 switchPoint()
中的 coroutine.yield()
把 co
暂停, 也就是函数 job()
再次把控制权交还给系统, 接着按照 第二步
来继续;第五步:
或者在时间片耗尽前 createMapTable()
函数所有执行完了, 此时程序也会由 job()
函数把控制权交还给系统, 也按照 第二步
来继续;第六步:
顺序执行到 draw()
中的 dispatch()
里的 coroutine.resume(co)
, 不过由于此时任务函数 createMapTable()
已经所有完成, 因此这时再执行恢复函数 coroutine.resume(co)
会返回一个状态值 false
, 至关于执行恢复失败, 由于如今协程已经结束, 此时直接返回, 也就是退出 dispatch()
, 顺序执行 dispatch()
后面的语句;第七步:
把函数 draw()
内的语句所有执行一遍后, 由于 draw()
是反复执行的, 因此它会再次从 draw()
内开头处开始执行, 接着再按照 第六步
继续, 由于此时协程已经结束, 因此控制权就不会再次返回到 setup()
了, 剩下就是反复执行 draw()
了.结合上面的流程, 咱们有两种设置场景状态的方案:
一种是直接在最耗时的函数 createMapTable()
尾部增长一条场景状态设置语句:
-- 新建地图数据表, 插入地图上每一个格子里的物体数据 function Maps:createMapTable() --local mapTable = {} for i=1,self.gridCount,1 do for j=1,self.gridCount,1 do self.mapItem = {pos=vec2(i,j), plant=self:randomPlant(), mineral=self:randomMinerial()} --self.mapItem = {pos=vec2(i,j), plant=nil, mineral=nil} table.insert(self.mapTable, self.mapItem) -- 插入切换判断点 myT:switchPoint() end end print("OK, 地图初始化完成! ") -- 执行到此说明该函数已经彻底执行完, 则切换到下一个场景 state = states.playing self:updateMap() end
另外一种方案则须要结合协程中任务的状态 status
来判断什么时候修改场景状态, 这就须要对咱们的线程类作一点修改, 首先在线程类增长一个属性任务状态 self.taskStatus
, 开始时为 "Running"
, 在任务完成后设置为 "Finished"
, 最后再在 drawLoading()
函数中增长一条判断语句, 修改后的代码以下:
function Threads:init() ... self.taskStatus = "Running" end -- 建立协程,分配任务,该函数执行一次便可。 function Threads:job () self.taskStatus = "Running" local f = function () self:taskUnit() end -- 为 taskUnit() 函数建立协程。 local co = coroutine.create(f) table.insert(self.threads, co) end -- 计算某个整数区间内全部整数之和,要在本函数中设置好挂起条件 function Threads:taskUnit() -- 可在此处执行用户的任务函数 self.task() -- 切换点, 放在 self.task() 函数内部耗时较长的位置处, 以方便暂停 self:switchPoint() -- 运行到此说明任务所有完成, 设置状态 self.taskStatus = "Finished" end -- 加载过程提示信息显示 function drawLoading() ... -- 若是任务函数执行完毕, 则修改场景状态 if myT.taskStatus == "Finished" then -- 切换到下一个场景 state = states.playing end end
第一种方案比较简单, 不过不提倡, 由于这种场景切换控制点最好能集中到主程序框架中, 也就是说在 draw()
里控制, 不然程序读起来比较痛苦;
第二种方法稍微麻烦些, 不过优势一是通用, 二是控制点清晰, 因此咱们推荐的是第二种.
最终修改完的代码在这里Github项目代码
执行以后发现很好地实现了咱们的意图, 太有成就感了! 本身点个赞! :)
本章咱们利用协程实现了一个比较简单的功能, 可是讲解起来却占了不小的篇幅, 这是由于协程虽然只有几个函数, 可是在使用中却要来回嵌套, 并且主要是程序控制权切换来切换去, 跟咱们一般的代码执行顺序相比, 确实有些复杂, 因此就多花了些篇幅.
认真读读, 再把例程跑跑, 本身作些小修改, 应该仍是比较容易理解的, 话说对于协程我也是边学边写, 甚至如今还没搞清楚带参数的 coroutine.yield()
和 coroutine.resume()
的具体用法, 不过这并不妨碍咱们使用那些咱们理解了的部分.
为方便理解, 下面把咱们的线程类中各函数的调用关系画出来:
关于 coroutine
只要记住这几点:
coroutine.create(f)
建立的, 只须要执行一次, 放在 setup()
中;coroutine.yield()
和 coroutine.resume()
实现的;coroutine.yield()
一般放在用于建立协程的函数 f
中;coroutine.resume()
一般在外部, 须要循环执行, 放在 draw()
中.事实上协程颇有用, 后续咱们还可让协程发挥更大的做用, 好比咱们若是增长网络功能的话, 那么协程就是必不可少的工具了.
从零开始写一个武侠练功游戏-1-状态原型
从零开始写一个武侠练功游戏-2-帧动画
从零开始写一个武侠练功游戏-3-地图生成
从零开始写一个武侠冒险游戏-4-第一次整合
从零开始写一个武侠冒险游戏-5-使用协程
本章参考了下面两篇文档的部份内容和代码, 对文档做者表示感谢.