查看原文
其他

我如何在互联网计算机上构建多人黑白棋游戏

Dfifans DFINITY 2022-07-07




与传统的云计算平台相比,互联网计算机具有多种优势,可提供更为简化的应用程序开发体验。


我是DFINITY的一名工程师,但我也是一个软件开发人员,因此我想测试这一前提,并从Web开发人员的角度评估在互联网计算机上进行构建的经验。


我选择构建一个可逆版本,一个供两个玩家使用的战略棋盘游戏,而不是作为示例应用程序,而是作为一个真正的应用程序,它具有我想象的多人可逆游戏具有的所有可能性和细节。


在深入探讨幕后技术细节之前,我想重点介绍一下高级概念:一个虚拟环境,互联网应用程序可以无缝地彼此连接。


我个人认为,随着云计算的发展,基础架构将成为一种商品。换句话说,由谁提供基础架构不再重要。


重要的是:您编写了一个应用程序,它在互联网上运行。


程式设计模型


在互联网计算机上开发Web应用程序的经验接近于(现在已经不复存在)Parse或类似平台的较新平台。


这种平台的基本前提是隐藏构建和维护后端服务(例如HTTP服务器、数据库、用户登录名等)的复杂性。


相反,他们提供了一个仅运行用户应用程序的抽象虚拟环境,而用户却不知道或不必注意其应用程序的运行位置和运行方式。


从这个角度来看,互联网计算机既熟悉又不同。


互联网计算机应用程序的基本构建块是容器,从概念上讲,它是一个实时运行的过程,该过程:


  • 是100%确定性的(如果所有输入和状态都相同,则输出必须相同)

  • 透明的持久化(也称为正交持久化)

  • 通过异步消息(远程函数调用)与用户或其它容器通信

  • 一次处理一条消息(按照actor模型)


如果我们将Docker容器视为虚拟化了整个操作系统(OS),则容器将虚拟化单个程序,几乎隐藏了所有OS详细信息。


由于它无法运行您喜欢的OS或数据库,因此似乎过于严格。它有什么用?


我个人更喜欢从学科而不是限制方面进行思考,只是要突出显示容器模型与常规Web服务不同的两个属性(在众多属性中):


  • 原子性:每个消息罐的状态更新都是原子的(远程函数调用),呼叫成功或者状态被更新,或者引发错误并且状态没有被触及(好像从未发生过呼叫)。


  • 双向消息传递:消息最多传递一次,并且始终保证消息调用方是成功还是失败的答复。


如果不限制用户程序的功能,就很难获得这样的保证。


希望在本文阅读结束时,您将同意,受限容器模型实际上可以通过找到效率、健壮和简单性之间的最佳结合来完成很多工作。


客户端:服务器架构


多人游戏需要在玩家之间交换数据,其实现通常遵循客户端-服务器架构:


  • 服务器托管实际的游戏并管理与游戏客户端的通信

  • 两个或更多客户端(分别代表玩家)从服务器获取状态,渲染游戏UI,还接受玩家输入以转发到服务器


将多人游戏构建为Web应用程序意味着客户端必须在浏览器中运行,利用HTTP协议进行数据通信并使用Javascript(JS)将游戏UI呈现为网页。


对于此多人逆转游戏,我想实现以下功能:


  • 任何两个玩家都可以选择互相对战

  • 玩家通过赢得游戏来获得积分,这也计入其累积分数

  • 计分板显示了排名靠前的玩家

  • 当然还有通常的游戏流程:依次从每个玩家那里获取输入信息,仅强制执行合法举动,并检测游戏结束以计算点数


这种游戏逻辑大部分是关于状态操纵的,而服务器端的实现有助于确保玩家拥有一致的视野。


后端服务器


在传统的后端设置中,我将不得不选择一套服务器端软件,包括用于保存玩家和游戏数据的数据库,用于处理HTTP请求的Web服务器,然后编写自己的应用程序软件以将两者连接以实现全套服务器端逻辑。


在“无服务器”设置中,通常平台已经提供了Web服务器和数据库服务,我只需要编写调用该平台的应用程序软件即可使用这些服务。


尽管有误导性的术语“无服务器”,但应用程序仍将按照客户端-服务器体系结构规定的角色扮演“服务器”角色。


无论后端设置如何,我的应用程序设计的中心都是一组API,它们控制游戏服务器与其客户端之间的通信。


在互联网计算机上开发此应用程序也没有什么不同,因此,我从游戏流程的以下高级设计入手:



