我是一名软件工程师,已经写代码超过15年了。很长一段时间以来,我一直想创造一个沉浸式的奇幻世界,让你可以找到冒险和结交新朋友的机会。我有一个核心愿景,汇集了我过去玩过的所有游戏的机制和我喜欢的机制,以及一些我以前从未见过的机制。
但由于制作大型多人游戏是一个大任务,我一直在不断地劝说自己不要做它。另外,即使我是一个好工程师,我也没有任何关于艺术、音乐、3D模型等方面的想法。
因此,为了教育目的,让我们“讨论”一下制作大型多人游戏需要做些什么。
我们的假想项目使用 C#,使用一个专门的C#控制台应用程序作为服务器和 Godot作为前端渲染引擎来渲染客户端。
核心网络
在开始之前,我将假设你能够在两台计算机之间建立连接,并使用 LiteNetLib 或类似的方式发送字节。我们首先要建立一个C#库,包含共享类和数据结构。然后我们将使用一个C#控制台应用程序作为服务器。最后,我们将使用一个C#项目作为客户端,这是使用Godot构建的。
由于我们正在制作一个 Server Authoritative游戏,我们需要所有客户端都连接到服务器,服务器将负责与客户端通信。所有角色和对象都由服务器拥有,客户端只能发送输入命令来移动自己的角色。
当我们发送包到服务器或客户端时,我们需要确定我们发送什么。根据我们发送的数据类型,我们可能需要在机器上调用不同的函数。
为了实现这一点,我们将实现一个简单的远程过程调用(RPC)。在发送数据之前,我们将在第一个字节中预先添加一个唯一的函数ID。这将告诉客户端应该调用哪个函数。
在客户端中,我们可以在字典中注册函数。其中函数ID作为键,回调函数(callback函数)作为值。当客户端接收到网络包时,我们可以查看第一个字节,检查它是否存在于字典中,如果存在,则调用相应的回调函数。
我们需要在服务器端做同样的事情,注册所有回调函数,然后才能接收来自客户端的网络包。
\[ 1字节:函数ID \] \[ 剩余字节:参数/负载 \]
网络身份
我们可以发送数据之间的服务器和客户端,但如果我们在服务器上创建一个对象,那么我们如何让客户端也看到同一个对象呢?
首先,我们需要创建一个网络身份,一个唯一的ID来标识每个网络对象。什么是网络对象?它是一个可见于所有客户端和服务器的对象。如果服务器移动了该对象,那么它也应该移动到客户端。
当我们在服务器上创建一个网络对象时,我们需要首先为它分配一个唯一的网络ID,然后将该ID发送给客户端。这样客户端也可以创建同一个对象,给它相同的网络ID。
每当客户端或服务器通信时,他们都会发送彼此的网络ID,以便他们可以引用哪个对象的消息是指的。
设置位置为(1, 4, 2)的对象,其网络ID为2
通过网络身份,我们可以更新对象并同步它们从服务器和客户端。然而,如果你只是发送整个对象转换为字节或JSON,那么你可能正在浪费大量带宽。
优化!
序列化
首先,我们需要有一个高效的方式来将我们的对象转换为字节数组。使用C#最简单的方式是使用MemoryPack库。
但是,即使使用MemoryPack库,如果我们只将对象转换为字节,我们也没有做得很好。这是因为不是每个对象都需要在每个帧或每个tick中更新。我们应该只发送那些变化的信息,而不是客户端不需要的信息。
我们可以通过设置一个自定义泛型类型来实现这一点。
public class NetworkField<T>
{
private T _value;
public T Value
{
get => _value;
set
{
_value = value;
isDirty = true;
}
}
public bool IsDirty { get; set; } = false;
}
这个类如何工作?实际值位于_value中。当你使用setter设置_value时,它会标记NetworkField为脏。我们可以使用这个IsDirty属性来过滤出那些没有变化的字段。
记住在发送所有脏字段到客户端后将IsDirty设置为false。
你可以进一步优化并使用NetworkArrayField来处理多个项和长度。只发送客户端需要修改的信息。
角色移动
我们可以终于开始讨论角色移动了。几乎所有的对象都需要在服务器端拥有权力,这意味着服务器是拥有者,而客户端不能直接修改对象(即使是自己的角色)。
当用户想要移动角色时,他们应该将输入从设备(键盘、鼠标)发送到服务器。然后服务器负责处理输入并移动角色。
你可能会想,如果我们发送输入并等待服务器更新角色位置,那么这不会造成巨大的延迟吗?你是正确的!
客户端预测
为了解决这个问题,我们可以在客户端本地进行相同的运动。然后,当我们收到来自服务器的信息时,我们可以覆盖位置以匹配服务器位置。
线性插值
现在我们已经解决了前面的问题,但又引发了一个新问题。我们的角色会在按下按钮时瞬间移动,但在收到来自服务器的更新时会突然跳动。
为了解决这个问题,让我们让角色始终平滑移动到新位置,使用线性插值而不是突然跳动。
速度外推
我们可以进一步优化。如果我们让服务器发送每个角色速度,我们可以使用速度来让角色继续移动,而不是等待下一个服务器消息。然而,这可能会导致角色在收到来自服务器的信息时跳动。
角色位置已经同步了,但我们忘记了角色旋转。让我们对旋转使用相同的方法。
我们完成了吗?很遗憾,我们还没有完成。这个架构可能可以在少数玩家之间工作,但最终会遇到一个限制:我们需要发送大量数据到每个客户端关于每个客户端,这将使游戏变得不可玩。
网格系统与区域感兴趣(AOI)
要将我们的游戏从在线游戏转变为大型多人游戏,我们需要只发送客户端可以看到的网络对象。没有必要发送一个角色位于另一洲时的信息。这被称为区域感兴趣(AOI)。
这个问题在使用顶视角的游戏中更容易实现。但是,如果你使用第三人称视角,你可能会遇到其他挑战,并且会有一定程度的“弹出”现象。
在我们可以实现AOI之前,我们会遇到另一个问题。如果服务器检查每个角色和每个玩家的距离以确定该玩家是否足够接近,那么这将导致服务器效率降低。
使用网格系统可以解决这个问题。每个网络对象都需要位于一个网格内的单元格中。然后我们可以询问网格,给出玩家附近的所有对象。这样我们只需要遍历附近的单元格,而不是遍历整个世界。
我们如何将一个位置转换为一个单元格?这取决于单元格的大小。你可以简单地将位置除以单元格的大小,然后取向下取整。
Math.Floor(position.X / Configuration.GridCellSize)
当你需要查询玩家可以看到的对象时,你可以获取玩家位于的单元格,减去和加上范围来获取附近的所有网络对象。
但是,这个解决方案又引发了一个问题。如果有10个角色近乎彼此,我们可能会将数据序列化10次以发送给每个角色。
正确的方法是将所有网络对象序列化,然后使用这些序列化的字节来多次发送,而不是每个角色发送一次。
创生和摧毁
我们的网格系统和AOI又引发了一个新问题。现在当角色移动到一个网络对象时,网络对象似乎是被冻结在那里,因为客户端不再接收任何与该对象相关的数据。
为了解决这个问题,我们需要在客户端发送一个摧毁消息到客户端,当玩家与网络对象足够远时。然后,当玩家靠近该对象时,我们应该发送一个创建消息,包含该对象的全部信息(包括脏字段)。
我的方法是创建一个对象的差异。我们需要将上次发送给客户端的消息与当前消息进行比较。
如果当前消息包含新的对象,那么我们需要发送一个创建消息给这些对象。
如果当前消息不包含之前消息中包含的对象,那么我们需要发送一个摧毁消息给这些对象。
但我们有所有网络对象的序列化,包括脏标志吗?是的,由于这个原因,我们需要序列化所有网络对象两次:一次只包含脏字段,另一次包含所有字段。
批处理
恭喜你已经阅读到这里!现在我们可以讨论路由器如何将网络包发送到其他机器。
大多数路由器会将包分割成1400字节以下的包。因此,我们将确保所有我们的包都小于1000字节。
然而,如果我们发送非常小的包,那么我们会浪费性能,因为每次发送包时都会有一个小的开销。更好的方法是批处理多个数据。
服务器主要发送网络对象的更新给客户端。因此,下一步是批处理网络对象,而不是发送一个网络对象一个包。
冲突
如果你想用你的引擎的物理系统,那么你可能还不够想象大型多人游戏。
大多数游戏引擎的物理系统复杂,服务器运行物理系统时会导致显著的延迟。
如何解决这个问题?我们可以自己编写一个基本的碰撞系统。我们已经有一个网格系统。我们可以询问网格,给出每个玩家附近的所有对象。
然后我们可以使用这些信息创建一个圆柱体。然后如果玩家太近,我们可以移动玩家和网络对象。
predictedPosition = character.Position + character.Velocity * delta;
foreach (var target in results)
{
// 如果网络对象是当前玩家,则跳过
if (character == target) continue;
var deltaPosition = target.Position - predictedPosition;
var distanceSq = deltaPosition.LengthSquared();
if (distanceSq <= 1.0f)
{
float distance = MathF.Sqrt(distanceSq);
if (distance == 0) continue;
float overlap = 1.0f - distance;
Vector2 normal = deltaPosition / distance;
// 将玩家向后移动 0.5 个重叠
predictedPosition -= normal * (overlap * 0.5f);
}
}
character.Position = predictedPosition;
由于这可能需要一些时间,我们将跳过墙壁和建筑物。我们可以在将来的文章中讨论这些问题。
但你可能注意到另一个问题。我们在网格系统中询问每个玩家时,将会导致两个问题:一次是发送网络对象的更新,另一次是碰撞系统。
为了解决这个问题,我们可以询问网格系统给出每个玩家附近的所有对象,然后缓存结果。
最后的话
有一个需要注意的事项:在服务器上有很多循环,循环遍历每个玩家。大多数这些循环可以并行执行,如果正确执行的话。
另一个优化方法是限制某些系统的执行频率。例如,我们可以每两帧执行一次网格系统,而不是每帧。
希望阅读这篇文章的人能够学到如何优化他们的大型多人游戏。
感谢你阅读这篇文章!
评论 (0)