5-使用協程-從零開始寫一個武俠冒險遊戲
從零開始寫一個武俠冒險遊戲-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-使用協程
參考
本章參考了下面兩篇文件的部分內容和程式碼, 對文件作者表示感謝.
相關文章
- 2-幀動畫-從零開始寫一個武俠冒險遊戲動畫遊戲
- 3-地圖生成-從零開始寫一個武俠冒險遊戲地圖遊戲
- 1-狀態原型-從零開始寫一個武俠冒險遊戲原型遊戲
- 6-用GPU提升效能(3)-從零開始寫一個武俠冒險遊戲GPU遊戲
- 6-用GPU提升效能(1)-從零開始寫一個武俠冒險遊戲GPU遊戲
- 6-用GPU提升效能(2)-從零開始寫一個武俠冒險遊戲GPU遊戲
- 4-第一次整合-從零開始寫一個武俠冒險遊戲遊戲
- 0-開發框架Codea簡介-從零開始寫一個武俠冒險遊戲框架遊戲
- 從零開始:用REACT寫一個格鬥遊戲(一)React遊戲
- 從零開始寫一個ExporterExport
- 從零開始:用REACT寫一個格鬥遊戲(二)React遊戲
- 從零開始仿寫一個抖音App——開始APP
- 從零開始寫一個網頁網頁
- 動作冒險遊戲俠盜獵車手遊戲
- 如何從零開始寫一個網站網站
- 從零開始寫一個node爬蟲(一)爬蟲
- 從零開始編寫一個babel外掛Babel
- 從零開始寫一個Javascript解析器JavaScript
- 冒險遊戲已逝?冒險遊戲萬歲!遊戲
- 從3A遊戲中看國產武俠遊戲
- 從零開始寫JavaScript框架(一)JavaScript框架
- 從零開始構建一個vue專案 --- webpack歷險記VueWeb
- 從零開始寫一個微前端框架-沙箱篇前端框架
- 從零開始手寫一個微前端框架-渲染篇前端框架
- 從零開始開發一個 WebpackWeb
- 從零開始實現放置遊戲(一)遊戲
- 武俠遊戲,江湖告急遊戲
- 武俠遊戲演變史:從“俠客英雄傳”到“只狼”遊戲
- 從零開始,如何用puppeteer寫一個爬蟲指令碼爬蟲指令碼
- 【從零開始擼一個App】PKCEAPP
- 從零開始做一個SLG遊戲(一):六邊形網格遊戲
- 從零開始做一個SLG遊戲(七):遊戲系統以及配置表遊戲
- 從零開始編寫自己的JavaScript框架(一)JavaScript框架
- 從零開始寫JavaScript框架(二)JavaScript框架
- [AST實戰]從零開始寫一個wepy轉VUE的工具ASTVue
- 從零開始寫一個微前端框架-樣式隔離篇前端框架
- 從零開始寫一個微前端框架-資料通訊篇前端框架
- 從零開始編寫一個 Python 非同步 ASGI WEB 框架Python非同步Web框架