如何从0开始开发一个实时联机游戏?
这是一篇严肃的联机游戏开发入门介绍,本文所述代码开源,文末可获得地址。
关于游戏的实时联机对战,目前是很多游戏开发者研究课题,也延伸出了很多概念,如“状态同步”、“帧同步”,目前很多游戏开发框架也提供了这样的开发一些理念和组件,比如unity的The High Level API等等。
本文不希望传授如何用框架、服务端引擎等来搭建一套商业级的框架。而是希望从最基本的网络通信原理开始,一点点的进行朴素的分解和搭建,旨在从原理上概述联机游戏的设计思路以及抽象于计算机网络的通信框架如何设计和构建。
我个人编写这套DEMO包括完整的客户端和服务器,也是用于调研工作室游戏《汉家江湖》后续的实时联机部分如何搭建和开发,并且后续作为我们自己的一个实时联机通信框架的测试程序。
实际完成的DEMO如上,每个玩家是一个点,只有一个操作:使用左下的虚拟摇杆进行方向控制。在移动过程中不断喷射子弹,命中对方即可扣血,在规定时间内比谁的击杀的人数多。由于是一个简单的DEMO,我们不在美术上做太多东西(请原谅我的五毛钱PS技术),主要是为了讲解程序如何来设计。
工具部分,客户端我们使用unity开发,服务端我们直接从0开始写一个基于socket的服务器。
我们使用状态同步来做这个DEMO(理论上帧同步机制是更加适合这种IO类游戏的,我们为了逻辑清晰简单,先用状态同步来做,后续有时间我再补帧同步的方案。)
状态同步
什么叫状态同步?
简单的来理解就是所有的数据在服务端进行计算和校验,客户端将操作上发到服务器,服务器不断的告诉客户端计算结果,由客户端进行展现。
客户端逻辑结构
客户端的整体框架逻辑非常简单,unity是典型的单线程编程,我们只需要在FixedUpdate里出来所有的消息队列即可。
(点击上图,可放大查看)
其实按照socket的非阻塞模型来说,我们客户端也可以不使用接收线程而使用纯粹的单线程。我们在这里不再赘述,再次重申,我们只是很简单粗暴的一切为了编程简单易于理解。
客户端维护一个消息队列的原因是,我们每次收到的数据需要顺序的执行。在unity中我们所有改变UI或者界面相关的逻辑需要写在主线程中。所以我们使用一个消息队列来进行传递,每次FixedUpdate时依次处理所有的队列中的消息。然后处理客户端应该发送的指令。需要注意的是,由于消息队列被两个线程同时访问,作为临界区数据,需要加线程锁。
服务端逻辑结构
我们这里通信协议使用tcp(大家实现也可以使用udp,都类似),那么服务端是一个典型的处理连接、处理请求并分发数据的逻辑结构。我们为了编程简单,也先不顾效率的每一个客户端连接我们起一个专门的接收线程,然后统一分发处理,逻辑结构如下:
(点击上图,可放大查看)
使用单线程来处理整个游戏的主逻辑,是一个典型朴素且最简单的编程思想。当然,这里可能会存在瓶颈问题,所以具体中间有很多可以优化的点,比如一些物理计算实际可以拆分成多线程或者跟进一步使用GPU、比如接收客户端数据可以使用非阻塞模型或者线程池……这里不再多做说明。
其实我们抽象一下,以上整个框架实际上适用于任何类型的联机游戏。对于网络连接层,我们需要很清楚几个概念:
每一个客户端连接,在服务端维护一个session,这个session主要处理与客户端的通信(收发数据)以及该客户端的一些临时状态(我们这个游戏没有)。
通信协议
服务端维护了一份完整的数据结构,在本游戏就是当前游戏中剩余时间、一共有多少个玩家、玩家的HP、玩家们所在位置和移动方向及速度、一共有多少颗子弹、子弹的移动方向和速度……
我们称整个以上数据为一份全量状态,它描述了整个游戏当前的情况。
最朴素的思想就是服务端不断的将整个全量状态分发到每个客户端,这样客户端就只用管显示就行了。但实际的开发过程中会发现这样飞快就会达到性能瓶颈(因为发送的数据量太多,网络IO吃不住。而且也可能因为不断的要生成全量数据快照,CPU的计算量也非常大。)所以需要优化,接下来我们具体探讨一下通信协议如何来做。
通信协议是客户端和服务端共同约定的一个数据结构,其包含了双方可以发送并对方可以识别处理的数据包。
在设计网络协议的过程中,我们需要有一个的分层概念,我们更多的只需要来关心业务逻辑,也就是具体发送什么样的数据,底层的话这里我使用protobuf(性能最高的开源序列化、反序列化库)。
我们使用protobuf将数据结构序列化为二进制数据,并且通过socket来进行发送,接收方收到后使用protobuf反序列化为业务协议,提供给上层逻辑代码进行解析。所以实际上蓝色部分都是使用开源库来进行的,我们只用关注实际的游戏业务协议(绿色部分)。
我们拆解一下实际的业务协议,如下:
客户端 -> 服务器:
1、加入游戏
2、角色行动+开火
服务器 -> 客户端:
1、新玩家加入游戏
2、某个玩家被击中/击杀
3、玩家移动+开火
4、全量状态同步(用于游戏进行到一半有玩家加入,发送给他当前对局的整体情况)
5、时间流逝
所以实际上我们游戏的编程,就是在客户端和服务端互相生成并处理以上数据包的过程。对应前面的流程图就是所有的消息队列处理和生成。由于具体和游戏内容相关,各位有兴趣可以看代码,这里不再多做描述。
代码结构
另外,我需要更进一步说一下关于代码结构的一些思考。
由于服务端和客户端实际上有大量的数据结构交换,我认为一个比较好的方式是一份代码两边使用。所以我服务端也是使用C#编程开发,将一个脱离框架的dll(主要是业务通信协议和各种两边使用的数据结构、常量和计算工具)同时分发给两边使用。
具体可见服务端代码中的ShootGameServer.SharedData,这份代码同时会生成dll到客户端Unity的/Assets/Plugins目录下。
另外关于网络层的适配,我整个抽象了出来,所以大家如果有兴趣的话,可以使用UDP重写或者自己来编写底层的通信链路(下图橙色部分)。其TCP实现位于ShootGameServer.SharedData.Network.Impl
其中服务器我写了一个通信链路的集成单元测试,位于:
开源代码中通信链路KCP部分代码我尚未集成完毕,大家可以忽略。
未来的一点点计划
目前我们工作室计划针对性的开发一套业务无关的网络链路层框架,主要实现的功能是“开房间”-“加入游戏”-“游戏”-“结束”的一套基于云服务分布式调度的管理框架。
通俗来说就是可以开发 实时联机的IO类游戏、百人联机的吃鸡类游戏、MOBA类游戏这种高实时性互动性要求的游戏。
我们计划将各个模块性能消耗优化到极致,并且未来提供多种高度封装易用的编程模型。
此模块我们会先在自己的内部的游戏项目中实践使用,未来考虑开源+分享出来。或者提供一套便捷易用的SDK,供外部使用。