基於聲網 Flat 構建白板外掛應用“成語解謎”的最佳實踐

聲網發表於2022-12-26

前言

本文作者趙杭天。他參加了“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 類,並使用 addChildscene 例項加入渲染。接下來我們為主介面寫一個 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

相關文章