基于声网 Flat 构建白板插件应用“成语解谜”的最佳实践
前言
本文作者赵杭天。他参加了“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
评论