关于网络游戏架构聊天功能那些事儿

2016年08月02日 18:05 0 点赞 0 评论 更新于 2025-11-21 19:16
关于网络游戏架构聊天功能那些事儿

玩过联网游戏的小伙伴们,想必都在游戏里聊过天。下面我们就来深入探讨一下网络游戏架构中聊天功能的相关技术细节。

一、世界喊话

一般简单聊天室的实现方式是,玩家发送一条消息后,服务器将其广播给所有在线玩家,就好像大家都在一个屋子里,能看到彼此的发言。很多大学或专科学生都实践过这类功能。

这种聊天室的工作模式可以用下图表示(此处可插入对应图片)。实现该功能时,服务器只需在收到消息后,将其分发到所有客户端。服务器上仅需维护一张全局用户表。

有了聊天功能,游戏中的玩家终于可以交流了,不过这种世界喊话模式比较“公开”,缺乏隐私性。

二、密聊

如果游戏里只有世界喊话,小情侣们私密的话题就会被所有人看到,这显然不太合适。因此,除了公共聊天,还需要密聊功能。

密聊的特性是聊天对象明确,是玩家 A 到玩家 B 的单向消息传递。服务器在转发这类消息时,无需遍历所有玩家,只需找到特定的接收玩家并将消息发送过去即可,实现难度不大。

三、小队频道

随着游戏玩家数量增多,公共聊天容易出现刷屏现象。为了解决这个问题,我们可以为不同目的的玩家划分频道。例如,玩家 A 召集 5 个小伙伴组成小队去打 boss,此时聊天室中就引入了“频道”的概念。

频道与全局聊天的用户列表不同,频道内只有有限的几个玩家,本质上就是一个玩家列表。给频道内的玩家发消息,只需循环遍历频道列表即可,实现起来并不复杂。

要实现小队频道功能,首先要在服务器上动态创建消息容器。当两人以上完成组队时,可用队伍 ID 作为队伍频道的标识符。这相当于在游戏聊天服务器中创建了多个小聊天室。队伍中的人发送队伍消息时,带上频道 ID(即队伍 ID),不同的队伍拥有不同的小聊天室,从而完美解决频道聊天问题。

四、InBox

随着游戏聊天系统逐渐复杂,出现了全局喊话、密聊、频道聊天等多种聊天方式,不同频道还存在加入和退出的情况。此时,需要为消息传递设计一种有序的工作方式。

喊话的玩家将消息发送到公共聊天 InBox,公共聊天 InBox 再将消息转发给所有玩家;队伍聊天时,玩家把消息发送到自己队伍的 InBox,队伍 InBox 再将消息转发给队伍里的玩家。

五、本地聊天

在游戏中,我们在城里时能看到很多玩家聊天,但远离城市去做任务时,收到的聊天信息就会减少。当周围只有自己一个人时,聊天框里通常只有战斗信息。这并非是别人没说话,而是我们收不到他们的聊天信息,这就是本地聊天的作用。

另一种本地聊天情况是,离我们较近的人说话能看到,距离足够远时则收不到消息。本地聊天类似于现实中我们的听觉范围,离得太远就听不到别人说话。

实现本地聊天,只需发送消息的人每次确定一个范围,并将消息发送给该范围内的其他玩家。这实际上就是筛选距离符合条件的玩家,可通过计算两个聊天人之间的距离来实现,这里会用到初中的直角三角形计算公式。

假定接收消息的半径是 R,那么位于蓝点位置的玩家能否接收到红点位置玩家发出的消息,就转化为计算红蓝两点之间的距离是否大于 R 的算术问题。

六、3D 下任意两个点之间的距离

在二维情况下,我们可以用圆圈表示消息接收范围;在 3D 情况下,则需要用球来表示。每个玩家对应一个球,多个玩家就有多个球。考虑 3D 模式是因为两个玩家即使在同一平面坐标上,但由于高度不同,彼此距离过远也可能收不到对方的消息,否则 3D 模式下就成了圆柱。

