來寫狀態機 - NPC商店
美國遊戲業的很多「Designer」是寫代碼的,或者說,寫script的,因此寫「狀態機」就是我們日常工作的一大部分。即使你不會實際接觸代碼,以狀態機的形式去構思你設計的遊戲流程,也會讓你避開很多隻寫粗略流程圖可能踩中的邏輯陷阱。這個專題 - 來寫狀態機 - 因此而誕生。
免責聲明:專欄中的所有文章只代表本人看法,不涉及我所工作的公司實際的實現方式,不代表任何我所工作的公司的觀點。
行為
那麼,我們先從一些實時性不那麼強的內容開始吧。NPC商店 - 提供物品購買、出售和修理 - 是遊戲里很常見的一個內容。為了涵蓋這三種交互,我們來舉個簡單的例子吧:魔獸世界中的鐵匠NPC。
一個魔獸世界中的鐵匠一般包含下面的行為:
- 允許你購買專業技能鍛造所需的材料和工具:元素助熔劑、煤塊、強效助熔劑、弱效助熔劑、礦工鋤、鐵匠錘;
- 允許你修理你的裝備,選擇的一件或全身所有部位;
- 允許你出售持有的物品。
- 允許你購回剛出售的物品。
- 當你右鍵點擊NPC時,NPC會打開購買物品的界面。在界面上,清楚地列出了所有他所出售的物品。當你按下ESC / 按右上角的X時,NPC關閉這個界面。只有在這個界面存在時,你才能進行所有的交互行為。
- 在這個界面打開的同時,你可以右鍵點擊NPC列出的物品進行購買。
- 與此同時,右鍵點擊包里任何的物品,可以出售它們 - 只要它們是可以出售的物品。
- 在購買界面上點左邊的小鎚子圖標,可將你的滑鼠指針切換為修理錘,此時,點擊背包中或身上穿戴的裝備,可以進行修理。
- 在購買界面上點右邊帶+號的鐵砧,可以立刻修理所有背包中及裝備中的物品。
- 右下角顯示的是你剛剛賣出的物品。左鍵點擊它可以立刻購回。
- 點擊最下方的「購回」標籤可以切換到購回頁面,在那裡,你可以買回最近賣出的物品。點擊「商人」標籤可以切換回現在的界面。在打開購回頁面時,你不能出售物品。
- 為了能夠購買物品,我們需要一個顯示他的售賣品的UI。
- 為了在上面的UI中顯示物品,我們需要向伺服器端獲取這個鐵匠的物品列表,在本地,我們也需要一個數據結構來存儲這個列表。
- 我們需要UI上的物件來顯示每一件物品。
- 我們需要兩個UI按鈕,一個用於單件修理,一個用於批量修理。
- 為了讓購買實際發生,我們需要用右鍵去點UI中的物品 - 因此我們需要監聽「滑鼠事件」。
- 這是一個在線遊戲,我們需要一個過程來等待伺服器處理你的購買請求,並確認請求完成後,再更新你的背包/鐵匠的貨架(記住,有些圖紙或者材料是限量的!)。
- 為了允許鐵匠修理你的物品,我們需要設立一個標記(flag),來讓遊戲明白你現在左鍵點擊你背包里的物品,不是為了拿起他們,而是為了修理他們。
- 這是一個在線遊戲,我們需要一個過程來等待伺服器處理你的修理請求,並確認請求完成後,再更新你的背包。
- 為了允許鐵匠批量修理你全身的物品,我們需要一個過程來等待伺服器依次處理所有需要修理的物品,並確認所有修理請求完成後,再更新你的背包。
- 為了允許你出售持有的物品,我們需要設立一個標記,來讓遊戲明白現在你右鍵點擊你背包里的物品,不是為了使用它們,而是為了出售他們。
- 為了允許你購回剛出售的物品,我們需要一個新的UI來顯示你近期賣出的物品。因為在購回頁面下,你不能出售物品,不能修理,最好也設立一個標記來方便邏輯上的控制。
- 為了在這個UI中顯示物品,我們需要向伺服器端獲取近期出售的物品列表,在本地,我們也需要一個數據結構來存儲這個列表。
- 我們需要UI上的物件來顯示每一件物品。
- 關閉
- 開啟
SWITCH (shopData.eState) CASE BLACKSMITH_CLOSED //Do nothing BREAK CASE BLACKSMITH_OPEN //Do something BREAK DEFAULT //這裡應該什麼都沒有ENDSWITCH
讓我們慢慢的實現剩下的細節吧。
每一幀的次序
不難想到,每一幀里我們都要處理下面的工作:
- 在屏幕上顯示商店的UI。
- 檢查滑鼠輸入,並處理。
- 檢查鍵盤輸入(別忘了,按ESC鍵會關店!),並處理。
- 決定是否要切換狀態。
一般來說,首先我們會花時間顯示東西,這部分也最耗時,然後我們去檢查滑鼠輸入(是的,這其實是上一幀的時候我們輸入進去的指令)和鍵盤輸入,最後再做狀態切換。
切換狀態處在結尾是一種經驗上的習慣。邏輯上來說,當我在處理商人頁面的內容時,即使沒有實際顯示任何變化,我也是在「處理商人頁面」,因此在這個過程中我如果已經變成了「購回頁面」的狀態,是很奇怪的。功能上說,如果同時有別的過程在生效,並有可能會讀取到你這個商店的狀態機時,提前進行狀態變更就容易造成一些bug。
所以我們應該這樣做:
SWITCH (shopData.eState) CASE BLACKSMITH_CLOSED IF IS_SHOP_JUST_STARTED(shopData) shopData.bIsBuybackActive = FALSE //一開始打開的是商人頁面 shopData.eState = BLACKSMITH_OPEN ENDIF BREAK CASE BLACKSMITH_OPEN IF shopData.bIsBuybackActive REFRESH_BUYBACK_TAB() //處理UI ELSE REFRESH_MERCHANT_TAB() //處理UI ENDIF RESOLVE_SHOP_INPUT_EVENTS() //在這裡,我們看看鍵鼠都做了啥 //有一些就地處理,有一些設標記等其他過程處理 IF IS_FLAG_SET(shopData, BS_SHOP_CLOSE_CLICKED) //檢查標記 CLEAR_FLAG(shopData, BS_SHOP_CLOSE_CLICKED) //重置標記,重要 shopData.eState = BLACKSMITH_CLOSED ENDIF BREAK DEFAULT //這裡應該什麼都沒有ENDSWITCH
「初始化」狀態
現在我們意識到我們需要一些「數據」,這些數據包括:
- 這個NPC在賣什麼。
- 你最近賣過什麼。
我們應該先把這些數據準備好。由於和伺服器溝通是一件比較「耗時(expensive)」的工作,我們不能期待這個行為在一幀內完成,因此通常來說,對於多數的遊戲內容,你需要準備一個「初始化」狀態,用來載入數據,它的邏輯很簡單:
SWITCH (shopData.eState) CASE BLACKSMITH_CLOSED IF IS_SHOP_JUST_STARTED(shopData) shopData.eState = BLACKSMITH_INIT //進入初始化 ENDIF BREAK CASE BLACKSMITH_INIT //初始化狀態 IF NOT IS_SHOP_INIT_START(shopData) //如果還沒開始載入 START_SHOP_INIT(shopData) //開始載入 ENDIF IF IS_SHOP_INIT_COMPLETE(shopData) //如果載入完成 FLAG_PLAYER_AS_IN_SHOP(TRUE) //更動玩家狀態 shopData.bIsBuybackActive = FALSE //這個應該移到這裡! shopData.eState = BLACKSMITH_OPEN //正式開張 ENDIF BREAK CASE BLACKSMITH_OPEN IF shopData.bIsBuybackActive REFRESH_BUYBACK_TAB() ELSE REFRESH_MERCHANT_TAB() ENDIF RESOLVE_SHOP_INPUT_EVENTS() IF IS_FLAG_SET(shopData, BS_SHOP_CLOSE_CLICKED) CLEAR_FLAG(shopData, BS_SHOP_CLOSE_CLICKED) shopData.eState = BLACKSMITH_CLOSED ENDIF BREAK DEFAULTENDSWITCH
在這個載入過程中可以做很多事:
- 獲得我們所需的數據:NPC的出售物品列表,你最近出售的物品列表。
- 通知伺服器改變玩家的狀態(當我在商店裡,別人就不能跟我交易了)。
- 其他一些程序上的行為,比如,預載所需的UI資源。
- 其他你能想到的行為。
SWITCH (shopData.eState) CASE BLACKSMITH_CLOSED IF IS_SHOP_JUST_STARTED(shopData) shopData.eState = BLACKSMITH_INIT ENDIF BREAK CASE BLACKSMITH_INIT IF NOT IS_SHOP_INIT_START(shopData) START_SHOP_INIT(shopData) ENDIF IF IS_SHOP_INIT_COMPLETE(shopData) FLAG_PLAYER_AS_IN_SHOP(TRUE) shopData.bIsBuybackActive = FALSE shopData.eState = BLACKSMITH_OPEN ENDIF BREAK CASE BLACKSMITH_OPEN IF shopData.bIsBuybackActive REFRESH_BUYBACK_TAB() ELSE REFRESH_MERCHANT_TAB() ENDIF RESOLVE_SHOP_INPUT_EVENTS() IF IS_FLAG_SET(shopData, BS_SHOP_CLOSE_CLICKED) CLEAR_FLAG(shopData, BS_SHOP_CLOSE_CLICKED) shopData.eState = BLACKSMITH_UNLOAD ENDIF BREAK CASE BLACKSMITH_UNLOAD //卸載狀態 IF NOT IS_SHOP_UNLOAD_START(shopData) START_SHOP_UNLOAD(shopData) ENDIF IF IS_SHOP_UNLOAD_COMPLETE(shopData) FLAG_PLAYER_AS_IN_SHOP(FALSE) shopData.eState = BLACKSMITH_CLOSED ENDIF DEFAULTENDSWITCH
刷新UI
現在可以開始處理整件事情的第一步,就是在屏幕上顯示物品。由於顯示2D的一屏幕物品並不是一件很耗時的工作,沒有必要特意為這個顯示過程再做優化(相對應的:如果我們要在遊戲里刷出幾個NPC,這個工作就最好分幀來進行,這就需要一個狀態機,我們可以在以後的文章中聊),所以只要一次性的顯示完就可以了。
這部分更多的就是自己喜歡的實現方式的選擇,不是太和本文的主旨有關係(因為沒有用到狀態機),比如我可以這樣做:
- 先檢查現在在顯示貨架的第幾頁,假設為i。
- 從貨架數組的第(i*每頁物品數)格開始循環。
- 獲得第一格UI的物件,然後向它寫入這個物品的信息。
- 循環直到:
- 貨架數組結束了。這個情況下,把「下一頁」的按鈕標灰。
- 物品陳列界面的所有物品格子UI物件都用完了,貨架數組沒有循環完。這個情況下,把「下一頁」的按鈕啟用。
- 如果不在第一頁,把「上一頁」的按鈕啟用;否則,把「上一頁」的按鈕標灰。
- 最後,根據現在在刷新的是「商人」還是「購回」頁面,把對應頁面的UI物件顯示,另一個頁面的UI物件隱藏。
一般來說,輸入信息的獲取在任何場合下都是非同步的,也就是說它不會佔用你的這個商店流程的運算時間:你的遊戲引擎先為你準備好在上一幀收到過什麼輸入指令,你在這一幀便可以讀取並相應進行處理。並且,因為這個準備好的數據包對應的是「上一幀收到過什麼輸入指令」,因此你必須在一幀內把所有需要處理的輸入都處理完,因為下一幀這個隊列就重寫了,而之前沒有處理的所有指令就會丟掉。
具體的實現可能有以下幾種類型:
- 提供一個輸入事件隊列。比如下面的偽代碼描述的情況:
FOREACH eInputEvents IN SCRIPT_INPUT_EVENT_QUEUE DO SWITCH (GET_INPUT_EVENT_TYPE(eInputEvents)) CASE KEY_JUST_PRESSED //Do something... BREAK CASE KEY_PRESSED //Do something... BREAK DEFAULT //Nothing here ENDSWITCHENDFOREACH
一般這種類型處理的是鍵盤、手柄等沒有位置信息的輸入。
- 具體的UI物件提供了輸入事件介面,比如IsJustClicked。這種情況下就要換個方式循環:
FOREACH uiItem IN UI_ITEMS_COLLECTION DO IF IS_JUST_CLICKED(uiItem) //進行處理ENDFOREACH
- 直接在每個單獨的UI物件上掛載處理輸入事件的腳本。比如大家都很熟悉的Unity。這種時候狀態機里的RESOLVE_SHOP_INPUT_EVENTS()就不是很必要了。
既然我們開始處理輸入,現在就得弄明白髮生了購買事件的時候怎麼處理了。先再從頭想一遍這個流程:
- 我們點了一個物品。
- 遊戲告訴伺服器,我們要買/賣掉這個物品。
- 伺服器進行一些驗證,確定我們能買/賣掉這個物品。
- 如果能買賣,伺服器端做一些數據的更新,然後把結果反饋給我們。
- 如果不能買賣,伺服器把錯誤信息反饋給我們。
- 根據收到的反饋信息,遊戲可能需要更新界面,播放音效等。
以本文的第二種來說,一般認為:
- 流程是:發出交易請求→驗證交易請求→更新本地數據→展示反饋→結束。
- 在已經有進行中的交易請求時,阻擋或者暫緩新提交的交易請求。
於是我們可以寫出下面的狀態機:
FUNC VOID INSERT_TRANSACTION_TO_QUEUE(TRANSACTION_OBJECT transactionObj) LocalQueue.Push(transactionObj)ENDFUNC////...////狀態機SWITCH (LocalQueue.eState) CASE MODULE_IDLE IF (LocalQueue.length > 0) transactionData.eState = MODULE_INIT_PURCHASE ENDIF BREAK CASE MODULE_INIT_TRANSACTION IF (LocalQueue.length > 0) IF WAIT_TRANSACTION_VALID_READY(LocalQueue.GetFirst(), result) LocalQueue.lastTransaction = LocalQueue.GetFirst() LocalQueue.lastTransactionResult = result LocalQueue.Pop() //移除第一個 transactionData.eState = MODULE_RESOLVE_TRANSACTION ENDIF //否則,一直等這個交易的驗證 ELSE transactionData.eState = MODULE_IDLE ENDIF BREAK CASE MODULE_RESOLVE_TRANSACTION //更新本地數組 //根據成功或不成功顯示反饋 <- 別的邏輯處理 transactionData.eState = MODULE_INIT_TRANSACTION BREAKENDSWITCH
一些最後的優化
很多具體功能的邏輯就不再深入分析了,因為具體遊戲的情況不一定一樣。最後,有一些常見的優化可以進一步聊聊。
通常來說,在整個遊戲世界裡會有不止一個商店NPC,因此一般會只讓玩家所在地區的商店生效,並關閉其他地區的商店。這個可以通過具體區域的腳本來控制(比如,有一個區域的腳本被註冊為active region,同時其他所有區域執行unload),這種情況下就不需要商店的狀態機做額外的工作。
在有些場合下,即使是只載入一個區域中的NPC,也需要進行進一步優化。我們先再看看之前的代碼——我們最後有四個狀態:
- CLOSED
- INIT
- OPEN
- UNLOAD
SWITCH (shopData.eState) CASE BLACKSMITH_INIT //這個現在只在商店腳本 //剛啟動的時候運行 //也就是伴隨進入region IF NOT IS_SHOP_INIT_START(shopData) START_SHOP_INIT(shopData) ENDIF IF IS_SHOP_INIT_COMPLETE(shopData) FLAG_PLAYER_AS_IN_SHOP(TRUE) shopData.bIsBuybackActive = FALSE shopData.eState = BLACKSMITH_WAIT_FOR_ENTRY ENDIF BREAK CASE BLACKSMITH_WAIT_FOR_ENTRY //在這裡只做位置檢查 //不做輸入檢查 IF IS_PLAYER_IN_VOLUME(shopData.shopVolume) shopData.eState = BLACKSMITH_WAIT_FOR_INTERACTION ELIF IS_SHOP_TERMINATION_START() shopData.eState = BLACKSMITH_UNLOAD //region腳本關閉時做的標記 ENDIF BREAK CASE BLACKSMITH_WAIT_FOR_INTERACTION //進入了商店碰撞體後 //才做輸入檢查 IF IS_SHOP_TERMINATION_START() //region腳本關閉時做的標記 shopData.eState = BLACKSMITH_UNLOAD ELSEIF IS_SHOP_JUST_STARTED(shopData) //輸入檢查 shopData.eState = BLACKSMITH_OPEN ELSEIF NOT IS_PLAYER_IN_VOLUME(shopData.shopVolume) shopData.eState = BLACKSMITH_WAIT_FOR_ENTRY ENDIF BREAK CASE BLACKSMITH_OPEN IF shopData.bIsBuybackActive REFRESH_BUYBACK_TAB() ELSE REFRESH_MERCHANT_TAB() ENDIF RESOLVE_SHOP_INPUT_EVENTS() IF IS_FLAG_SET(shopData, BS_SHOP_CLOSE_CLICKED) CLEAR_FLAG(shopData, BS_SHOP_CLOSE_CLICKED) shopData.eState = BLACKSMITH_WAIT_FOR_INTERACTION //退回等待狀態 ENDIF BREAK CASE BLACKSMITH_UNLOAD //卸載狀態 IF NOT IS_SHOP_UNLOAD_START(shopData) START_SHOP_UNLOAD(shopData) ENDIF IF IS_SHOP_UNLOAD_COMPLETE(shopData) FLAG_PLAYER_AS_IN_SHOP(FALSE) shopData.eState = BLACKSMITH_CLOSED ENDIF BREAK CASE BLACKSMITH_CLOSED //沒什麼可做的了 DEFAULTENDSWITCH
相應的,也可以有先做是否進入了碰撞體的檢查,進了碰撞體才進行INIT載入的優化思路,這些都是由具體遊戲場合下哪些行為更昂貴來決定的(比如後面說的這種,一般是因為內存更緊缺;前面那種則是因為輸入輸出檢查更貴)。
結語
終於完文了,第一次在知乎發此類文章,感覺寫到後面走的已經有點和一開始的計劃不太一樣……很感謝大家的贊,如果有什麼意見請在評論里提出,謝謝大家看到這裡,有什麼感興趣的題目我也可以專門分享!如果沒有特別的,下一期大概會分享一些UI的狀態機?
推薦閱讀:
※設計遊戲的最佳實踐方式(四)
※今後大型MMORPG會朝著哪個方向發展?
※如果為一個班級打造一個成就系統,你有哪些有趣的建議?
※模擬經營快餐店這類遊戲核心玩法是什麼?