玩家注册后,如果他们中的任何两个表示希望彼此玩,通过start(opponent_name)调用,则将开始一个新游戏。


然后,玩家轮流放置下一个动作,另一个玩家将不得不定期调用view()以刷新其在最新游戏状态下的视图,然后进行下一个动作,依此类推,直到游戏结束。


根据简单的经验法则,玩家只能在任何给定时间玩一个游戏。


服务器必须保留以下数据集:


  • 注册玩家列表,他们的姓名和分数等

  • 正在进行的游戏列表,每个游戏都包括最新的游戏板,正在玩黑白游戏的人,允许下一步行动的人以及完成游戏后的最终结果等


我选择在Motoko中实现服务器,但是从理论上讲,只要使用相同的系统API与互联网组件通信,任何可以编译为Web Assembly(Wasm)的语言都可以正常工作。(截至撰写本文时,Rust SDK即将真正推出。)


作为一种新的语言,Motoko具有一些粗略的优势(例如,其基础库有点不足并且尚未稳定),但是它已经在VSCode中获得了包管理器和语言服务器协议(LSP)的支持,这使得开发流程变得相当愉快(之所以这样,因为我是Vim用户)。


在这篇文章中,我不会讨论Motoko语言本身。


相反,我将讨论Motoko和互联网计算机的一些值得注意的功能,这些功能使容器的开发令人兴奋。


稳定变量


正交持久性(OP)并不是一个新想法。


诸如NVRam之类的新一代计算机硬件在很大程度上消除了持久存储所有程序存储器的障碍,并且对文件系统等外部存储的访问成为程序的可选操作。


但是,OP文献中经常提到的一个挑战是有关升级的,即,当更新必须更改程序的数据结构或内存布局时,会发生什么?


Motoko用稳定变量回答了这个问题。它们可以通过升级生存下来,在我看来,这是保存玩家数据的理想选择,因为我不希望玩家在更新容器软件时丢失其帐户。


在常规的服务器端开发中,我必须将玩家帐户存储在文件或数据库中,这是“无服务器”平台的基本系统服务。


只有某些类型的变量可以保持稳定,但是除此之外,它们就像在堆上存储数据的任何其它变量一样,也可以这样使用。


也就是说,当前存在一个限制,无法将HashMaps用作稳定变量,因此我不得不诉诸数组。这是一个例子:



我希望DFINITY SDK的将来版本将消除此限制,以便我可以简单地使用稳定的var播放器,而无需进行任何转换。


用户认证


每个容器以及每个客户端(例如dfx命令行或浏览器)都将获得一个唯一标识它们的主体ID(对于客户端,此类ID是从公共/私有密钥对自动生成的,并且DFINITY JS库对其进行管理,目前位于浏览器的本地存储中)。


Motoko允许容器识别“共享”功能的调用方,我们可以将其用于身份验证目的。


例如,我定义注册和查看函数如下:



表达式msg.caller给出消息的调用者的主体ID,请注意,这不同于函数的调用者。


在Motoko中,发给参与者的消息必须发送到公共可访问函数,该函数必须具有异步返回类型。


上面的代码显示了两个公共功能:register和view,其中后者是一个查询调用,由query关键字标记。


如我们所见,访问消息调用者字段必须使用特殊的语法:shared(msg)或shared query(msg),其中msg是一个形式参数,它整体上引用传入的消息。


目前,它具有的唯一属性是caller。


能够访问呼叫者(消息发送者)的唯一ID感到很熟悉,例如HTTP cookie。


但是与HTTP不同,互联网计算机协议实际上确保主体ID在密码上是安全的,并且在互联网计算机上运行的用户程序可以完全信任其真实性。


就我个人而言,我认为让程序知道其调用者可能过于强大,而且过于僵化(例如,当必须更改此类ID时会发生什么?)。


但是就目前而言,它确实导致了一种非常简单的身份验证方案,应用程序开发人员可以利用该方案,我希望在这方面看到更多的发展。


并发性和原子性


游戏客户端可以随时向游戏服务器发送消息,因此服务器有责任正确处理并发请求。


在常规体系结构中,我将必须构建一些逻辑来按顺序(通常是通过消息传递队列或互斥锁)来确定玩家的移动顺序。


通过容器使用的actor编程模型,可以自动解决这一问题,而我不必为此编写任何代码。


消息只是远程函数调用,并且保证容器一次只能处理一条消息。这导致简化的编程逻辑,我根本不用担心函数被并发调用。