在 3D 情况下计算任意两点之间的距离较为复杂。任意两点可看成盒子的两个对角线,求两点距离就是求两点所处平面的直线距离。为了得到这个平面,需要将盒子“切割”成一个三角形(可插入对应示意图)。整个计算过程会涉及两次直角三角形计算。

假设红点坐标为 {x1,y1,z1},蓝点坐标为 {x2,y2,z2},第一步先计算红绿之间的距离(此处可插入计算红绿距离的公式)。接下来在奶酪斜面上计算斜边边长,同样使用直角三角形计算公式。最后将两个公式合并并简化,就可以编写一个函数来计算长度。

七、计算量的挑战

到目前为止,似乎所有问题都已解决。但回顾 InBox 的工作逻辑,当有 1000 个玩家同时在线,其中一人发言,就需要计算该玩家与所有在线玩家的距离,即 1000 次计算。如果 1000 个人都发言,计算量将达到 1000 * 1000 = 100 万次。若说话频率增加,或者在线人数增多(如 3000 人、万人在线),计算量将呈指数级增长。

虽然可以控制一个人的说话频率,但这种计算量的增长是指数级的,单独控制一个变量无法解决问题。因此,需要从发送消息的角度进行控制。

八、用空间换时间

前面方案的最大问题是计算量过大,是否有办法避免大量计算呢?

我们假定地图是二维平面的,将大的地图网络划分成许多方块,根据玩家坐标将其归类到某一个格子里(可插入对应图片)。为每个格子打上 ID,并创建专属的地图频道,这样地图上的每个位置都对应一个唯一的地图区域聊天频道。

玩家发出的聊天消息只需发送到特定格子的频道,其他玩家接收自己所处地图频道内的聊天信息即可。这样就无需遍历全部玩家列表,也不用计算玩家距离,只需向特定频道发消息。

九、画格子

游戏地图有自己的坐标系统,在 3D 游戏中,玩家坐标用 x、y、z 三个量表示。由于聊天频道基于二维平面计算,忽略玩家所处高度后,3 维坐标就变成了所需的二维坐标。

若为地图上每一个坐标都创建一个频道,玩家只有站在同一点时才能收到彼此的消息,这不符合实际需求。因此,需要将 100 100 的地图坐标映射到 3 3 的频道网格里。

假设玩家位于蓝色地图区块内说话,其地图坐标为 (5, 5),聊天频道区块的坐标为 (2, 2)。我们需要一个函数,将用户坐标转换为频道网格坐标。可把两个二维网络看作比例尺不同的地图,找出它们的比例关系,将玩家实际坐标与比例关系相乘,再进行“Math.ceil”取整,得到格子坐标 (2, 2),然后将消息发送到该格子的频道,其他在该频道的玩家就能收到消息。即使有 100 万人在线,也无需计算距离,可最大程度提高 CPU 效率。

9.1 - 3D 下的格子

3D 下画格子看似复杂,实则类似于两个魔方套在一起,计算方式和坐标转换函数与 2D 情况相同。例如,消息格子和地图之间 x、y、z 轴的比例关系均为 3:9 = 1:3,可得到近似转换坐标 (0.33, 0.33, 0.33)。玩家实际坐标 (5, 5, 5) 乘以转换坐标得到 (1.65, 1.65, 1.65),取整后得到格子坐标 (2, 2, 2)。若玩家 z 坐标变为 2,计算后 z 格子坐标为 1,玩家就“跑到”盒子的上面去了。

9.2 - 真的就解决问题了么?

从微观角度重新审视本地聊天系统,会发现存在一些问题。例如,红蓝两个玩家距离接近但处于不同格子,蓝色玩家发的消息红色玩家可能收不到;绿色玩家和蓝色玩家距离比蓝色和紫色玩家近,但绿色玩家却收不到消息。

为解决这些问题,发消息时需要将消息发送到周边的格子。在 3D 情况下,可想象 3 3 3 的格子方阵,原理相同。

9.3 - 处理玩家移动

玩家会移动,这意味着他们的聊天频道会随玩家移动而变化。因此,地图频道需要具备进入频道、离开频道的基本功能。玩家进入一些格子时,必然会离开一些格子。

9.4 - 还可以更完美么?

