基于声网 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 *[]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 构建白板插件应用“成语解谜”的最佳实践的更多相关文章
- 基于ABP落地领域驱动设计-04.领域服务和应用服务的最佳实践和原则
目录 系列文章 领域服务 应用服务 学习帮助 系列文章 基于ABP落地领域驱动设计-00.目录和前言 基于ABP落地领域驱动设计-01.全景图 基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践 ...
- 基于华为云IOT及无线RFID技术的智慧仓储解决方案最佳实践系列一
[摘要]仓储管理存在四大细分场景:出入库管理.盘点.分拣和货物跟踪.本系列将介绍利用华为云IOT全栈云服务,端侧采用华为收发分离式RFID解决方案,打造端到端到IOT智慧仓储解决方案的最佳实践. 仓储 ...
- 基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践和原则
目录 前言 聚合 聚合和聚合根原则 包含业务原则 单个单元原则 事务边界原则 可序列化原则 聚合和聚合根最佳实践 只通过ID引用其他聚合 用于 EF Core 和 关系型数据库 保持聚合根足够小 聚合 ...
- 基于 Lerna 管理 packages 的 Monorepo 项目最佳实践
本文首发于 vivo互联网技术 微信公众号 https://mp.weixin.qq.com/s/NlOn7er0ixY1HO40dq5Gag作者:孔垂亮 目录 一.背景二.Monorepo vs M ...
- 基于ABP落地领域驱动设计-05.实体创建和更新最佳实践
目录 系列文章 数据传输对象 输入DTO最佳实践 不要在输入DTO中定义不使用的属性 不要重用输入DTO 输入DTO中验证逻辑 输出DTO最佳实践 对象映射 学习帮助 系列文章 基于ABP落地领域驱动 ...
- 基于ABP落地领域驱动设计-03.仓储和规约最佳实践和原则
目录 系列文章 仓储 仓储的通用原则 仓储中不包含领域逻辑 规约 在实体中使用规约 在仓储中使用规约 组合规约 学习帮助 围绕DDD和ABP Framework两个核心技术,后面还会陆续发布核心构件实 ...
- 干货 | 博云基于OVS自研容器网络插件在金融企业的落地实践
本文根据博云在dockerone社区微信群分享内容整理 过去几年博云在企业中落地容器云平台遇到了很多痛点,其中一个比较典型的痛点来自网络方面,今天很高兴跟大家聊聊这个话题并介绍下我们基于OVS自研的C ...
- 基于Jmeter+maven+Jenkins构建性能自动化测试平台
一.目的: 为能够将相关系统性能测试做为常规化测试任务执行,且可自动无人值守定时执行并输出性能测试结果报告及统计数据,因此基于Jmeter+maven+Jenkins构建了一套性能自动化测试平台 ...
- 个人博客添加网易云音乐Flash插件
博客底部添加网易云音乐播放插件 歌单或者歌曲外链可从音乐界面"生成外链播放器"中得到,选择Flash播放插件即可 footer.html文件增加 实现效果: 历史精选文章: Jli ...
- 基于 Vue BootStrap的迷你Chrome插件
代码地址如下:http://www.demodashi.com/demo/14306.html 安装 安装 Visual Studio Code 和Chrome, 自行FQ 详细安装这里略过 安装包管 ...
随机推荐
- Coursera Programming Languages, Part C 华盛顿大学 Week 2
week 2 我们采用一种新的视角来对比 FP 与 OOP,即将问题分解 (decompose) 与实现 (implement) 的方式 OOP Versus Functional Decomposi ...
- mysql查询锁表和表解锁的操作
转载自:https://www.cnblogs.com/qianxiaoruofeng/p/15542468.html 第一种 1.查询是否锁表 show OPEN TABLES where In_u ...
- HTTP 协议(超文本传输协议)
一.HTTP 协议(超文本传输协议) http 协议 版本 1.1 http由来 1960年 http通讯 http原理 URL和URI 区别 Request 请求报文 Response 响应报文 H ...
- UR #3 核聚变反应强度( gcd )
tags: -分解质因数 , gcd 题目大意 给定\(n\)个数,求\(a_1\)与\(a_i\)次小公约数 分析 易知次小公约数是\(\gcd\)的因数,于是用\(\gcd\)除去它的最小质因子即 ...
- 用python遍历一个图片文件夹,并输出所有路径到一个 txt 文件
1 #coding:utf8 2 import os 3 import sys 4 def listfiles(rootDir, txtfile, label=0): 5 ftxtfile = ope ...
- phpmyadmin scripts/setup.php 反序列化漏洞(WooYun-2016-199433)(Kali)
phpmyadmin 2.x版本中存在一处反序列化漏洞,通过该漏洞,攻击者可以读取任意文件或执行任意代码. 通过vulhub靶场进行复现操作 1.首先搭建靶场环境(采用Kali) cd vulhu ...
- python-官网下载安装教程
1.官网地址:https://www.python.org/ 2.点击Downloads,选择Windows版本 3.找到对应版本,这里以3.9.12为例,选择结尾为executable instal ...
- win 端口占用
netstat -aon|findstr "8080" 查看端口 TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 11468 TCP 172.27.232 ...
- CentOS6.x 7.x 8.x 服务器系统初始化设置
服务器设置例子一.挂载硬盘1.磁盘分区fdisk -l #查看设备,一般可以看到设备名为/dev/xvdb,或者为/dev/vdb(阿里云io优化型)fdisk /dev/xvdb #对磁盘进行分区, ...
- mysql授权、导入等基本操作
1.授权: mysqladmin -uroot password rootpwd mysql -uroot -prootpwd mysql -e "INSERT INTO user (Hos ...