因为容器状态仅在完全处理完一条消息后才保留(即,公共函数调用返回),所以我不必担心将内存刷新到磁盘,是否异常会导致磁盘状态损坏或与可靠性有关。


还应注意,持久状态更改的原子性是每条消息。


公共函数可以自由调用任何其他非异步函数,并且只要整个执行完成而没有错误,更改的状态就会保留(对于更新调用,下面提供更多详细信息)。


可以通过发出异步调用而不是同步调用来实现更精细的粒度,异步调用成为系统要计划而不是立即执行的新消息。


如果我要使用常规架构来构建该游戏,那么我可能也会选择一个actor框架,例如Java的Akka、Rust的Actix等。


Motoko提供本机actor支持,加入了基于actor的编程语言家族,例如Erlang和Pony。


更新通话与查询通话


我认为此功能确实可以改善互联网计算机应用程序的用户体验,它使它们与传统云平台托管的功能相提并论(与其它区块链相比,速度要快几个数量级)。


这也是一个简单的概念:任何不需要更改程序状态的公共函数都可以标记为“查询”调用,否则默认情况下被视为“更新”调用。


查询和更新之间的区别在于延迟和并发性:


  • 一个查询调用可能只需要几毫秒即可完成,而更新调用大约需要两秒钟。


  • 查询调用可以并发执行,并具有良好的可伸缩性,更新调用是按顺序进行的(基于参与者模型),它们提供了原子性保证。


就像上面的代码示例一样,我能够将view函数标记为查询调用,因为它只是查找并返回玩家正在玩的游戏状态。


实际上,在浏览网络的大多数时间里,我们都在进行查询调用:数据是从服务器中检索到的,但未经修改。


另一方面,上面的注册功能保留为更新调用,因为它必须在成功注册后将新玩家添加到玩家列表中。


由于数据一致性、原子性和可靠性等许多原因,更新调用将花费更长的时间。


但这不是互联网计算机固有的问题。


如今,网络上的许多操作实际上都需要两秒多的时间才能完成,例如,信用卡付款、下订单或登录银行帐户,仅举几例。


我认为两秒钟是良好用户体验的临界点。


回到逆向游戏,当玩家进行下一步行动时,它也必须是一个更新呼叫:



如果游戏仅在玩家单击鼠标(或触摸屏幕)两秒钟后刷新其屏幕,则它会感觉无响应,并且没有人会想要玩游戏时效如此之差。


因此,我不得不通过直接在客户端上对用户输入做出反应来优化这一部分,而不必等待服务器响应。


这意味着前端用户界面将必须验证玩家的移动,计算将翻转的棋子并立即在屏幕上显示它们。


这也意味着,无论前端显示给玩家什么,当它回来时,都必须使服务器对相同动作的响应与之匹配,否则我们可能会出现不一致的风险。


但是,我再次相信,多人双向或国际象棋游戏的任何合理实现都可以做到这一点,而不管其后端响应需要200毫秒还是需要2秒。


前端客户


DFINITY SDK提供了一种直接加载应用程序的浏览器中的前端。


但是,它与Web服务器提供的普通HTML页面不同。


与后端容器的通信是通过远程函数调用进行的,在浏览器的情况下,远程函数调用将覆盖在HTTP之上。


这是由JS用户库透明地处理的,因此JS程序只需将容器作为JS对象导入即可,并且可以像调用对象的常规异步JS函数一样调用其公共函数。


DFINITY SDK对如何设置一个JS前端一套教程,所以我不会详谈这里。


在后台,SDK中的dfx命令使用Webpack打包资源,包括JS、CSS、图像和您可能拥有的其它文件。


您还可以将自己喜欢的JS框架(如React、AngularJS、Vue.js等)与DFINITY用户库结合起来,以开发一个JS前端,以供在浏览器或移动应用中使用。


主要的UI组件


我对前端开发比较陌生,只对React有短暂的经验。


这次我自由地学习了Mithril,因为我听说了许多关于Mithril的好东西,尤其是它的简单性。


为了简单起见,我还提出了只有两个屏幕的设计:


  • 一个“播放”屏幕,允许玩家在进入“游戏”屏幕之前输入自己的名字和对手的名字。它还将显示一些提示和说明、顶级玩家图表、最近的玩家等等。


  • 一个“游戏”屏幕,接受玩家输入并与后端容器进行通信以渲染一个反向棋盘。它还将在游戏结束时显示玩家得分,然后将玩家带回到“游戏”屏幕。


