前言

本文作者赵杭天。他参加了“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 *[]maprune[]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

基于声网 Flat 构建白板插件应用“成语解谜”的最佳实践的更多相关文章

  1. 基于ABP落地领域驱动设计-04.领域服务和应用服务的最佳实践和原则

    目录 系列文章 领域服务 应用服务 学习帮助 系列文章 基于ABP落地领域驱动设计-00.目录和前言 基于ABP落地领域驱动设计-01.全景图 基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践 ...

  2. 基于华为云IOT及无线RFID技术的智慧仓储解决方案最佳实践系列一

    [摘要]仓储管理存在四大细分场景:出入库管理.盘点.分拣和货物跟踪.本系列将介绍利用华为云IOT全栈云服务,端侧采用华为收发分离式RFID解决方案,打造端到端到IOT智慧仓储解决方案的最佳实践. 仓储 ...

  3. 基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践和原则

    目录 前言 聚合 聚合和聚合根原则 包含业务原则 单个单元原则 事务边界原则 可序列化原则 聚合和聚合根最佳实践 只通过ID引用其他聚合 用于 EF Core 和 关系型数据库 保持聚合根足够小 聚合 ...

  4. 基于 Lerna 管理 packages 的 Monorepo 项目最佳实践

    本文首发于 vivo互联网技术 微信公众号 https://mp.weixin.qq.com/s/NlOn7er0ixY1HO40dq5Gag作者:孔垂亮 目录 一.背景二.Monorepo vs M ...

  5. 基于ABP落地领域驱动设计-05.实体创建和更新最佳实践

    目录 系列文章 数据传输对象 输入DTO最佳实践 不要在输入DTO中定义不使用的属性 不要重用输入DTO 输入DTO中验证逻辑 输出DTO最佳实践 对象映射 学习帮助 系列文章 基于ABP落地领域驱动 ...

  6. 基于ABP落地领域驱动设计-03.仓储和规约最佳实践和原则

    目录 系列文章 仓储 仓储的通用原则 仓储中不包含领域逻辑 规约 在实体中使用规约 在仓储中使用规约 组合规约 学习帮助 围绕DDD和ABP Framework两个核心技术,后面还会陆续发布核心构件实 ...

  7. 干货 | 博云基于OVS自研容器网络插件在金融企业的落地实践

    本文根据博云在dockerone社区微信群分享内容整理 过去几年博云在企业中落地容器云平台遇到了很多痛点,其中一个比较典型的痛点来自网络方面,今天很高兴跟大家聊聊这个话题并介绍下我们基于OVS自研的C ...

  8. 基于Jmeter+maven+Jenkins构建性能自动化测试平台

      一.目的: 为能够将相关系统性能测试做为常规化测试任务执行,且可自动无人值守定时执行并输出性能测试结果报告及统计数据,因此基于Jmeter+maven+Jenkins构建了一套性能自动化测试平台 ...

  9. 个人博客添加网易云音乐Flash插件

    博客底部添加网易云音乐播放插件 歌单或者歌曲外链可从音乐界面"生成外链播放器"中得到,选择Flash播放插件即可 footer.html文件增加 实现效果: 历史精选文章: Jli ...

  10. 基于 Vue BootStrap的迷你Chrome插件

    代码地址如下:http://www.demodashi.com/demo/14306.html 安装 安装 Visual Studio Code 和Chrome, 自行FQ 详细安装这里略过 安装包管 ...

随机推荐

  1. js 比较两个数组对象,取不同的值

    let array1 = [ {'Num': 'A ', 'Name': 't1 '}, {'Num': 'B', 'Name': 't2'}, {'Num': 'C ', 'Name': 't3 ' ...

  2. springsecurity 配置swagger

    最近在学习springsecurity 安全框架,具体是什么概念在这里不一一赘述了.下面呢,咱们一起搭建一下简单的springsecurity swagger 项目感受一下. 首先初始化spring ...

  3. 1903021126 申文骏 Java 第七周作业 客户类测试

     项目  内容 课程班级博客链接 19级信计班(本) 作业要求链接 Java 第七周作业 博客名称 1903021126  申文骏  Java 第七周作业  客户类测试 要求 每道题要有题目,代码(使 ...

  4. server2008R2 安装.net framework 4.7 4.8 时间戳签名和/或证书无法验证或格式错误

    安装补丁 KB4474419 和KB4490628 实测有效 补丁下载: https://www.catalog.update.microsoft.com/Search.aspx?q=4474419 ...

  5. Flutter Web预览时白屏解决方法

    原因是因为运行 flutter run 是自动选择渲染器 桌面端WEB浏览器默认使用  CanvasKit渲染器 移动端WEB浏览器默认使用  HTML渲染器 问题就出在了CanvasKit渲染器,他 ...

  6. nuxt项目npm install 或安装sass时报错

    初始化nuxt项目时,多人开发,同事提前安装的sass ,拉去代码初始化npm install 时提示gyp版本有问题.找了好多方法,最后还是将node.js版本降低了.原来是16.13.2降低为14 ...

  7. Ajax属性

    如何创建一个 Ajax <script>         window.onload=function(){             1)创建一个异步调用对象             va ...

  8. 使用idea从零编写SpringCloud项目-Feign

    ps:Fegin和Ribbon 其实是差不多的东西,Fegin里面也是集成了Ribbon,不过咱们写代码不是要优雅嘛,使用Feign就会优雅很多了,看着比直接使用Ribbon舒坦一点 就不重新构建项目 ...

  9. activiti引擎的表结构(仅记录用)

    act_hi_*:'hi'表示 history,此前缀的表包含历史数据,如历史(结束)流程实例,变量,任务等等.act_ge_*:'ge'表示 general,此前缀的表为全局通用数据,用于不同场景中 ...

  10. MyBatis-Plus使用SQL语句

    项目中碰到一个必须要使用动态SQL的地方, 想着在xml文件中进行一层一层的判断太麻烦了,也不好理解,要是能在Java代码中组织好SQL,进行查询操作 QueryWrapper<object&g ...