前言
本文作者趙杭天。他參加了“2022 RTE 程式設計挑戰賽”——“賽道二 場景化白板外掛應用開發” , 並憑藉作品“成語解謎”獲得了該賽道大獎。“成語解謎”是一個基於互動白板 SDK 的互動小遊戲應用。透過前端編碼、呼叫白板 API 能力、定製化後端邏輯等,實現了一個老少咸宜、寓教於樂的成語解謎遊戲。其中的流程、步驟與相關的技術棧在白板互動應用開發上具有一定的通用性。本文將分享該專案的開發過程,包括一些關鍵功能的實現,希望與各位同學一起交流,共同進步。大家可以訪問 game.willtian.cn/idiom2/,線上體驗該作品。
01 選題
為什麼要做這樣一款小遊戲?有幾個原因。
零幾年剛上小學的時候,第一次接觸到電腦和教育軟體,裡面有一些小遊戲,真的會被引導去學習到一些東西,比如一些名詞概念、科學常識,對小孩子挺有幫助。
“白板”兩個字,給我的第一感覺是回到了校園。在學校裡都能遇到很好的同學和老師,有很多美好的回憶。小時候喜歡讀成語字典,就像看故事書,然後在教室裡也會玩一些類似成語解謎這樣的字謎遊戲。
20 年疫情在家會玩一些益智休閒遊戲,能玩到自己做的遊戲,感覺很開心。另外,這類遊戲很適合碎片化的時間,並且能讓使用者學習到一些東西。尤其適合小朋友和喜歡休閒遊戲的大朋友;對於長輩,操作上比較友好,內容也容易引起共鳴。從市場和社會上看,都是有價值的。
02 什麼是互動白板 SDK
互動白板的正式名稱叫聲網 Flat(點選文末“閱讀原文”,瞭解更多),官方的解釋是:“個人老師可直接使用的線上授課軟體,開箱即用,前後端完全開源,快速搭建簡約美觀的線上教室”。它執行起來初始介面長這樣子:
互動白板初始介面
左側工具欄圖示告訴我們,這是一個可以在上面寫寫畫畫的東西。它具有這些特點:
1.互動性,每個房間對應一個互動白板,預設情況下,房間內所有人都可以操作白板,並且互動效果所有人可見的;
2.擴充套件性,除基本的書寫、塗鴉功能外,互動白板支援自定義應用(點選工具欄最下面的“田”字型圖示檢視所有應用);
個人認為支援各種 APPs 是 Flat 互動白板最強大的功能,透過 Flat 提供的 SDK 能力,我們可以實現許多複雜的功能的白板應用。
每個房間對應一個白板
互動白板的內容,包括文字、塗鴉以及 App,可由 SDK 中的 Window Manager 物件來控制。可以透過官方提供的 demo 來快速熟悉一個App開發流程。利用 Window Manager 的 API 介面,我們可以完成應用例項通訊等操作,具體例子請見後文。
03 架構規劃
在展開具體例子前,先介紹“成語解謎“專案的整體框架。如下圖,我們將前後端分離的方式,前端專注頁面繪製與互動,後端專注題目生成與結果判斷。使用者訪問前端頁面無需下載全量詞庫,大幅提高訪問速度。前端利用 Window Manager 的 context API 介面,在聲網伺服器上進行 App 例項的同步與廣播。
前端 App 例項與聲網服務、遊戲後端的通訊
04 介面設計
我們採用“設計驅動”的開發模式,首先畫出設計圖,然後一步一步的把腦海裡的畫面透過程式碼變成現實 :
設計草圖
遊戲主介面設計圖如上,互動設計如下:
1.謎面隨機出現若干個成語,這些成語由公共字進行關聯,作為生成的約束條件;
2.成語間關聯的公共字被挖走並隨機排列,作為候選字;
3.使用者透過“觸控->拖拽->放置” 互動操作候選字的完成對謎面的補全;
4.“提交”得到對使用者謎面的判斷結果,分別對應通關與未透過的場景;
5.“重置”將謎面和候選字恢復到遊戲初始狀態;
6.“答案”透過彈窗展示謎面包含成語的資訊,包括字型、字音、釋義、出處以及用例;
(對於比較複雜的場景,建議把場景直接切換的邏輯都畫出來,形成一個比較完成的需求文件)
抓住主要矛盾,優先完成核心功能的開發,實現產品原型後,再繼續打磨,解決次要矛盾。
05 前端開發
完成遊戲基本介面設計後,我們開始選擇前端框架並完成介面開發。
適合遊戲開發的前端框架很多,Three.js、Phaser、Cocos2d-js等,針對具體需求選擇。個人感覺 Three.js 比較底層,用來寫遊戲程式碼量可能比較大。Cocos2d-js 封裝程度較高,需要熟悉Cocos的工具鏈,對於非專業做遊戲的同學而言,上手難度不低而且技術可遷移性不高。
這裡選擇的是 PixiJS,PixiJS 是一個基於 2D WebGL 的渲染引擎,相容HTML5 Canvas。它有一系列合理、整潔的 APIs,支援 Sprite,將物件抽象為各種層級的 Container。類似 React/Vue 資料驅動的設計,在 PixiJS 中,透過修改 Container 的引數,即可產生使用者介面的變化。Pixi 的 API 實際上是 Flash 率先使用的,經過反覆改進,有 Flash 經驗的同學極易上手。
入口
以“成語解謎”為例,我們來介紹編碼的一些細節。首先我們找到自己程式碼的掛載點,根據文件給出的 demo 或者本文提供的例子,找到這個入口檔案:
自定義應用的入口(src/index.js)
注意到 const box = context.getBox();
這一行,box 對應這個應用開啟的視窗。我們透過 box.mountContent
向視窗掛載了包含我們的 App 例項的 div 容器 $content
。
App 類
接下來,我們定義 App 類。關鍵程式碼如下。
App 類(src/app.js)App 類中持有一個 PIXI.Application
例項,此外 App 類還持有一些相對 App 維度上的變數與方法,例如:從 setup
(見 src/index.js)裡透傳的過來的 context
(用於呼叫 Window Manager 的 API)、App 例項的 id(用於前端區分 App 例項)、layers(圖層)、 resizeObserver
(用於監聽介面變化並自適應佈局) getRandomString
(生成每局遊戲的 token,用於後端互動)、storage(用於在聲網伺服器上存取App的狀態)等。
Scene類
我們為每個場景寫一個 Scene 類,這裡只有一個場景。App 類例項化了 Scene 類,並使用 addChild
將 scene
例項加入渲染。接下來我們為主介面寫一個 Scene。關鍵程式碼如下:
Scene 類(src/scene.js)
在 Scene 的建構函式里例項化了“提交”、“重置”和“答案”三個按鍵,並定義了對應事件。我們在 Scene 裡例項化了類 Idiom,一個 Idiom 例項對應一套字謎與候選字,Idiom 又有子物件 Piece,Piece 對應具體的每一個字塊。由於 Scene 的按鍵事件函式的需要,我們把Piece 狀態的儲存/讀取方法寫在了 Scene 類裡。
Idiom 類 & Piece 類
我們在 Idiom 類裡定義了謎面與候選字的(Piece)字塊生成方法、重置方法、拖拽生效方法。在 Piece 類中實現拖拽時的外觀行為。
Idiom 類(src/idiom.js)
Piece 類(src/piece.js)
整體效果
主介面執行效果
06 後端開發
例項關聯與隔離
由於詞庫比較大,使用者每次載入完整詞庫會消耗較多的頻寬和時間,對使用者體驗影響較大。我們透過搭建後端將謎面的獲取、提交結果的驗證、答案的獲取,進行服務化,提升使用者體驗。
如上文“架構規劃”所述,我們和每個 App 例項均持有一個 token,用於與後端通訊時,對應上後端的遊戲例項物件。UserGames 的 key 即為 token,在接受到瀏覽器發來的請求後,後端會在 UserGames 中查詢相應的遊戲例項 BoardGame,並得到當前的遊戲狀態,包括謎面 table、答案 answers、答案解析 answerDetail 等。
使用 UserGames 的 key(token) 來隔離遊戲例項,並與前端 App 例項關聯
謎面生成
謎面是怎麼生成的呢,基本的演算法思路是:
1.預處理成語庫,建立所有成語的字索引 NthOfChar *[]map[rune][][]rune
,儲存第 n 個字為 m 的資訊;
2.使用 DFS 遞迴搜尋謎面。在當前成語找一個字 k 作為下一個生成開始的節點,根據約束條件,選定新成語以及新成語擺放位置:
a. k 必須出現在新成語中;
b. 新成語放置後須保證當前謎面不被破壞;
搜尋的過程中使用索引 NthOfChar
實現剪枝;
多解相容
我們透過生成演算法形成的謎面同時會產生 1 個唯一的答案。但實際上可能答案並不唯一,尤其是在成語較多時,交換某幾個字,亦可生成合理的答案。針對這種情況,我需要逐個校驗使用者提交的成語。若成語庫裡總共有 N 個成語,對成語庫的成語生成字典樹 Trie,可以將查詢時間複雜度從 O(N) 下降到 O(1),最多 4 次搜尋。
全域性單例
負責遊戲例項生成的結構體 GlobalBoard 儲存了全量成語以及中間資料資訊,作為全域性單例,減少記憶體複製;對於每個問題(謎面)獲取的請求,直接返回 GlobalBoard 生成結果的複製。
使用全域性單例與狀態複製的方式最佳化記憶體使用
07 App 例項通訊
例項狀態的同步
到目前為止,我們基本實現單使用者的遊戲。但是當我們開啟兩個瀏覽器 tab 模擬多使用者操作時會發現,App 的互動僅對當前使用者生效,其他使用者是無感知的。表現為,A 使用者開啟 App,拖拽到 App 視窗合適的位置,開始遊戲,將候選詞與空字塊交換,然後提交;同時,B 使用者在同一房間,卻只看到了 A 開啟 App,拖拽 App,看到的 App 內容與 A 的 App 展示內容並不同步,也感知不到 A 對 App 做的操作(能看到 A 滑鼠游標運動,這是 Flat 兜底的同步邏輯)。
針對當前問題,我們可以自然想到必須有某種機制,使使用者在本地對 App 例項操作後,同步狀態到某個所有使用者可訪問的遠端服務裡,然後通知所有使用者將遠端服務儲存的狀態同步到本地 App 例項中,重新渲染 App 畫面,這樣才可以實現多使用者的互動。
談到這裡,大家可能會想到,那我們是否可以在自己寫的後端服務中加入同步功能呢?讓我們構思一下做這樣的同步功能需要做哪些事:
1.設計一套通訊機制,本地例項能夠主動感知遠端狀態的更新;
2.處理好超時、重連、弱網等問題;
3.延遲足夠低,能接受業務波動的負載;
4.服務經過充分的測試,足夠穩定;
仔細思考會發現,穩定可靠的實時通訊其實是一個比較大的課題,並不應該成為實現業務、產生業務價值的一個主要工作,換言之,自己造輪子的投入產出並不高。聲網在實時網路通訊領域耕耘多年,基於其技術積累,在 Flat 專案中提供一系列非常有用的通訊 APIs,這些 APIs 設計與 React 很像,比較容易上手。下面我們透過這些 APIs 進行同步與廣播,解決互動性的問題。
讓我們回到前端程式碼裡,在 app.js 的 App 類做一些修改:
初始化例項的 storage
我們給每個 App 例項持有一份 storage
物件, storage
物件來自白板應用建立時得到的 context。這裡的 storage.ensureState
用以確保 storage.state
包含某些初始值。 context.storage
實際上關聯了遠端服務的一個儲存例項,它實時監聽到本地 storage 的變化,當變化發生時,將自動同步最新的 storage 到服務端。即使是不同的使用者,同一房間相同的應用例項,實際上會對應到同一個遠端 storage,畫一張圖直觀一些:
storage
關聯關係圖
弄明白 storage 的同步特性,我們要做的就是在遊戲狀態發生變化的時候更新 context.storage,以及增加監聽 context.storage 變化的回撥事件,將遠端 context.storage 同步到遊戲(應用例項)中。
我們將狀態的 push/pull 方法做封裝,使程式碼更利於維護。這裡的 storage.setState
和 React 的 setState 類似,更新 storage.state
並同步到所有客戶端。
遊戲狀態 -> 遠端storage
增加監聽事件, addStateChangedListener
在有人呼叫storage.setState()
後觸發 (包含當前 storage
) ,在這裡我們編寫將遠端 storage
同步到遊戲狀態的邏輯。
遠端 storage -> 遊戲狀態
分散式鎖
設想這麼一個場景,我們的使用者需要共同操作同一個 App 例項,比如共同完成一場解謎遊戲,使用者 A、B 幾乎同時點選了“提交”,後端接到提交請求,判斷答案正確,然後為遊戲例項分發新的題目,此時,若後端在為 A 分發題目的過程中 B 的請求到達,且也給 B 分發新的題目,會導致 A、B 前端收到不一致的新題目。此外,還有一種場景,使用者 C 因為弱網或其他原因,提交後未馬上收到反饋,重複頻繁地點選提交,將導致發起重複請求,使用者較多且請求時間集中時,容易導致負載波動,影響服務質量。
因此,我們有必要為“提交”增加一個分散式的鎖,使在某個 App 例項裡,所有時間裡,只能由一個使用者提交。
透過 context.storage 實現分散式鎖
例項廣播
當對於某個 App 例項,某個使用者提交透過得到新的遊戲狀態(新的謎面與候選詞等)後,需要將狀態同步給其他使用者。實際上我們可以將獲取新遊戲與狀態寫入本地遊戲這兩步分離,在進行廣播時自己也會接收到,所有包括自己在內的使用者監聽到廣播立即寫入本地遊戲。如圖所示:
先獲取新狀態再透過廣播進行狀態同步的流程
我們可以利用廣播與監聽 API context.dispatchMagixEvent(event, payload)
和 context.addMagixEventListener(event, listener)
上述功能:
在遊戲狀態發生變化(提交成功和重置)時廣播
監聽廣播發生,並根據具體事件做不同操作
至此,我們的跨越前後端的例項通訊部分也完成了,實現了使用者對 App 例項操作時互動的同步,並處理了如同時、重複提交這類的併發問題。此類問題在其他互動應用的開發中也普遍存在,這裡提供了一些參考。
08 小結
聲網 Flat 開源專案提供了白板 SDK,支援開發自定義 App,為線上教育和白板應用提供了巨大的想象空間。本次分享從一個初次接觸 Flat 開發者的視角,介紹了互動白板的特點,並從基於實際例子——完成一款互動小遊戲,分享了小遊戲前端框架的選擇與使用、整體架構設計思路、後端開發流程等。同時介紹一些實用的 window-manager API,並在實戰中如何使用這些 APIs 來快速解決一些原本比較複雜的問題。希望能對大家開發Flat白板自定義應用、線上互動小遊戲中提供一些參考和幫助。由於時間倉促,仍存在許多有待完善和最佳化的點,請大家不吝指出。拋磚引玉,互動教育、教育遊戲等在國內外仍有較大的市場前景,希望與大家有更多的交流與合作,謝謝大家。
- 參考:
https://github.com/netless-io/window-manager/blob/master/docs/develop-app.md
- 成語解謎:
https://github.com/Zhao-hangtian/happy-star
- 大賽官網:
https://www.agora.io/cn/rte-hackathon-2022
- 大賽作品倉庫:
https://github.com/AgoraIO-Community/RTE-2022-Innovation-Challenge