下面的代码片段显示了JS游戏前端的框架:



有几件事要注意:


  • 就像其它任何JS库一样,导入了主要的后端容器reversi。可以将其视为将功能调用转发到远程服务器,接收答复并透明地处理必要的身份验证、消息签名、数据序列化/反序列化、错误传播等的代理。


  • 还将导入另一个reversi_assets容器。这是一种在安装后端容器时获取Webpack打包的必要资产的方法。在这种情况下,我有一个声音文件,当玩家放置新作品时将播放该声音文件。


  • 一个直接进入的标志形象。这必须在Webpack中使用url-loader进行配置,该工具实际上将图像的内容嵌入为要用于图像元素的Base64字符串。适用于小图像,但不适用于大图像。


  • 最终的应用程序使用Mithril通过两条路径/play和/game进行设置。后者将玩家和对手的名字作为两个参数,这样可以在不中断游戏的情况下将游戏屏幕重新加载到浏览器中。


从资产容器加载资源


因为我不熟悉在JS中异步加载DOM元素,所以我为此付出了一些努力。


当DFX建立罐,它也建立了一个reversi_assets罐,基本上只是打包一切的src / reversi_assets /资产/在里面。


我使用它来检索声音文件,但正确地加载它并不像将URL放置到HTML元素的src字段中的mp3文件那样直接。


这是我的加载方式(如果您是前端开发人员,您可能已经知道这一点):



当调用start函数(从异步上下文)时,它将尝试从远程容器检索文件“put.mp3”。


成功检索后,它将使用JS工具AudioContext解码音频数据并初始化全局变量putsound。


如果正确初始化了putsound,则对playAudio(putsound)的调用将播放实际的声音:



其它资源可以以类似的方式加载。我没有使用徽标以外的任何图像,徽标很小,可以通过将以下配置添加到webpack.config.js将其源代码嵌入到Webpack中:



数据交换格式


Motoko的概念是“可共享”的数据,即可以跨容器或语言边界发送的数据。


显然,我不会想象C中的堆指针是“可共享的”,但是对我而言,任何可以映射到JSON的东西都可以“共享”。


为此,DFINITY为互联网计算机应用程序开发了一种称为Candid的IDL(接口描述语言)。


Candid大大简化了前端与后端的通信方式或容器之间的通信方式。


例如,以下是Candid描述的后端可逆容器的一个(不完整)代码段:



以move方法为例:


  • 这是在容器的服务接口下导出的方法之一。


  • 它以两个整数作为输入(表示一个坐标),并返回类型为MoveResult的结果。


  • MoveResult是一个变体(又称枚举),表示玩家移动时可能出现的结果和错误。


  • 在MoveResult的各个分支中,GameOver表示游戏已完成,并且带有ColorCount参数,该参数代表游戏板上黑白棋子的数量。


Motoko源代码会自动为每个容器生成一个Candid文件,并由JS用户库自动使用,而无需开发人员的参与:


  • 在Motoko方面,每种Candid类型都与Motoko类型相对应,每种方法都与公共功能相对应。


  • 在JS端,每种Candid类型都对应一个JSON对象,每种方法都对应于导入的容器对象的成员函数。


大多数Candid类型都具有直接的JS表示形式,有些则需要一些转换。


例如,nat在Motoko和Candid中都是任意精度的,在JS中它被映射到bignumber.js整数,因此必须使用n.toNumber()将其转换为JS本机数字类型。


我遇到的一个问题是Candid(以及Motoko的Option类型)中的空值。


它在JSON中表示为空数组[],而不是其本机null。这是为了区分我们具有嵌套选项的情况,例如Option <Option <int >>:



Candid非常强大,尽管从表面上看它听起来很像Protocolbuf或JSON。


那么为什么有必要呢?


除了此处介绍的内容外,还有很多很好的理由,我鼓励对此主题感兴趣的人阅读Candid Spec。


将游戏状态与后端同步


如前所述,我使用了一个技巧来立即对有效的用户输入做出反应,而不必等待后端游戏服务器做出响应。


这意味着前端在玩家移动后只需要来自游戏服务器的确认(或者,如果有的话,需要进行错误处理)。


除了发送自己的动作外,客户还必须了解另一位玩家的动作。


这是通过定期调用服务器端托管的游戏容器的view()函数来实现的。


