jjzjj

[教你做小游戏] 用86行代码写一个联机五子棋WebSocket后端

HullQin 2023-03-28 原文
大家好,我是公众号「线下聚会游戏」作者HullQin,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。

背景

上篇文章《用177行代码写个体验超好的五子棋》,我们一起用177行代码实现了一个本地对战的五子棋游戏。

现在,如果我们要做一个联机五子棋,怎么办呢?

需求分析

首先,我们需要一个后端服务。2个不同的玩家,一起连接这个后端服务,把要下的棋告诉后端,后端再转发给另一个玩家即可。当然,如果有观战的,也要把当前期局转发给观战者。

此外,为了让2个玩家联机,还需要有「房间号」的概念,只有同一个房间的人才能联机对战。不同房间的人互不影响,允许同时有多个房间的人同时玩游戏。

流程

整个通信流程是这样的:

  1. 玩家A请求进入房间1。玩家A会执黑棋。
  2. 玩家B请求进入房间1。玩家B会执白棋。此时人已满,其他人进入将观战。
  3. 玩家C请求进入房间1。玩家C是观战者。
  4. 玩家A请求下棋,告诉坐标给服务器。
  5. 服务器通知玩家B、玩家C,告诉大家A下棋的坐标。
  6. 玩家B请求下棋,告诉坐标给服务器。
  7. 服务器通知玩家A、玩家C,告诉大家B下棋的坐标。
之后循环4-7步骤。

为了简化后端逻辑,把逻辑判断都放在前端。例如在前端判断是否游戏结束(五联珠),如果游戏结束,前端不允许再发任何请求。

技术选型

协议与方案

因为涉及到服务器主动给用户发送数据,所以有几种可选方案:

  • Http轮询:若在等待对方下棋,则前端每隔1s就发送一条请求,看看对方是否下棋。
  • Http长轮询:若在等待对方下棋,则前端每隔1s就发送一条请求,看看对方是否下棋。但是后台不会立即返回结果,要等到接口超过某个时间才返回结果。
  • WebSocket:建立好浏览器、服务器的连接,可随时主动向浏览器推送数据。
这里我们选择WebSocket,因为这种场景下Http协议确实有很大的资源浪费。而WebSocket虽然实现起来有点难度,但是节约了资源。

具体实现方案

只要某个编程语言/框架可以支持WebSocket就可以。

因为我以前经常用Django,用过Channels,对它的底层依赖daphne有所了解,所以我直接选择了daphne。它是ASGI标准的一种实现。

daphne是一个非常轻量的选择,不像Django+Channels这套框架提供了很重的解决方案。daphne只提供了基础的ASGI实现,没有其它冗余的功能。就好比:我开发五子棋前端时,使用了SVG + Dom API,没有用React框架一样。

开发

基础知识

daphne要求我们以这样的格式定义一个服务:

# server.py async def application(scope, receive, send): # 处理websocket协议 if scope['type'] == 'websocket': # 先接收第一个包,必须是建立连接的包(connect),否则拒绝服务 event = await receive() if event['type'] != 'websocket.connect': return # 校验通过,发送accept,表明建立ws连接成功 await send({'type': 'websocket.accept'}) # 此后双方可以互相随时发消息。开启个无限循环 while True: # 接收一个包 event = await receive() # 如果是断开连接的请求,就结束循环 if event['type'] == 'websocket.disconnect': break # 这种方式可以读取包的文本内容 data = event['text'] # 这种方式可以发送一个包给浏览器,这里是把浏览器发来的包原封不动传回去 await send({'type': 'websocket.send', 'text': data}) 运行方法:

pip install daphne daphne -b 0.0.0.0 -p 8001 server:application

业务开发

我们需要定义一个房间集合,称之为house

house = {} 编写玩家初次连接(进入房间)的逻辑:

import json async def application(scope, receive, send): if scope['type'] == 'websocket': event = await receive() if event['type'] != 'websocket.connect': return await send({'type': 'websocket.accept'}) # 建立连接后,要求前端发送一个EnterRoom事件,以json格式提供用户id和房间号room event = await receive() data = json.loads(event['text']) if data['type'] != 'EnterRoom' or not data['id'] or not data['room']: # 若前端发送的第一个事件不是这个,就报错,断开连接 await send({'type': 'websocket.close', 'code': 403}) return room_id = data['room'] user_id = data['id'] # 看看房间号是否在house内,不在则创建一个room if room_id not in house: house[room_id] = { 'black': None, 'white': None, 'pieces': [], 'sends': [], 'users': [], } room = house[room_id] old = False # 看玩家是不是老玩家(断线重连进来的) if room['black'] == user_id or room['white'] == user_id: old = True if user_id in room['users']: old_send = room['sends'][room['users'].index(user_id)] room['sends'].remove(old_send) room['users'].remove(user_id) await old_send({'type': 'websocket.close', 'code': 4000}) else: # 说明玩家是第一次进,给他拿黑棋或白棋 if room['black'] is None: room['black'] = user_id elif room['white'] is None: room['white'] = user_id # 如果玩家没拿到黑棋也没拿到白旗,就是观战者 visiting = room['black'] != user_id and room['white'] != user_id # 把玩家的send函数存到room里,方便其他玩家下棋时调用,从而广播下棋事件 room['sends'].append(send) # 把玩家ID存进去 room['users'].append(user_id) 玩家进入房间后,我们需要给他通知一下这个房间的基本信息,例如是否已经开始了?当前场上的期局是怎样的?

