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

news2024/11/24 12:46:27

前言

本文作者赵杭天。他参加了“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/netless-io/window-manager/blob/master/docs/app-context.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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/116631.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

刷题记录:牛客NC17509挖沟[prim+kruskal算法详解]

传送门:牛客 题目描述: 胡队长带领HA实验的战士们玩真人CS,真人CS的地图由一些据点组成,现在胡队长已经占领了n个据点,为了方 便,将他们编号为1-n,为了隐蔽,胡队长命令战士们在每个据点出挖一个坑&#x…

Cocos Creator 3.61所有工具软件的使用

文章目录Tiled-地图绘制软件下载安装基本使用点点就会了导入cocos编辑动画注意cocos导入TiledBigShear-图片裁剪为plist形式下载安装使用TexturePacker-使用plist文件产生精灵图集下载安装Tiled-地图绘制软件 下载安装 官网免费 Tiled官方 安装无要求 基本使用 点点就会了 导…

UE5 Meerkat狐獴演示Demo分析

1.特效的生成方式 1.1临时特效的生成:使用了已生成轨道临时创建该特效(不用在场景中放入该特效,而是临时创建即可)、系统生命周期轨道设置该特效的播放时长 1.2长期特效的生成:特效时长为该镜头片段长度 2.特效的类…

软考高级哪个好考?

软考高级有5个科目。 开发方向的有系统分析师,系统架构师; 网络方向的有网络规划与设计师; 信息系统方向的有信息系统项目管理师; 信息化服务方向的有系统规划与管理师。 考试题型 上午是9:00 — 11:30考综合知识,…

ROBOGUIDE软件:FANUC机器人多层堆焊功能介绍与示教编程操作方法

目录 机器人多层堆焊功能介绍 机器人跟踪路径数据指令介绍 机器人多层堆焊指令介绍 机器人弧焊焊接工作站创建 机器人多层堆焊示教编程 仿真运行 机器人多层堆焊功能介绍 在厚板焊接中进行多层堆焊焊接,以便多次焊接相同的部位而增大焊接宽度。通常情况下&am…

Java池化技术

在我们平常的编码中,通常会将一些对象保存起来,这主要考虑的是对象的创建成本。比如像线程资源、数据库连接资源或者 TCP 连接等,这类对象的初始化通常要花费比较长的时间,如果频繁地申请和销毁,就会耗费大量的系统资源…

Java-String 类·上

Java-String 类上1. 创建字符串2. 字符串比较相等3. 字符串常量池4. 理解字符串不可变大家好,我是晓星航。今天为大家带来的是Java String字符串相关知识点的讲解!😀 1. 创建字符串 常见的构造 String 的方式 // 方式一 String str "…

轻量化网络ShuffleNet 旷视

CVPR2018 人脸识别 脸部特效 张翔宇 什么是分组卷积 我们可以回忆一下 普通卷积 feature map有几个 我们的对应的卷积核就需要几个channel 然后我们学习这个 分组卷积 如图所示,前两个channel 有一个2个channel的卷积核负责,两个与两个对应 来自这…

学习.NET MAUI Blazor(四)、路由

Web应用程序的可以通过URL将多个页面串联起来,并且可以互相跳转。Web应用主要是使用a标签或者是服务端redirect来跳转。而现在流行的单页应用程序 (SPA) ,则通过路由(Router)来实现跳转,如Vue 、React等。 提示 MAUI的…

C#一个网络小程序的逐步实现过程

经常要检测某些IP地址范围段的计算机是否在线。 有很多的方法,比如进入到网关的交换机上去查询、使用现成的工具或者编写一个简单的DOS脚本等等,这些都比较容易实现。 现在使用C#来完成。 1、简单上手 公用函数: public static long IPToLong…

传奇服务器容易受到什么攻击,怎么防御攻击?

有兄弟问明杰,说自己打算开服,听说攻击挺多的,就是想先了解一下开传奇用的服务器最容易受到什么类型的攻击,如果遇到了又改怎么防御呢?带着这个问题,明杰跟大家详细的说一下,常见的开区时候遇到…

Max Sum Plus Plus(DP 滚动数组优化)[HDU - 1024]

题目如下: 题目链接 Max Sum Plus Plus 题解 or 思路: 经典的动态规划问题 dp[i][j]dp[i][j]dp[i][j], 前 jjj 个物品, 我们分成题目要求的 iii 组 对于第 jjj 个物品, 我们可以将它分到 第 kkk 组中, 或者分到新的一…

前端八股文——笔试题

目录 前言 一、flex布局手写题目 二、移动端点击事件为什么有延迟?时间多久?如何解决这个问题? 1.meta标签里面content属性,设置禁止缩放。 2.设置默认宽度为浏览器宽度。 3.设置touch-action:manipulation。 4…

Chat-GPT从注册到搬进服务器

首先,我们要明白的一个事实是,不可能免费的成功搞定,得付出10几块得成本,我这个方法满打满算16块钱(是不犯错得情况下,实际上我用了30多了) 1.10元买个香港的服务器 不一定是香港的&#xff0c…

Day 04-Composition API_ref reactive 函数

1.ref函数 作用: 定义一个响应式的数据; 语法: const xxx ref(initValue) 创建一个包含响应式数据的引用对象(reference对象,简称ref对象)。 JS中操作数据: xxx.value 模板中读取数据: 不需要.value,直…

永恒之蓝(MS17-010)

目录追溯了解网络IP查找环境条件复现流程445端口使用MSF的永恒之蓝漏洞模块扫描模块攻击模块温馨提醒:纯水文,如果不幸翻到这篇文章,可以立刻关闭! 先整理两个学习的链接(本文学习第一个): htt…

JDK19都出来了~是时候梳理清楚JDK的各个版本的特性了【JDK17特性讲解】

JDK各个版本特性讲解-JDK17特性 一、JAVA17概述 JDK 16 刚发布半年(2021/03/16),JDK 17 又如期而至(2021/09/14),这个时间点特殊,蹭苹果发布会的热度?记得当年 JDK 15 的发布也是同天…

Kafka Consumer auto.offset.reset 理解

先来一下 kafka 官网对于 auto.offset.reset 的解释: 上面的描述挺准确的,但如果没有相关背景会感觉很懵逼。网上也有很多文章讲这个东西并给了很多例子,看了之后总感觉没有理解清楚。 先来看一下怎么查看消费者 group 的 offset 情况&…

wpf需求及实现方法 动态创建控件 对数据模板任意对象操作 查找由 DataTemplate 生成的元素 查找由 ControlTemplate 生成的元素

我想实现一个支持多设备同时更新固件的应用。如下图 插入多少个设备就显示多少个进度度。每个进度条上显示对应的串口及进度。 最终实现演示如下&#xff1a; public MainWindow(){InitializeComponent();List<DownloadProgress> test new List<DownloadProgress>…

文件系统与文件系统管理以及RAID技术的思想

文件与文件系统FCB&#xff08;文件控制块&#xff09;文件是什么&#xff1f;文件是对 磁盘的抽象所谓文件 是指 一组带标识&#xff08;标识即为文件名&#xff09;的、在逻辑上有完整意义的信息项的序列。信息项&#xff1a;构成文件内容的基本单位&#xff08;单个…