从零开始写一个武侠冒险游戏-5-使用协程

---- 用协程实现控制权灵活切换

概述

因为我们的地图类是可以自己控制大小的, 在无意中用了一个比较大的数字 500*500后, 结果花了挺长时间来生成地图, 而在这段时间里, 屏幕黑乎乎的什么也不显示, 如果我们的游戏最终发布时也是这样, 那就太不专业了, 所以现在需要在地图生成过程中在屏幕上显示一些提示信息, 告诉用户还没有死机...

这个问题看似简单, 但是在 Codea 的程序架构下却没办法简单地实现, 需要用到 Lua 的另一项比较有趣的特性 协程-coroutine.

Codea 运行机制

我们知道, Codea 的运行机制是这样的:

  • setup() 只在程序启动时执行一次
  • draw() 在程序执行完 setup() 后反复循环执行, 每秒执行 60
  • touched()draw() 类似, 也是反复循环执行

简单说, 就是类似于这样的一个程序结构:

setup()

while true do
    ...
    draw()
    touched(touch)
    ...
end

而我们生成地图的函数只需要执行一次, 也就是说它们会被放在 setup() 中执行, 而在 Codea 中, setup() 没有执行完是不会去执行 draw() 的, 也就是说我们没办法在 setup() 阶段绘图, 如果我们的 setup() 执行的时间比较长的话, 我们就只能面对黑乎乎的屏幕傻等了.

怎么办呢? 幸运的是, Lua 还有 协程-coroutine 这个强大的特性, 利用它我们可以更灵活地控制程序的执行流程.

先稍微了解下协程.

协程 coroutine 的简单介绍

Lua协程全名为协同式多线程(collaborative multithreading). Lua为每个 coroutine 提供一个独立的运行线路。然而和多线程不同的地方就是,coroutine 只有在显式调用 yield 函数后才被挂起,再调用 resume 函数后恢复运行, 同一时间内只有一个协程正在运行.

Lua 将它的协程函数都放进了 coroutine 这个表里,其中主要的函数如下:

表格图

协程 coroutine 的使用示例

新建协程 coroutine.create()

使用 coroutine.create(f) 可以为指定函数 f 新建一个协程 co, 代码如下:

-- 先定义一个函数 f
function f ()
    print(os.time())
end

-- 为这个函数新建一个协程
co = coroutine.create(f)

通常协程的例子都是直接在 coroutine.create() 中使用一个匿名函数作为参数, 我们这里为了更容易理解, 专门定义了一个函数 f.

  • 为一个函数新建协程的意义就在于我们可以通过协程来调用函数.

为什么要通过协程来调用函数呢? 因为如果我们直接调用函数, 那么从函数开始运行的那一刻起, 我们就只能被动地等待函数里的语句完全执行完后返回, 否则是没办法让函数在运行中暂停/恢复, 而如果是通过协程来调用的函数, 那么我们不仅可以让函数暂停在它内部的任意一条语句处, 还可以让函数随时从这个位置恢复运行.

也就是说, 通过为一个函数新建协程, 我们对函数的控制粒度从函数级别精细到了语句级别.

协程状态 coroutine.status()

我们可以用 coroutine.status(co) 来查看当前协程 co 的状态

> coroutine.status(co)
suspended
>

看来新建的协程默认是被设置为 挂起-suspended 状态的, 需要手动恢复.

恢复协程 coroutine.resume()

执行 coroutine.resume(co), 代码如下:

> coroutine.resume(co)
1465905122
true
> 

我们再查看一下协程的状态:

> coroutine.status(co)
dead
>

显示已经死掉了, 也就是说函数 f 已经执行完了.

挂起协程 coroutine.yield()

有人就问了, 这个例子一下子就执行完了, 协程只是在最初被挂起了一次, 我们如何去手动控制它的挂起/恢复呢? 其实这个例子有些太简单, 没有很好地模拟出适合协程发挥作用的使用场景来, 设想一下, 我们有一个函数执行起来要花很多时间, 如果不使用协程的话, 我们就只能傻傻地等待它执行完.

用了协程, 我们就可以在这个函数执行一段时间后, 执行一次 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
> 

挂起协程 coroutine.yield()

有人就问了, 这个例子一下子就执行完了, 协程只是在最初被挂起了一次, 我们如何去手动控制它的挂起/恢复呢? 其实这个例子有些太简单, 没有很好地模拟出适合协程发挥作用的使用场景来, 设想一下, 我们有一个函数执行起来要花很多时间, 如果不使用协程的话, 我们就只能傻傻地等待它执行完.

用了协程, 我们就可以在这个函数执行一段时间后, 执行一次 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 来加载调用, 记得要把它封装成一个匿名函数的形式, 同时需要在它的多重循环内层放一个 switchPoint() 函数, 再写一个加载过程提示信息显示函数 ``, 具体如下:

function setup()
    ...
    -- 初始化地图
    myMap = Maps()

    -- 使用线程类
    myT = Threads()
    myT.task = function () myMap:createMapTable() end
    myT:job()
    ...
end

function draw()
    ...
    myT:dispatch()
    ...
    drawLoading()
    ...
end    

-- 加载过程提示信息显示
function drawLoadingInfo()
    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-使用协程

参考

本章参考了下面两篇文档的部分内容和代码, 对文档作者表示感谢.

快速掌握Lua 5.3 —— Coroutines
【深入Lua】理解Lua中最强大的特性-coroutine(协程)