声音按直线传播且会逐渐衰减。假定一个人讲话,能听到其讲话的距离为 20 米,那么以讲话人为中心,20 米范围内的人都能听到,超过这个距离则听不到。

我们对比格子模型和真实声音传播模型(可插入对应图片),会发现格子模型存在一些不合理之处。为使系统更完美,可通过画圆来解决。已知消息发送最大半径为 3 个单位,以消息发送者为圆心,半径为 4 个单位(直径 7 个单位)画圆。找到圆圈所在正方形的四个顶点,循环矩形内的每个点,与中心点计算距离,排除超过半径的格子,即可得到最终希望收到消息的格子。

若觉得 7 * 7 的格子圆不够光滑,可提升半径数以增加圆的光滑度。例如,喊话半径为 100 个单位,每次喊话的计算量为 w = 201,h = 201,约 4.04 万次。实际上,半径为 50 时效果就已经不错了。

9.5 - 追求极致性能

目前定义的格子模型和地图坐标系统是静态关系,格子之间的关系固定。若整个世界遵循消息最大传播半径 100 的定律,可预先为每个格子进行计算,并将计算结果作为格子的属性保存在内存中。若该配置是服务器的基础配置,还可将计算结果保存到磁盘,服务器启动加载格子数据时无需再进行计算,从而实现效率最大化。

十、格子画多少比较合适?

画格子的数量需要考虑系统承载力的上限。我们可以通过“假定 -> 计算 -> 调整”的方法,得到一个较为合理的数值。

10.1 - 地图大小

假设游戏有一张很大的地图,玩家每秒移动 1 米,从最东边跑到最西边需要 24 小时,那么游戏地图长度为 86400 米,换算成面积约为 7464.96 平方公里。游戏设计者可根据需要缩小地形数据,使用小数表示地图坐标以提高精度。7000 多平方公里的地图资源通常足够,不够时还可增加。

10.2 - 消息格子数目

