來寫狀態機 - NPC商店

一些和本文無關的東西

前兩天,一名在業內做了一兩年策劃的老朋友和我聊起,現在做策劃的人很多,有些什麼東西可以增加自己的競爭力?我想到一般來說,會寫代碼的策劃,比不會寫的要佔點便宜 - 至少策劃會很清楚,什麼東西好實現,什麼東西不好實現,什麼東西沒法實現。不過,這個會要會到什麼程度呢?我覺得是懂得更邏輯的描述你的設計,不論你設計的是什麼。這就要牽扯到常常提到的「流程圖」,而「狀態機(state machine)」則是流程圖更靠近程序員方面的表現 - 它更接近於你的流程實際是怎麼實現的,而這常常涉及到構思很多關鍵事件的先後順序 - 因為在遊戲編程里,狀態機只能安排「一幀里的行動」。

美國遊戲業的很多「Designer」是寫代碼的,或者說,寫script的,因此寫「狀態機」就是我們日常工作的一大部分。即使你不會實際接觸代碼,以狀態機的形式去構思你設計的遊戲流程,也會讓你避開很多隻寫粗略流程圖可能踩中的邏輯陷阱。這個專題 - 來寫狀態機 - 因此而誕生。

免責聲明:專欄中的所有文章只代表本人看法,不涉及我所工作的公司實際的實現方式,不代表任何我所工作的公司的觀點。

行為

那麼,我們先從一些實時性不那麼強的內容開始吧。NPC商店 - 提供物品購買、出售和修理 - 是遊戲里很常見的一個內容。為了涵蓋這三種交互,我們來舉個簡單的例子吧:魔獸世界中的鐵匠NPC。

一個魔獸世界中的鐵匠一般包含下面的行為:

  • 允許你購買專業技能鍛造所需的材料和工具:元素助熔劑、煤塊、強效助熔劑、弱效助熔劑、礦工鋤、鐵匠錘;
  • 允許你修理你的裝備,選擇的一件或全身所有部位;
  • 允許你出售持有的物品。
  • 允許你購回剛出售的物品。

先列好你將要開發的內容所涉及的行為很重要,接下來我們要構想這些行為的具體細節:

  • 當你右鍵點擊NPC時,NPC會打開購買物品的界面。在界面上,清楚地列出了所有他所出售的物品。當你按下ESC / 按右上角的X時,NPC關閉這個界面。只有在這個界面存在時,你才能進行所有的交互行為。
  • 在這個界面打開的同時,你可以右鍵點擊NPC列出的物品進行購買。
  • 與此同時,右鍵點擊包里任何的物品,可以出售它們 - 只要它們是可以出售的物品。
  • 在購買界面上點左邊的小鎚子圖標,可將你的滑鼠指針切換為修理錘,此時,點擊背包中或身上穿戴的裝備,可以進行修理。
  • 在購買界面上點右邊帶+號的鐵砧,可以立刻修理所有背包中及裝備中的物品。

  • 右下角顯示的是你剛剛賣出的物品。左鍵點擊它可以立刻購回。
  • 點擊最下方的「購回」標籤可以切換到購回頁面,在那裡,你可以買回最近賣出的物品。點擊「商人」標籤可以切換回現在的界面。在打開購回頁面時,你不能出售物品。

這個構想過程很快幫我們列出了一些需求:

  • 為了能夠購買物品,我們需要一個顯示他的售賣品的UI。
    • 為了在上面的UI中顯示物品,我們需要向伺服器端獲取這個鐵匠的物品列表,在本地,我們也需要一個數據結構來存儲這個列表。
    • 我們需要UI上的物件來顯示每一件物品。
    • 我們需要兩個UI按鈕,一個用於單件修理,一個用於批量修理。
    • 為了讓購買實際發生,我們需要用右鍵去點UI中的物品 - 因此我們需要監聽「滑鼠事件」。
    • 這是一個在線遊戲,我們需要一個過程來等待伺服器處理你的購買請求,並確認請求完成後,再更新你的背包/鐵匠的貨架(記住,有些圖紙或者材料是限量的!)。
  • 為了允許鐵匠修理你的物品,我們需要設立一個標記(flag),來讓遊戲明白你現在左鍵點擊你背包里的物品,不是為了拿起他們,而是為了修理他們。
    • 這是一個在線遊戲,我們需要一個過程來等待伺服器處理你的修理請求,並確認請求完成後,再更新你的背包。

  • 為了允許鐵匠批量修理你全身的物品,我們需要一個過程來等待伺服器依次處理所有需要修理的物品,並確認所有修理請求完成後,再更新你的背包。
  • 為了允許你出售持有的物品,我們需要設立一個標記,來讓遊戲明白現在你右鍵點擊你背包里的物品,不是為了使用它們,而是為了出售他們。
  • 為了允許你購回剛出售的物品,我們需要一個新的UI來顯示你近期賣出的物品。因為在購回頁面下,你不能出售物品,不能修理,最好也設立一個標記來方便邏輯上的控制。
    • 為了在這個UI中顯示物品,我們需要向伺服器端獲取近期出售的物品列表,在本地,我們也需要一個數據結構來存儲這個列表。
    • 我們需要UI上的物件來顯示每一件物品。

事情開始變得複雜了 - 但不要急,這麼多狀態不會同時發生,不是嗎?至少我們有一個最基礎的概念,就是這個商店有兩個最基本的狀態:

  1. 關閉
  2. 開啟

於是我們有了最初的代碼:

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

一種優化的思路是,先做INIT,但是不保持每幀去檢查玩家是否和商店開始了交互——在現在我們寫好的代碼里,是在CLOSED狀態里持續檢查是否玩家和商店開始了交互,如果開始了,進行INIT,然後直接打開商店。如果我們這樣進一步優化,可以把狀態機拆成:

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會朝著哪個方向發展?
如果為一個班級打造一個成就系統,你有哪些有趣的建議?
模擬經營快餐店這類遊戲核心玩法是什麼?

TAG:游戏开发 | 游戏策划 | 游戏编程 |