-
Socket
网络上的两个程序通过一个双向的通信连接实现数据交换,这个 连接的一端称为一个Socket。一个Socket包含了进行网络通信必需的 五种信息:连接使用的协议、本地主机的IP地址、本地的协议端口、 远程主机的IP地址和远程协议端口(如图1-6所示)。如果把Socket理 解成一台手机,那么本地主机IP地址和端口相当于自己的手机号码, 远程主机IP地址和端口相当于对方的号码。至少需要两台手机才能打 电话,同样地,至少需要两个Socket才能进行网络通信。
-
TCP、UDP
从概念上讲,TCP是一种面向连接的、可靠的、基于字节流的传输 层通信协议,与TCP相对应的UDP协议是无连接的、不可靠的、但传输 效率较高的协议。在本章的语义中,“Socket通信”特指使用TCP协议 的Socket通信。 也许能够以寄快递的例子解释不同协议的区别。有些快递公司收 费低,对快递员的要求也低,丢件的事情频频发生;有些公司收费 高,但要求快递员在每个节点都做检查和记录,丢件率很低。不同快 递公司有着不同的行为规则,有的奉行低价优先,有的奉行服务至 上。TCP、UDP协议对应不同快递公司的行为规则。它们的目的都是将 数据发送给接收方,但使用的策略不同:TCP注重传输的可靠性,确保 数据不会丢失,但速度慢;UDP注重传输速度,但不保证所有发送的数 据对方都能够收到。至于孰优孰劣,得看具体的应用场景。游戏开发 最常用的是TCP协议,所以本书也以TCP为主。
-
异步
在Start方法中创建一个定时器对象timer(定时器Timer类位于 System.Threading命名空间内)。Timer类的构造函数有4个参数:第 一个参数TimeOut代表回调函数,即打印“铃铃铃”的方法;第三个参 数5000代表5000毫秒,即5秒;另外两个参数暂不需要关心。整个程序 的功能就是开启定时器,5秒后回调TimeOut方法,打印“铃铃铃”。 这种方法称为异步,它指进程不需要一直等下去,而是继续往下 执行,直到满足条件时才调用回调函数,这样可以提高执行的效率。
异步的实现依赖于多线程技术。在Unity中,执行 Start、Update方法的线程是主线程,定时器会把定时任务交给另外的 线程去执行,在等待5秒后,“另外的某条线程”调用回调函数。主线 程继续往下执行代码,不受影响。
-
状态检测Poll
Poll方法将会检查Socket的状态。如果指定mode参数为 SelectMode.SelectRead,则可确定Socket是否为可读;指定参数为 SelectMode.SelectWrite,可确定Socket是否为可写;指定参数为 SelectMode.SelectError,可以检测错误条件。Poll将在指定的时段 (以微秒为单位)内阻止执行,如果希望无限期地等待响应,可将 microSeconds设置为一个负整数;如果希望不阻塞,可将 microSeconds设置为0。
*在阻塞方法前加上一层判断,有数据可读才调用Receive,有 数据可写才调用Send,那不就既能够实现功能,又不会卡住程序了 *
-
多路复用Select
多路复用,就是同时处理多路信号,比如同时检测多个 Socket的状态。同时检测多个 Socket的状态。在设置要监听的Socket列表后,如果有一个(或多 个)Socket可读(或可写,或发生错误信息),那就返回这些可读的 Socket,如果没有可读的,那就阻塞。
Select可以确定一个或多个Socket对象的状态,如图所示。 使用它时,须先将一个或多个套接字放入IList中。通过调用 Select(将IList作为checkRead参数),可检查Socket是否具有可读 性。若要检查套接字是否具有可写性,可使用checkWrite参数。若要 检测错误条件,可使用checkError。在调用Select之后,Select将修 改IList列表,仅保留那些满足条件的套接字。如图2-13所示,把包含 6个Socket的列表传给Select,Select方法将会阻塞,等到超时或某个 (或多个)Socket可读时返回,并且修改checkRead列表,仅保存可读 的socket A和socket C。当没有任何可读Socket时,程序将会阻塞, 不占用CPU资源。
-
角色控制
可以设计图所示的类结构,其基类BaseHuman是基础的角色 类,它处理“操控角色”和“同步角色”的一些共有功能;CtrlHuman 类代表“操控角色”,它在BaseHuman类的基础上处理鼠标操控功能; SyncHuman类是“同步角色”类,它也继承自BaseHuman,并处理网络同步(如果有必要)。
-
网络模块
在实际的网络游戏开发中,网络模块往往是作为一个底 层模块用的,它应该和具体的游戏逻辑分开,而不应该把处理逻辑的 代码(例如之前给recvStr赋值)写到ReceiveCallback里面去,因为 ReceiveCallback应当只处理网络数据,不应该去处理游戏功能。一个 可行的做法是,给网络管理类添加回调方法,当收到某种消息时就自 动调用某个函数,这样便能够将游戏逻辑和底层模块分开。
-
进入游戏:Enter协议
当玩家打开游戏,客户端程序会生成一个操控角色 (CtrlHuman),并把它放到场景中的一个随机位置。然后发送一条 Enter协议给服务端,包含了对玩家的描述、位置等信息。服务端将 Enter协议广播出去,其他客户端收到Enter协议后,创建一个同步角 色(SyncHuman)
-
玩家列表:List协议
客户端发送和接收List协议的代码如下所示,它解析参数后,生 成一个同步角色。
-
移动同步:Move协议
当玩家用鼠标点击场景,角色移动时,客户端应把目的地位置发 送给服务端。服务端一方面记录位置信息,另一方面将目的地位置信 息广播给其他客户端。其他客户端收到协议后,解析目的地位置信 息,然后控制SyncHuman走到对应的位置去。
-
玩家离开:Leave协议
当某个客户端掉线,服务端会广播Leave协议,客户端收到后删除 对应的角色。
-
攻击动作:Attack协议
Attack协议设计如图所示。它带有两个参数,第一个参数为 角色描述,第二个参数为攻击的方向。在CtrlHuman发起攻击动作后, 将Attack协议发送给服务端
-
攻击伤害:Hit协议
当玩家发起进攻,且打击到敌人时,敌人会受到伤害。假设不会 有玩家作弊,服务端完全信任客户端,一种可能的实现方式是,当攻 击到敌人时,攻击方发送Hit协议,如图所示,协议中带有被攻击 者的信息。服务端收到协议后,扣除被攻击角色的血量。
-
角色死亡:Die协议
当角色死亡时,服务端会广播Die协议(图3-57),客户端收到协 议后删除该角色。
-
TCP数据流:粘包半包现象
在聊天软件中,客户端依次 发送“Lpy”和“_is_handsome”,期望其他客户端也展示出“Lpy” 和“_is_handsome”两条信息,但由于Receive可能把两条信息当作一 条信息处理,有可能只展示“Lpy_is_handsome”一条信息(如图所示)。Receive方法返回多少个数据,取决于操作系统接收缓冲区中 存放的内容
发送端发送的数据还有可能被拆分,如发送“HelloWorld”(如图所示),但在接收端调用Receive时,操作系统只接收到了部分 数据,如“Hel”,在等待一小段时间后再次调用Receive才接收到另 一部分数据“loWorld”。
-
解决粘包问题的方法:长度信息法、固定长度法、结束符号法
-
长度信息法
长度信息法是指在每个数据包前面加上长度信息。每次接收到数 据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字 节数,则取出相应的字节,否则等待下一次数据接收。
-
固定长度
每次都以相同的长度发送数据,假设规定每条信息的长度都为10 个字符,那么发送“Hello”“Unity”两条信息可以发送成 “Hello...”“Unity...”,其中的“.”表示填充字符,是为凑数, 没有实际意义,只为了每次发送的数据都有固定长度。
-
结束符号法
规定一个结束符号,作为消息间的分隔符。假设规定结束符号为 “$”,那么发送“Hello”“Unity”两条信息可以发送成“Hello$” “Unity$”。接收方每次读取数据,直到“$”出现为止,并且使用 “$”去分割消息。
-
-
大端小端
大端:高位存低字节;小端相反
-
完整发送数据
Send方法会把要发送的数据存入操作系统的发 送缓冲区,然后返回成功写入的字节数。这句话的另一层含义是,对 于那些没有成功发送的数据,程序需要把它们保存起来,在适当的时 机再次发送。
-
从TCP到铜线:
-
应用层
应用层功能是应用程序(游戏程序)提供的功能。在给客户端发 送“hello”的例子中,程序把“hello”转化成二进制流传递给传输 层(传送给send方法,如图5-1所示)。操作系统会对二进制数据做一 系列加工,使它适合于网络传输。
-
传输层
收到二进制数据后,传输层协议会对它做一系列加工,并提供数 据流传送、可靠性校验、流量控制等功能。依然想象一下寄信,在寄 出一封信后,为了确保对方一定会收到信件,人们可以约定如下的规则。
1)加个确认机制,收到信件的人必须写回信,告诉对方收到了信 件。寄件人在收到回信后,可以确认对方一定收到了信件。
2)信件寄出后,寄信人会等待回信。如果过一个月时间都没能收 到回信,说明信件很有可能丢失了,寄件人会重新写一封一模一样的 信,再次寄出,等待回信。如果三次重寄都没有回音,只能放弃,当 作对方不可能收到信件。
-
网络层
邮政系统并不是直达系统,当寄件人想要把信件从广州天河区寄 到北京西城区的时候,信件会先从天河区邮局发送到广州市邮局,再 由广州市邮局发送到北京市邮局,北京市邮局再发送到西城区邮局, 最后再由邮递员投递到指定地址。网络通信同理,数据包会经过一层 层传送,最终到达目的地(5.3.3节会有进一步介绍),所以网络消息 必须附带“寄件人地址”“收件人地址”等数据,方便“各地邮局” 投递。IP协议会给TCP数据添加本地地址、目的地地址等信息(如图所示)。
-
网络接口
在多层处理后,数据通过物理介质(如电缆、光纤)传输到接收 方,接收方再依照相反的过程解析,得到用户数据。实际上,IP协议 还会被封装成更为底层的链路层协议,以完成数据校验等一些功能。
-
-
数据传输流程:
-
常用TCP参数
-
ReceiveBufferSize
ReceiveBufferSize指定了操作系统读缓冲区的大小,默认值是 8192
-
SendBufferSize
SendBufferSize指定了操作系统写缓冲区的大小,默认值也是 8192
-
NoDelay
指定发送数据时是否使用Nagle算法,对于实时性要求高的游戏, 该值需要设置成true。Nagle是一种节省网络流量的机制,默认情况 下,TCP会使用Nagle算法去发送数据。
-
TTL
TTL指发送的IP数据包的生存时间值(Time To Live,TTL)。TTL 是IP头部的一个值,该值表示一个IP数据报能够经过的最大的路由器 跳数。
-
ReuseAddress
ReuseAddress即端口复用,让同一个端口可被多个socket使用。 一般情况下,一个端口只能由一个进程独占,假设服务端程序都绑定 了1234端口,若开启两个服务端程序,虽然,第一个开启的程序能够 成功绑定端口并监听,但第二个程序会提示“端口已经在使用中”, 无法绑定端口。在计算机中,退出程序与释放端口并不同步。
-
LingerState
LingerState的功能是设置套接字保持连接的时间。
-
-
Close的恰当时机
LingerState选项可以让程序在关闭连接前发完系统缓冲区中的数据,然而,这并不代表能将所有数据发出去。写入队列writeQueue保存要发送的数据,再逐 一发送。
-
心跳机制
断开连接时,主动方会给对端发送FIN信号,开启4次挥手流程。 但在某些情况下,比如拿着手机进入没有信号的山区,更极端的,比 如有人拿剪刀把网线剪断。虽然断开了连接,但主动方无法给对端发 送FIN信号(网线剪断了还能干什么?),对端会认为连接有效,一直 占用系统资源。
TCP有一个连接检测机制,就是如果在指定的时间内没有数据传 送,会给对端发送一个信号(通过SetSocketOption的KeepAlive选项 开启)。对端如果收到这个信号,回送一个TCP的信号,确认已经收 到,这样就知道此连接通畅。如果一段时间没有收到对方的响应,会 进行重试,重试几次后,会认为网络不通,关闭socket。
游戏开发中,TCP默认的KeepAlive机制很“鸡肋”,因为上述的 “一段时间”太长,默认为2小时。一般会自行实现心跳机制。心跳机 制是指客户端定时(比如每隔1分钟)向服务端发送PING消息,服务端 收到后回应PONG消息。服务端会记录客户端最后一次发送PING消息的 时间,如果很久没有收到(比如3分钟),就假定连接不通,服务端会 关闭连接,释放系统资源。
-
网络模块设计:对外接口、内部设计
-
网络事件:事件类型、监听列表、分发事件
-
连接服务端:Connect、ConnectCallback、测试程序
-
关闭连接:isClosing、Close、测试
-
Json协议:为什么会有协议类、使用JsonUtility、协议格式、协议文件、协议体的编码解码、协议名的编码解码
-
发送数据:Send、SendCallback、测试
-
消息事件
-
接收数据:新的成员、ConnectCallback、ReceiveCallback、OnReceiveData、Update、测试
-
心跳机制:PING和PONG协议、成员变量、发送PING协议、监听PONG协议、测试
-
Protobuf协议:什么是Protobuf、编写proto文件、生成协议类、导入protobuf-net.dll、编码解码
-
服务端架构:总体架构
服务端程序的两大核心是处理客户端的消息和存储玩家数据。下图展示的是最基础的单进程服务端结构,客户端与服务端通过TCP连 接,使两者可以传递数据;服务端还连接着MySQL数据库,可将玩家数 据保存到数据库中。
-
Json编码解码:添加协议文件、引用System.web.Extensions、修改MsgBase类、测试
-
网络模块:整体结构
本章的服务端程序与第3章的服务端程序在结构上基本相似,是在第3章程序的基础上,添加粘包半包处理、协议解析、数据库存储等功能。除了协议解析相关,网络模块还分为4个部分:一是处理select多路复用的网络管理器NetManager,它是服务端网络模块的核心部件; 二是定义客户端信息的ClientState类,第3章的ClientState类相对简单,本章会继续完善它;三是处理网络消息的MsgHandler类,第3章中所有的消息处理都写在同一个文件里,但对于大型游戏来说,一个几十万行的文件不太容易编辑,本章会根据消息的类型,将MsgHandler 分拆到多个文件中(如BattleMsgHanler.cs专门处理战斗相关的协议,SysMsgHandler.cs专门处理MsgPing、MsgPong等系统协议);四是事件处理类EventHandler。 下图展示了服务端网络模块的整体结构,与第3章不同的是,程序引入了玩家列表,玩家登录后clientState会与player对象关联。通过判断clientState是否持有player对象即可判断客户端是处于“连接 但未登录”状态,还是处于“登录成功”状态。
-
心跳机制:lastPingTime、时间戳、回应MsgPing协议、超时处理、测试程序
-
玩家的数据结构:完整的ClientState
-
配置MySQL数据库:安装并启动MySQL数据库、安装Navicat for MySQL、配置数据表、安装connector、MySQL基础知识
-
数据库模块:连接数据库、防止SQL注入、IsAccountExist、Register、CreatePlayer、CheckPassword、GetPlayerData、UpdatePlayerData
-
登录注册功能:注册登录协议、记事本协议、注册功能、登录功能、退出功能、获取文本功能、保存文本功能、客户端界面、客户端监听、客户端注册功能、客户端登录功能、客户端记事本功能、测试
-
《坦克大战》游戏功能: 登录注册、房间系统、战斗系统
-
坦克模型:导入模型、模型结构
-
资源管理器:设计构想
代码与资源分离是游戏程序设计的核心思想之一,被广大游戏公 司所采用。相比于乱成一团的编码方式,它有以下几点优势。
1)游戏公司里,美术人员负责模型的设计和制作,程序人员负责 实现功能。代码分离有利于美术人员和程序人员的分工合作,两者既 相互配合,又互不干扰。
2)有利于代码的重复使用。功能相同但外观不同实体(如坦克) 只需一套代码。
3)为游戏的热更新提供可能性。若游戏需要更新模型,只需要下 载新的模型资源。 当游戏中需要使用坦克模型时,可以使用动态加载的方式,即通 过代码把坦克模型加载到场景中,而不是在编辑场景的时候直接把坦 克模型拉进去。 Unity工程的Assets/Resources目录是个特殊的目录,它和 Resources类相关联。当使用形如Resources. Load ("tankPrefab")的语句去加载资源时,Unity会把 Assets/Resources/tankPrefab.prefab加载到内存。
-
坦克类:设计构想
与第3章的角色(Human)类相似,设计如图8-13所示的坦克类结 构。BaseTank是坦克基类,它包含坦克的一些通用功能,比如开炮、 皮肤设置等。CtrlTank为玩家控制的坦克,它会包含行走控制等功 能。SyncTank是同步坦克,它会根据网络数据移动坦克、控制开炮。
-
行走控制:速度参数、移动控制、测试、走在地形上
-
坦克爬坡:Unity的物理系统、添加物理组件、测试
-
相机跟随:功能需求、数学原理、编写代码、测试
-
旋转炮塔:炮塔元素、旋转控制、测试
-
发射炮:制作炮弹预设、制作爆炸效果、炮弹组件、坦克开炮、测试
-
摧毁敌人:坦克的生命值、焚烧特效、坦克被击中处理、炮弹的攻击处理、测试
-
界面模块的设计:通用界面模块
开发商业游戏,需要处理好9.1.1节提到的两个问题。首先,每一 个面板对应一个类,在这个类里面编写面板的功能。再定义一个界面 管理器,通过它来控制界面的显示和关闭。界面管理器有两个基本方 法,分别是Open和Close。形如: PanelManager.Open(); PanelManager.Close(); 只要程序调用“PanelManager.Open();”,游戏就 会显示出登录面板(对应LoginPanel类);只要调用 “PanelManager.Open("作者很帅");”就会弹出显示“作者很 帅”的提示框。下图展示了界面管理器的基本结构,除了Open和 Close两个方法,它一般还包含一个列表(这里取名为panels),索引 着所有已经打开的界面,避免重复打开。
-
场景结构
在Unity的界面系统中,所有面板组件都应放置在画布下。考虑到 不同面板间会有层级关系,我们在Unity场景中添加名为Root的空物 体,Root将永久保留在场景上。如图所示,Root下面有一个名为 Canvas的子物体,是一个画布。再下面有名为Panel和Tip的空物体, 代表不同的层级。
-
面板基类BasePanel:设计要点
这套界面系统由面板基类(BasePanel)、界面管理器 (PanelManager)和多个具体的面板组件(如LoginPanel、 RegisterPanel)组成。所有面板都继承自BasePanel,而 PanelManager提供打开某个面板、关闭某个面板的方法。BasePanel是 面板基类,所有的面板类都要继承它。BasePanel的一些设计要点如 下。
1)面板的资源称为皮肤(skin,为GameObject类型),皮肤的路 径称为skinPath。界面管理器将会根据skinPath去实例化skin。
2)由于某些面板有层级关系,比如提示框总要覆盖普通面板。在 PanelManager中会定义名为Layer的枚举,指定面板的层级。
3)某些面板需要通过参数来确定它的表现形式。比如提示框显示 的内容由调用它的语句指定。
4)面板有着图9-6所示的生命周期。在打开面板后,管理器会调 用面板的OnInit方法,做些初始化工作。随后加载资源,将面板预设 添加到场景上。再调用面板的OnShow方法,做些和面板资源有关的初 始化工作。当关闭面板时,会调用面板的OnClose方法。
-
界面管理器PanelManager:层级管理
既是层级管理,就需要定义有哪些层级。以下代码展示了界面管 理器的整体结构,定义枚举类型Layer,分别有Layer.Panel和 Layer.Panel两项。layers、root和canvas三个成员分别指向场景中的 物体,Dictionary类型的layers让Layer枚举和场景物体相对应,后续 的代码会让layers[Layer.Panel]指向“Root/Canvas/Panel”,让 layers[Layer.Tip]指向“Root/Canvas/Tip”,如图所示。
-
登录面板LoginPanel:导入资源、UI组件、制作面板预设、登录面板类、打开面板、引用UI组件、网络监听、登录和注册按钮、收到登录协议
-
注册面板RegisterPanel:制作面板预设、注册面板类、按钮事件、收到注册协议
-
提示面板TipPanel:制作面板预设、提示面板类、测试面板
-
游戏入口GameMain:设计要点、代码实现、缓存用户名
游戏入口类GameMain是一个挂载在场景中的组件,它作为整个游 戏的驱动类,解决下面几个问题。
1)当玩家打开游戏时,登录面板会作为第一个弹出的面板。那么 游戏项目中,应该在哪里调用弹出登录面板的PanelManager.Open呢? 我们会把游戏的初始功能放置到GameMain里。
2)客户端需要保存一些玩家数据,这些数据在整个游戏中都会用 到。例如在游戏的很多地方都会显示玩家的id,如果客户端能够自己 记录,便不需要从服务端获取。
3)网络框架NetManager需要外部调用它的Update方法来驱动, GameMain可完成这项功能。
4)GameMain还会完成一些通用事件的处理,例如当网络断开时弹 出提示,当玩家被顶下线时也弹出提示。
-
功能测试:登录、注册、下线
-
列表面板预设:整体结构、个人信息栏、操作栏、房间列表栏、Scroll View、列表项Room
-
房间面板预设:整体结构、列表栏、列表项Player、控制栏
-
协议设计
-
查询战绩MsgGetAchieve协
服务端收到MsgGetAchieve协议后,返回玩家的总胜利次数win和 总失败次数lost。
-
查询房间列表MsgGetRoomList协议
服务端收到MsgGetRoomList协议后,会将所有房间信息发送给客 户端。协议类包含RoomInfo类型的数组,而RoomInfo类包含了房间的 各种信息,包括序号(id)、人数(count)、状态(status)。 status为0代表“准备中”状态,status为1代表“开战中”状态。
-
创建房间MsgCreateRoom协议
服务端收到MsgCreateRoom协议后,会创建一个新的房间并把玩家 添加到新的房间里。返回值result代表执行结果,result为0代表创建 成功,其他数值代表创建失败。例如,如果玩家已经加入别的房间 中,便不能创建新房间。
-
进入房间MsgEnterRoom协议
玩家请求加入房间时将房间序号(id)发送给服务端,服务端把 玩家添加到房间中。服务端的返回值result代表执行结果,result为0 代表成功进入,其他数值代表进入失败。例如玩家已经在房间中,就 不能重复进入。
-
查询房间信息MsgGetRoomInfo协议
玩家进入房间后,可以通过MsgGetRoomInfo协议请求该房间的详 细信息。服务端返回PlayerInfo类型的数组players,告诉客户端房间 里所有玩家的信息,包括账号(id)、所在队伍(camp)、胜利总数 (win)、失败总数(lost)、是否是房主(isOwner)。camp的取值 为1或者2,代表在第一个阵营或者第二个阵营;如果isOwner为1,代 表玩家是房主,如果为0,代表是普通成员。如有玩家加入或离开房 间,服务端还会给房间里的所有玩家推送该协议,让客户端更新界 面。
-
退出房间MsgLeaveRoom协议
玩家退出房间时发送MsgLeaveRoom协议,服务端的返回值result 代表离开房间的结果,result为0代表离开成功,其他数值代表离开失 败(如玩家不在房间中却发送离开房间的协议)。
-
开始战斗MsgStartBattle协议
当房主点击开始战斗按钮时,客户端会发送MsgStartBattle协 议。服务端的返回值result代表开始战斗的结果,result为0代表成 功,其他数值代表失败。
-
-
列表面板逻辑:面板类、获取部件、网络监听、刷新战绩、刷新房间列表、加入房间、创建房间、刷新按钮
-
房间面板逻辑:面板类、获取部件、网络监听、刷新玩家列表、退出房间、开始战斗
-
打开列表面板
-
服务端玩家数据:存储数据、临时数据
-
服务端房间类:管理器和房间类的关系、房间类的设计要点、添加玩家、选择阵营、删除玩家、选择新房主、广播消息、生成房间信息
-
服务端房间管理器:数据结构、获取房间、添加房间、删除房间、生成列表信息
-
服务端消息处理
-
查询战绩MsgGetAchieve
服务端收到查询战绩的协议后,返回玩家的总胜利次数win和总失 败次数lost。
-
查询房间列表MsgGetRoomList
服务端收到获取房间列表协议后,将房间列表信息发送给客户 端。由于RoomManager.ToMsg会返回带有列表信息的MsgGetRoomList对 象,只要把它发送给客户端即可。
-
创建房间MsgCreateRoom
服务端收到创建房间协议后,先进行一些条件检测,如果玩家在 房间中或者在战斗中,返回1表示不能创建。通过条件检测后,调用 RoomManager的AddRoom方法创建房间,再通过room.AddPlayer把玩家 添加到房间里面。最后返回0(msg.result)表示创建成功。
-
进入房间MsgEnterRoom
服务端根据客户端发来的房间序号(msg.id)找到房间,然后通 过room.AddPlayer方法把玩家添加到房间中。在下面的代码中,程序 会先做一些条件判断,如果玩家已经在房间里,那他不能够再进入房 间;如果客户端发送的房间序号(msg.id)不正确,服务端并没有这 个房间,也会返回失败。接着程序通过room.AddPlayer把玩家加入到 房间里。room.AddPlayer也会做一系列判断,比如房间是否处于准备 状态,房间是否满员了,如果这些条件不满足,依然会返回失败。如 果满足了room.AddPlayer的条件,程序不仅把玩家添加到房间中,还 会把房间信息推送给房间内的玩家
-
查询房间信息MsgGetRoomInfo
进入房间后,客户端会发送MsgGetRoomInfo协议请求该房间的详 细信息。在下面的处理方法中,程序先判断玩家是否在房间里 (if(room==null))。如果玩家不在房间,会回应空消息 (msg.players没有值);如果玩家在某个房间里面,程序会调用 room.ToMsg获取该房间的信息,然后发送给客户 端。
-
离开房间MsgLeaveRoo
服务端收到离开房间的协议后,会调用room.RemovePlayer将玩家 移出房间。下面的代码中,程序先会判断玩家是否在某个房间里 (if(room==null)),如果不在,自然就不存在退出房间的操作,会 返回失败(msg.result=1)。如果玩家确实在某个房间里,直接调用 room.RemovePlayer将玩家移出房间,room.RemovePlayer还会给房间 里的其他玩家广播MsgGetRoomInfo,让客户端更新界面。
-
-
玩家事件处理
-
测试
-
协议设计
-
进入战斗MsgEnterBattle
服务端收到离开房间的协议后,会调用room.RemovePlayer将玩家 移出房间。下面的代码中,程序先会判断玩家是否在某个房间里 (if(room==null)),如果不在,自然就不存在退出房间的操作,会 返回失败(msg.result=1)。如果玩家确实在某个房间里,直接调用 room.RemovePlayer将玩家移出房间,room.RemovePlayer还会给房间 里的其他玩家广播MsgGetRoomInfo,让客户端更新界面。
-
战斗结果MsgBattleResult
服务端收到离开房间的协议后,会调用room.RemovePlayer将玩家 移出房间。下面的代码中,程序先会判断玩家是否在某个房间里 (if(room==null)),如果不在,自然就不存在退出房间的操作,会 返回失败(msg.result=1)。如果玩家确实在某个房间里,直接调用 room.RemovePlayer将玩家移出房间,room.RemovePlayer还会给房间 里的其他玩家广播MsgGetRoomInfo,让客户端更新界面。
-
退出战斗MsgLeaveBattle
网络游戏不可避免会出现战斗中掉线的情况,当战场中有玩家掉 线,服务端会向房间内的其他玩家广播MsgLeaveBattle协议,通过参 数id告知谁退出了比赛。
-
-
坦克:不同阵营的坦克预设、战斗模块、同步坦克SyncTank、坦克的属性
-
战斗管理器:设计要点、管理器类、坦克管理、重置战场、开始战斗、产生坦克、战斗结束、玩家离开
-
战斗结果面板:面板预设、面板逻辑
-
服务端开启战斗:能否开始战斗、定义出生点、坦克信息、开启战斗、消息处理
-
服务端胜负判断:是否死亡、胜负决断函数、定时器、Room::Update
-
服务端断线处理
-
测试:进入战场、离开战场
-
同步理论
-
同步的过程
在客户端—服务端架构中,无论是用什么样的同步方法,都始终 遵循着图所示的过程。客户端1向服务端发送一条消息,服务端收 到后稍作处理,把它广播给所需的客户端(客户端1、客户端2和客户端3)。所传递的消息可以是坦克的位置、旋转这样的状态值,也可以 是“向前走”这样的指令值。前者称之为状态同步,后者称之为指令同步。
-
同步的难题
由于存在网络延迟和抖动,往往很难做到精确的同步。图12-3左 图展示的是理想的网络情况,服务端定时发送消息给客户端,客户端 立刻就能够收到。而实际的网络情况并非如此,更像图右图所展 示的,存在两个问题:其一,消息的传播需要时间,会有延迟;其 二,消息的到达时间并不稳定,有时候两条消息会相隔较长时间,有时候却相隔很短。
网络延迟问题基本无解,只能权衡。比如,尽量发送更少的数 据,数据越少,发生数据丢失并重传的概率就越小,平均速度越快。 又比如,在客户端上做些“障眼法”,让玩家感受不到延迟。
-
-
状态同步
状态同步指的是同步状态信息。在坦克游戏中,客户端把坦克的 位置坐标、旋转发送给服务端,服务端再做广播。
-
直接状态同步
最直接的同步方案莫过于客户端定时向服务端报告位置,其他玩 家收到转发的消息后,直接将对方坦克移动到指定位置。
-
跟随算法
为了解决“直接状态同步”的瞬移问题,人们引入了一种障眼 法,称为“跟随算法”。在收到同步协议后,客户端不直接将坦克拉 到目的地,而是让坦克以一定的速度移动。
-
预测算法
跟随算法的一大缺陷就是误差会变得很大,那么还有没有办法可 以减少误差呢?在某些有规律可循的条件下,比如坦克匀速运动,或 者匀加速运动,我们能够预测坦克在接下来某个时间点的位置,让坦 克提前走到预测的位置上去。这就是预测算法。
-
-
帧同步
帧同步是指令同步的一种,即同步操作信息。基本上所有指令同 步方法都结合了帧同步,两者可以视为一体。这里“帧”的概念与 Unity中“每一帧执行一次Update”“30FPS(每秒传输帧数,Frames Per Second)”里的“Unity帧”有所不同,我们会实现独立于 “Unity帧”的另外一种“同步帧”。
-
指令同步
状态同步所同步的是状态信息,如果要同步坦克的位置和旋转, 那就需要同步六个值(三个坐标值和三个旋转值)。缓解网络延迟的一个办法是减少传输的数据量,如果只传输玩家的操作指令,数据量就会减少很多。
缺点:有些电脑速度快,有些电脑 速度慢,尽管玩家2收到了玩家1的指令,但只要两者的电脑运行速度 不同,可能有人看到坦克走了很远,有人看到的却只移动了一点点的 距离。为了解决这个问题,人们在操作同步的基础上,引入了“同步 帧”的概念。
-
从Update说起
如果有一种办法,让不同的电脑有一致的运行效果,便可以解决 指令同步中的误差累积问题。在第8章中,我们在Update中设置控制坦克的新位置,代码如下:
public void MoveUpdate(){ //Update中调用 …… float y = Input.GetAxis("Vertical"); //如果是SyncTank,改为由网 络传播的指令 Vector3 s = y*transform.forward * speed * Time.deltaTime; transform.transform.position += s; }
由于采用了“速度*时间”的计算式,理论上说,无论电脑运行速 度快慢,坦克移动的路程都能够保持一致。因为当电脑很慢时, Update的执行次数会变少,但Time.deltaTime的值变大,反之示然, 但坦克移动的路程保持不变。
尽管如此,我们还不能够保证经由网络同步的坦克能够有一致的 行为。因为网络延迟的存在,从发出“前进”到“停止”指令之间的时间可能不一致,坦克移动的路程也就不同。
一种解决办法是,在发送命令的时候附带时间信息,客户端根据指令的时间信息去修正路程的计算方式,使所有客户端表现一致。人们定义了一种名为“帧”的 概念,来表示时间(为和Unity本身的帧区分,这里称为“同步帧”)。
-
什么是同步帧
假如我们自己实现一个类似Update的方法,称之为FrameUpdate, 程序会固定每隔0.1秒就调用它一次。每一次调用FrameUpdate称之为 一帧,第1次调用称为第1帧,第2次调用称为第2帧,以此类推。
在图 12-11中,在第0.1秒的时候执行了第1帧,在第0.2秒的时候执行了第2 帧。 然而图12-11展示的是一种理想情况,现实往往很残酷。比如在执 行第2帧的时候,系统突然卡顿了一下,这一帧的执行时间变长了,超 过0.1秒(图12-12),这会导致第3帧无法按时执行。为了保证后面的 帧能够按时执行,程序需要做出调整,即减少第2帧和第3帧之间、第3 帧和第4帧之间的时间间隔,保证程序在第0.5秒时,执行到第5帧。
-
指令
比起操作同步,在指令同步中,客户端向服务端发送的指令包含 了具体的指令和时间信息,即是在哪一帧(特指同步帧)做了哪些操 作。例如:在第10帧发出了“前进”指令(按下“上”键),在第20 帧发出了“后退”指令(按下“下”键)。
指令同步的协议形式如下,cmd代表指令的内容,可能是前进、后 退、左转、右转、停止。frame代表该指令在第几帧发出。
-
指令的执行
为了保证所有客户端有一样的表现,往往要做一些妥协,有两种常见的妥协方案。
1)有的客户端运行速度快,有的运行速度慢,如果要让它们表现一致,那只能让快的客户端去等待慢的客户端,所有客户端和最慢的 客户端保持一致,才有可能表现一致,毕竟,慢的客户端无论如何都 快不了。这种方案对速度快的客户端较为不利。达成此方案的一个方 法称为延迟执行,如果客户端1在第3帧发出向前的指令,由于网络延 迟,客户端2可能在第5帧才收到,所以客户端1的坦克也只能在第5帧 (或之后的某一帧)才开始前进。
2)对于速度慢客户端所发送的,丢弃那些已经过时的指令,直到 它赶上来。此种方案也称之为乐观帧同步,对速度慢的玩家较为不 利,因为某些操作指令会被丢弃。比如发出“前进”指令,但该指令 被丢弃了,坦克不会移动。
所以,帧同步是一种为了保证多个客户端表现一致,让某些客户 端做妥协的方案。而且如果启用了延迟执行,在玩家发出“前进”指 令之后,要隔一小段时间坦克才能移动,玩家会感受到延迟。但无论 如何,只要帧率(每秒执行多少帧)足够高,玩家就不会感觉到明显 的延迟。
-
-
协议设计:位置同步MsgSyncTank、开火MsgFire、击中MsgHit
-
发送同步信息:发送位置信息、发送开火信息、发送击中信息
-
处理同步信息:协议监听、OnMsgSyncTank、OnMsgFire、OnMsgHit
-
同步坦克SyncTank:预测算法的成员变量、移动到预测位置、初始化、更新预测位置、炮弹同步
-
服务端消息处理:位置同步MsgSyncTank、开火MsgFire、击中MsgHit、调试
-
完善细节
- 滚动的轮子和履带
- 灵活操作
- 准心
- 自动瞄准
- 界面和场景优化
- 战斗面板
- 击杀提示