格子尺度越小,格子数越多。格子总数 n 可表示为 n = (x / a) * (y / a) = xy / a ^ 2(n 为最终格子数,x 为 x 轴地图尺度,y 为 y 轴地图尺度,a 为格子划分尺度)。假设 x = y,则 n = x ^ 2 / a ^ 2。随着地图尺度增加,格子数增长迅速(可插入函数曲线,函数曲线生成地址:http://www.archimy.com/ ,脚本如下:

z = 0
xgrid = 86400
a = 1
y = (x ^ 2) / a ^ 2

调整比例关系 a,重新对比格子数增长情况。以 86400 米见方的地图为例:

  • 1 米单位:7464960000 个格子(86400 * 86400);
  • 3 米单位:829440000 个格子[(86400 / 3) * (86400 / 3)];
  • 10 米单位:74649600 个格子[(86400 / 10) * (86400 / 10)];
  • 20 米单位:18662400 个格子[(86400 / 20) * (86400 / 20)];
  • 50 米单位:2985984 个格子[(86400 / 50) * (86400 / 50)]。

10.3 - 格子占用内存空间

计算不同格子数量所需的内存空间:

  • 7464960000 个格子,需用 8 个字节(long 类型)存储,共需 55.61G 空间,该方案不可行;
  • 829440000 个格子,用 4 个字节(int 类型)存储,约需 3.08GB 空间,也不可行;
  • 74649600 个格子,约需 284.76MB 数据,可接受;
  • 18662400 个格子,约需 71.19MB 数据,较为合适;
  • 2985984 个格子,约需 11.39MB 数据,是不错的选择。

10.4 - 与周边格子关系数和存储

以玩家为中心,玩家说话影响到的格子数可通过圆的面积粗略计算。假设一公里地图长度,按 10 米单位切分,地图宽度为 100,以 100 为直径的圆圈内格子数可通过公式计算: [n = \pi \times (\frac{实际距离}{格子划分尺度})^2] 其中,n 为某个格子周边关系的近似数,实际距离和格子划分尺度的单位为米。

假设玩家说话能让周边 3 公里的人听到,使用 10 米单位划分格子,代入公式可得 n ≈ 282735,即玩家说话需与周边约 28.2 万个格子关联。若每个格子都进行这样的关联,将产生巨大的关联关系,该方案不可行。

降低格子数或消息范围,结果如下:

  • 1 公里:31415 个关联格子,约 3 万;
  • 500 米:7853.75 个关联格子,约 7800 多个。

若每个格子预先关联周边 1 公里的格子,使用 2 个字节(short 类型)表示关联格子数,这些关系共需约 74649600 31415 2 个字节,约 4368.12GB,不可行。

使用 20 米密度划分格子:

  • 3 公里:70683.75 个关联格子,约 7 万;
  • 1 公里:7853.75 个关联格子;
  • 500 米:1963.43 个关联格子。

重新计算存储空间:

  • 3 公里:约 4914GB,不可行;
  • 1 公里:约 273GB;
  • 500 米:约 68.2G。

使用 50 米密度划分格子:

  • 3 公里:11309.4 个关联格子,约 1.1 万;
  • 1 公里:1256.6 个关联格子;
  • 500 米:314.15 个关联格子。

存储大小:

  • 3 公里:约 62.9GB;
  • 1 公里:约 6.98GB;
  • 500 米:约 1.74GB。

10.5 - 不建议划分 3D 格子

前面讨论的是 2D 地图上的格子数据。若要实现立体范围的消息传递,有两种解决办法:

  1. 缩小消息传播尺度,同时增大格子单位。例如,将格子单位从 20 米提升到 100 米,地图模型为立方体,格子数为 (86400 / 100) ^ 3 = 644972544(约 6.44 亿个),用 4 字节 int 存储格子 ID 需约 2.4GB 空间。降低消息传播范围到 100 米,用球体积公式计算每个格子周边可能有关系的格子数近似值约为 4.18。但 6.44 亿个格子每个都有约 4.18 个临近对象,存储空间约需 10GB,加上格子 ID 存储空间,共约 12GB,成本过高。
  2. 不使用 3D 的格子模型,在格子转发消息时,对接收消息方的 z 轴做数值判断,通过“时间换空间”,因为同一格子上垂直方向高度密集分布玩家的情况在游戏中不太可能发生,画立体格子的代价过高。

10.6 - 回顾我们选择的参数

综合考虑,我们选择在方圆 7464.96 平方公里的土地上,画出大约 298.6 万个消息格子,平均每个格子记住周边 314.15 个临近的格子 ID。这种配置下,最少需要 1.74G 左右的内存,其中地图格子数据 11.39MB。当然,你可以根据需要自行调节地图尺寸、格子比例、传播半径这三个参数。

十一、看看具体性能如何?

玩家登录游戏服务器后,服务器根据玩家的游戏坐标找到其消息盒子,只需将玩家坐标乘以比例尺(0.2),再进行“Math.ceil”取整,得到格子坐标,此过程产生 1 次计算。

每个玩家都有一组状态数据,初次登录时可根据坐标预先算出玩家向四个方向移动多少米会触发格子更新事件。玩家每次移动只需发出移动消息,由逻辑处理器计算是否触发重新注册格子,每次移动产生 1 次计算。

进出格子的计算量方面,每个格子约有 314.15 个临近链接。玩家从一个格子跨到另一个格子时,需比对两个格子之间的差异,最少需做 314.15 * 314.15 ≈ 98690 次计算和判断。好在该计算量并非每次玩家移动都会触发。

假设单个大区服务器设定上线玩家数为 10 万人,考虑以下两种极端情况:

  1. 所有玩家聚集到一个格子里,每人说一句话。此时消息转发次数为 10 万人 * 10 万人,这种情况理论上不太可能发生,因为消息需发到每个人手中,且 10 万个人物对象同时渲染在一个屏幕里,显卡可能无法承受。
  2. 所有玩家平均分布在两个格子之间的临界点,然后匀速左右移动。这会导致服务器为每个玩家频繁计算 9 万次的进出格子判断。为解决这个问题,可设计一个专门保存玩家状态的服务器集群,20 台机器每台管理 5000 个在线用户,将计算量按用户维度分配到不同的服务器上。

关注“泰斗社区”,获取更多相关内容。