由於咱們的地圖類是能夠本身控制大小的, 在無心中用了一個比較大的數字 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-使用協程
本章參考了下面兩篇文檔的部份內容和代碼, 對文檔做者表示感謝.