await send({'type': 'websocket.send', 'text': json.dumps({ 'type': 'InitializeRoomState', 'pieces': room['pieces'], # 场上棋子情况 'visiting': visiting, # 你是否是观战者 'black': room['black'] == user_id if not visiting else bool(len(room['pieces']) % 2), # 如果你在下棋:黑棋是你吗?如果你是观战者:黑棋是谁? 'ready': bool(room['black'] and room['white']), # 房间是否准备好开局了?只要有2个人同时在,就可以开了 })}) # 因为有人进入了房间,所以需要广播一下这个消息。 if not old and (room['black'] == user_id or room['white'] == user_id): for _send in room['sends']: if _send == send: continue await _send({'type': 'websocket.send', 'text': json.dumps({ 'type': 'AddPlayer', 'ready': bool(room['black'] and room['white']), })}) while True: event = await receive() # 有人断线了,处理一下。若房间空了,还要删掉房间,以防内存占用无限增大 if event['type'] == 'websocket.disconnect': if send in room['sends']: room['sends'].remove(send) room['users'].remove(user_id) if len(room['pieces']) == 0 and len(room['sends']) == 0: del house[room_id] break # 有人发送了事件,接收一下 data = json.loads(event['text']) # 如果是下棋事件,就改一下room的pieces数据,并广播给大家 if data['type'] == 'DropPiece': room['pieces'].append((data['x'], data['y'])) for _send in room['sends']: if _send == send: # 不需要给自己通知,所以跳过自己 continue await _send({'type': 'websocket.send', 'text': json.dumps({ 'type': 'DropPiece', 'x': data['x'], 'y': data['y'], })}) 当然,写好这些后,还需要测试,最好直接写好前端一起联调。我们下篇文章把前端的WebSocket逻辑补充一下。

完整源码

包含了前后端源码(总共不到400行): https://github.com/HullQin/gobang

是一个非常值得学习的关于WebSocket的demo。

写在最后

我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

有关[教你做小游戏] 用86行代码写一个联机五子棋WebSocket后端的更多相关文章

  1. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

    如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

  2. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  3. ruby-on-rails - Rails 源代码 : initialize hash in a weird way? - 2

    在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has

  4. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  5. ruby-on-rails - 渲染另一个 Controller 的 View - 2

    我想要做的是有2个不同的Controller,client和test_client。客户端Controller已经构建,我想创建一个test_clientController,我可以使用它来玩弄客户端的UI并根据需要进行调整。我主要是想绕过我在客户端中内置的验证及其对加载数据的管理Controller的依赖。所以我希望test_clientController加载示例数据集,然后呈现客户端Controller的索引View,以便我可以调整客户端UI。就是这样。我在test_clients索引方法中试过这个:classTestClientdefindexrender:template=>

  6. ruby-on-rails - 如果 Object::try 被发送到一个 nil 对象,为什么它会起作用? - 2

    如果您尝试在Ruby中的nil对象上调用方法,则会出现NoMethodError异常并显示消息:"undefinedmethod‘...’fornil:NilClass"然而,有一个tryRails中的方法,如果它被发送到一个nil对象,它只返回nil:require'rubygems'require'active_support/all'nil.try(:nonexisting_method)#noNoMethodErrorexceptionanymore那么try如何在内部工作以防止该异常? 最佳答案 像Ruby中的所有其他对象

  7. ruby - 为什么 SecureRandom.uuid 创建一个唯一的字符串? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭8年前。Improvethisquestion为什么SecureRandom.uuid创建一个唯一的字符串?SecureRandom.uuid#=>"35cb4e30-54e1-49f9-b5ce-4134799eb2c0"SecureRandom.uuid方法创建的字符串从不重复?

  8. ruby-on-rails - Rails - 从另一个模型中创建一个模型的实例 - 2

    我有一个正在构建的应用程序,我需要一个模型来创建另一个模型的实例。我希望每辆车都有4个轮胎。汽车模型classCar轮胎模型classTire但是,在make_tires内部有一个错误,如果我为Tire尝试它,则没有用于创建或新建的activerecord方法。当我检查轮胎时,它没有这些方法。我该如何补救?错误是这样的:未定义的方法'create'forActiveRecord::AttributeMethods::Serialization::Tire::Module我测试了两个环境:测试和开发,它们都因相同的错误而失败。 最佳答案

  9. ruby-on-rails - 浏览 Ruby 源代码 - 2

    我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru

  10. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

随机推荐