这种设计的含义是我必须在后端(Motoko)和前端(JS)中重复一些相同的游戏逻辑,这并不理想。


由于Motoko可以编译为Wasm,并且Wasm可以在浏览器中运行,因此,如果前端和后端都可以共享实现核心游戏逻辑的同一Wasm模块,那不是很好吗?这种共享仅共享代码,而不共享状态。


它可能需要一些设置,但我认为这是完全可能的,我可能会在以后的更新中尝试一下。


特别是对于逆向游戏,在某些情况下,一个玩家可能被阻止采取任何行动,因此另一名玩家可以进行两次连续行动甚至更多次。


为了显示玩家的每一个举动,我选择将游戏状态实现为一系列动作,而不仅仅是游戏板的最新状态。


这也意味着通过将前端本地状态下的动作列表与调用view()函数所返回的内容进行比较,我们可以轻松地知道自从玩家进行最后一个动作(轮到该玩家进行下一步)以来发生了什么变化,等等。


SVG动画


使用可伸缩矢量图形(SVG)进行动画的主题可能不属于本文,但有一次我真的因为这个而陷入困境。


因此,我想分享我学到的教训。


我遇到的问题是,当我使用repeatCount设置仅显示一次动画时,动画无法启动。


SVG上的大多数在线资源仅提供<animate>的示例,该示例可以无限重复或使用repeatCount设置。


他们隐式地假设,如果动画只显示一次,则在页面加载后(或设置了一些延迟)开始动画。


但是,对于大多数一页的应用程序框架(如React或Mithril)而言,通常不会重新加载页面,而只是重新呈现页面。


因此,当我想显示一个游戏片段从白色翻转为黑色或从黑色翻转为白色时,它必须在页面重新呈现时发生,而不是在页面重新加载时发生。


我错过了这个关键的区别,只是在尝试了很多次之后才发现了它。


因此,这就是我如何使用Mithril 渲染动画元素(作为SVG的子元素)的方法,其中椭圆的rx从初始半径更改为0并返回。



解释如下:


  • begin设置为不确定,以便可以手动控制/开始动画

  • fill设置为冻结,表示动画结束后,其结束状态将保持不变

  • 值设置为4个值,其中重复前两个作为技巧,以在延迟0.1s(dur的 1/4 )后开始动画,这是因为begin已设置为indefinite


要点是动画应手动启动。我使用setTimeout以0s延迟触发它,这是一种等待直到Mithril准备的新UI元素在浏览器DOM中呈现的技巧:



上面说过,任何ID不以“点”开头的动画元素都将立即启动。


开发流程


我在Linux上开发了该游戏,初始设置包括安装DFINITY SDK并按照其说明创建项目。


记住所有dfx命令行很麻烦,因此我制作了一个Makefile来提供帮助。



调试和测试主要在浏览器中完成,因此需要大量console.log()。


实际上,在Motoko中有一种编写单元测试的方法,但是我只有在编写游戏后才了解它。


最初,我还使用Shell脚本和dfx开发了基于终端的前端。


我认为这有助于加快调试速度,而无需通过浏览器。


但是,当然,单元测试是确保正确性的更好方法。


玩游戏!


为了在互联网计算机上实际运行此游戏,现在有一个开放给第三方开发人员的钨网络。


我鼓励您注册,克隆此项目并自己部署游戏,以获得第一手的开发人员经验。


但是非开发人员无法访问钨上的应用程序,因为它尚未公开。


因此,我也使用dfx和nginx作为反向代理自己托管了它,以便我可以邀请朋友一起玩。


我不鼓励人们自己执行此操作,因为该软件仍处于Alpha阶段。


这是实际游戏的链接,仅用于演示目的。我的计划是在今年晚些时候启动后,将其部署在公共互联网计算机网络上。


如有任何疑问,请随时访问项目存储库并提交问题,也欢迎提出请求!


现在通过dfinity.org/tungsten申请访问互联网计算机的钨版本。


加入我们的开发人员社区,并在forum.dfinity.org上开始构建。



作者:Paul Liu(DFINITY)

翻译:Catherine



Demonstration of Motoko

Dfinity向开发人员开放其互联网计算机

互联网计算机的神话般的项目会不会见光了?

Dfinity向外部开发商开放平台,推出去中心化的TikTok竞争对手



进Dfinity官方社群,请添加小助手微信:

comiocn




长按关注

Dfinity官方微信

给你第一手资讯和项目信息

更可随时答疑解惑



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存