第 5 章 [ 实践 ] P2P MO 游戏开发:没有专用服务器的动作类游戏的实现

第 5 章 [实践] P2P MO游戏开发:没有专用服务器的动作类游戏的实现

没有专用服务器的动作类游戏的实现

本章主要介绍 P2PMO 游戏的开发。详细内容如下。

  • P2P MO 游戏的特点和开发策略

  • J Multiplayer 游戏开发案例的学习——与 K Online 的不同

  • P2P MO 游戏的设计资料

  • 客户端 / 服务器软件 + 中间件、基本原则

  • P2P 游戏 J Multiplayer 的实现——正式开始编程

  • 支持 C/S MO 游戏的技术 [ 补充 ]

前面章节介绍的 C/S MMO 游戏开发中使用了专用服务器,所以从最初的概要设计、流程设计到物理设计就需要考虑服务器的相关问题。

但是对于 P2P MO 游戏来说,因为参与游戏的玩家少、不需要为(游戏使用的)专用服务器付费,所以在概要设计、流程设计、物理设计中需要考虑的情况就大为减少,这样可以加快开发游戏的速度。

本章针对 P2P MO 游戏和 C/S MMO 游戏在设计上的不同,以及那些不需要花费很多精力解决的问题,通过实际的开发流程来向读者进行说明。

5.1 P2P MO 游戏的特点和开发策略

第 3 章已经介绍了 P2P MO 游戏的概要。本章主要整理开发中需要的知识点。

5.1.1 P2P MO 和动作类游戏——游戏的状态频繁发生改变

P2P MO 这种典型的多人游戏的设计主要有以下几种分类。

  • ARPG

    例:暴雪娱乐公司的《暗黑破坏神》、卡普空的《怪物猎人》系列

  • FPS

    例:id Software 的《雷神之锤》系列

  • RTS

    例:微软的《帝国时代》、暴雪娱乐公司的《星际争霸》系列

  • 竞速游戏

    例:任天堂的《马里奥赛车》系列

  • 格斗对战游戏

    例:任天堂的《全明星大乱斗》系列

这类游戏都具有动作游戏的特点。动作类游戏的游戏状态频繁发生变化,会产生大量数据交互,如果使用 C/S 方式实现,对服务器、带宽等成本要求过高,所以从商业角度考虑是不可行的。

因此,即便有使用游戏外挂作弊的风险,P2P 的实现方式从经济角度来说也是合理的。但是,目前服务器硬件以及带宽成本不断降低,这种经济角度的考虑已经变得不那么重要了。所以游戏的数据通信全部通过服务器来传输的方式也在逐渐流行起来 1

15.6 节会介绍这种实现方式。

上述动作游戏都可以使用“同步共享内存”(数据对象同步和事件通信)的方式来开发。这种方式与 C/S 类型的 MMO 游戏中经常使用的 RPC 方式相比,通信量会增加,但是程序开发相对简单,而且可以在游戏的单机部分开发完成后再扩展其网络功能。

共享内存这种方式是 P2P MO 游戏开发中经常使用的特有方法,本章会详细说明。

5.1.2 RPC 和共享内存

在 C/S MMO 游戏中,服务器通过 RPC 方式同步客户端数据进行游戏。P2P MO 类型的游戏使用共享内存方式,它们的区别请参考表 5.1。

表 5.1 RPC 开发方式和共享内存开发方式的比较

RPC 开发方式共享内存开发方式
通信量最小限度会有多余的通信
CPU 负荷最小限度少量多余的运算量
最大同时连接数千数十到 100
开发难易度复杂简单但是代码量大
通信延迟没有区别
游戏类型即时性低即时性高
游戏数据规模数据量大(对象数百万以上)数据量小(对象数几百到几千)
通信方式一对多1 对全部
数据同步的开发时机在最开始需要开发可以在单机版之后扩展

通过表 5.1 可以看到,RPC 开发方式面向的是 MMO 游戏,而共享内存的实现方式更适合 MO 游戏。共享内存其实在 C/S 和 P2P 游戏中都可以使用。相对于通信方式来说,我们更应该根据游戏的内容来选择开发技术。

例如,如果要求单服务器同时连接数在数千级别,带宽负荷和 CPU 负荷都应该尽量减少,就只能选择 RPC 开发方式。对于不使用服务器的 P2P MO 游戏,即使单个玩家需要的带宽多一点,也可以使用共享内存的方式来开发。

笔者认为,应该尽量采用共享内存的方式开发游戏。这是因为共享内存相对于 RPC,不需要针对每个操作分别定义函数,开发起来比较容易。但是对于大型 MMO 游戏来说,由于它们使用了大量数据对象,而且 CPU 负荷和服务器的带宽要求都很高,只能选择 RPC 开发方式。随着云计算的使用费逐渐降低,未来也许可以都采用共享内存的方式来开发,但目前趋势还不明显。

RPC 开发方式的相关内容,在介绍 C/S MMO 的章节已经详细描述过了,本章主要对共享内存开发方式进行详细说明。

5.1.3 P2P MO 游戏的特点——和 C/S MMO 游戏的比较和难点

P2P MO 游戏的需求和 C/S MMO 游戏正好相反,这里列举一下它们的不同特点。

  • 不需要大量数据交互

    游戏的相关资源,除了贴图和动画之外一般在几百兆以内,可以全部安装在普通玩家的 PC 中 2。因为不需要联网也可以进行游戏,所以需要安装全部的数据和资源。

  • 配置信息对玩家是可见的

    因为游戏的配置信息是安装在玩家的电脑中,所以可以在本地文件中看到配置内容,游戏运行时也可以跟踪内存信息,而且还可以通过逆向工程破解全部的游戏内容,这些都是无法预防的。

  • 不能严格保证游戏数据变更的安全性(可以作弊)

    正如前文所述,玩家可以通过修改内存数据来作弊,要保护游戏数据的安全性是很困难的。这样造成的结果就是:原本需要玩家花费数百小时,长时间培养游戏角色的游戏方式,以及具有精心创造的宏大游戏世界,能让玩家在不知不觉中消耗大量时间和精力的游戏方式就难以实现了。对玩家来说,这种经过长时间游戏积累的数据一旦损坏或者丢失将是难以承受的损失,游戏体验的品质也就不能得到保证。导致的结果就是 P2P MO 游戏的游戏时长较短并且以游戏内容为中心。

  • 不能进行 P2P 连接的 NAT 网络环境有很多

    玩家之间不能通过端对端方式进行网络连接(TCP 或者 UDP 的连接方式)的情况有很多。比如在公司局域网游戏时,或者使用公寓楼、大学、当地的有线电视网络等通过路由器构建的 NAT 公共网络。在日本,这种情况大概占到 10%~30%。另外还有因为 P2P 通信量的异常增大而禁止直接发送数据的情况。这种案例最近有所增加。利用这些网络环境的玩家根据所在地区的差别又会出现各种各样的情况。游戏内容针对的目标玩家不同、上市时间、游戏主机等各种因素的复杂组合导致了 NAT 问题的发生。为了解决诸如 UPnP3、SOCKS4、UPD Hole Punching(后面章节会介绍)等问题考虑了各种方法,但是还是不能完全解决。IPv6 网络普及之后也许情况会有所好转,但前景还不是很明朗。

  • 不能简单地更新游戏

    P2P 游戏的更新比较麻烦,一般要向玩家发布软件更新包并安装到 PC 来升级硬盘中的游戏程序。所以可能会有玩家在玩老版本的游戏。Steam 这类新的下载平台虽然可以解决这个问题,但是因为在不能访问网络的情况下一个人也可以玩,所以玩家往往选择不升级继续在离线的情况下玩老的版本。

  • 不方便结合社交网络等网络服务

    因为游戏程序不联网也可以玩,所以游戏结果就不能发布到社交网络上。

  • 玩家掉线 5 的情况比较多

    因为没有一直在线的服务器,游戏的通信依赖于玩家的机器,如果玩家突然关掉电源或者突然结束运行中的游戏就会导致通信中断。这种情况,需要同步玩家之间的游戏数据,同步显然不能花很长时间,所以如果数据量过大是不可行的。

2可以通过 DVD 或者 Steam(第 6 章会说明)之类的下载服务进行完整安装。

3Universal Plug and Play。PnP(Plug and Play) 的网络版协议,http://www.upnp.org/

4在传输层进行访问控制的安全协议。参考 RFC 1928。

5玩家掉线也叫 churn。

5.1.4 P2P MO 游戏的优点

上面列举了 P2P MO 游戏复杂的地方,不过这类游戏也有很多优点。

  • 延迟较少

    所有的操作都是直接在玩家的机器之间通信,因为不通过服务器,数据不需要在互联网上通过各种路由器来传输,所以网络延迟较少。这样就比较适合动作类游戏的实现。

  • 游戏服务器的带宽负荷低(甚至没有负荷)

    P2P 游戏中的数据都不需要通过服务器传送,所以服务器带宽的负荷很低,甚至没有负荷。只有 MMO 游戏带宽负荷的几十分之一到几百分之一,几乎到了可以忽略不计的程度。

  • 游戏服务器硬件成本低(甚至零成本)

    正如前文所述,因为不需要管理游戏数据的服务器程序,所以也就不需要配置专门的游戏服务器,也就不存在服务器运营的成本。

  • 服务器维护期间也可以正常进行游戏

    服务器(辅助系统的服务器)在维护期间,玩家也可以正常进行游戏。不过积分榜或者玩家匹配等功能还是需要正常的网络连接,这些辅助系统相关的内容会在第 6 章介绍。

5.1.5 从概要设计开始考虑[多人游戏模式]

考虑到之前所描述的那些和 C/S MMO 游戏的不同,在概要设计一开始就要有多人游戏的意识,必须针对这些特点进行设计。游戏设计师如果只是专注于自己的设计而忽略 P2P 技术开发这个前提,在之后实际开发时就会遇到各种问题,导致项目失败,甚至重新开发也不行,遇到这样严重问题的案例有很多。

开始编程时,P2P 游戏可以先制作单机版本。之后再使用共享内存技术开发多人游戏部分。但是,如果游戏设计开始就没有考虑到多人游戏的情况是不行的。

5.2 J Multiplayer 游戏开发案例的学习——和 K Online 的不同

本章以 J Multiplayer 这个假想游戏为案例,学习如何设计以及采用共享内存方式的实现,与第 4 章的 K Online MMORPG 游戏的不同点也会进行对比说明。

5.2.1 J Multiplayer ——和 K Online 的比较

J Multiplayer 是一个共享内存开发方式游戏的学习案例,让我们选择适合的游戏方案来进行设计。这里假定以《暗黑破坏神》这种 ARPG 为基础,因为笔者的相关经验较丰富,而且也容易和 MMORPG 游戏进行比较,具体的设计内容之后说明。为了便于与第 4 章的 C/S MMO K Online 的游戏设计内容进行比较,这里也使用同样的设计流程。

5.2.2 P2P MO 游戏开发的基本流程

P2P MO 游戏开发的基本流程和 C/S MMO 相同。不过和 MMO 相比,服务器端代码相对较少,开发的迭代速度也比较快。此外还可以采用单机游戏的开发方式,一边开发一边试玩。从这个角度来说,P2P MO 游戏开发的风险相对较低。

5.2.3 P2P MO 游戏开发的交付产品——开发各个阶段需要提交的资料

P2P MO 游戏开发项目的资料 / 交付产品中不包括服务器相关(游戏程序)的部分,DB 设计图等除了辅助系统几乎不需要。另外服务器端的单元测试,运营工具的准备等与 C/S MMO 游戏相比规模都很小,所以在交付产品中所占比重较小。

另外,在第 4 章 C/S MMO 游戏中,大概分为以下几个阶段来介绍提交的资料。

  • 框架开发阶段

  • 原型阶段

  • 商用版本阶段

通常,P2P MO 游戏会先开发单人游戏。不用像 MMO 游戏开发那样详细设定游戏框架开发阶段,因为这种开发方式在游戏业界已经很少见了。当然如果游戏的题材新颖、有创新性的话,对这些创新部分的详细技术验证还是有必要的。

和 MMO 不同,MO 游戏的开发顺序可以先制作单人游戏(原型),商业游戏也会先从单人游戏开始,然后追加网络功能,以及商业化相关的辅助系统。不过根据游戏题材和类型的不同,比如遇到对战格斗游戏等对网络延迟要求较高的情况时,开发完单人游戏后,会进行商业化版本前的网络版本的验证,然后再进行商业化版本的开发。

之后是辅助系统的开发,比如玩家匹配系统、积分榜、玩家之间的交流功能等,这样就更接近最后发布的商业版本了。具体内容会在第 6 章详细说明。

和 C/S MMO 游戏一样 MO 游戏也需要概要设计书。

概要设计的详细资料

在第 4 章中,我们参考了 Runescape 这款游戏,这里我们以著名的 P2P MO 游戏《暗黑破坏神》为例,开发一款同类型的游戏。由于篇幅所限,这里就不再对《暗黑破坏神》进行详细描述。在模仿某款游戏之前,应该花大量时间去试玩,然后再参考相关英语维基百科或者其他玩家的游戏视频,这样初期的开发就没有什么问题了。

从这些公开的资料中我们可以整理出以下设计要点。本书的案例游戏 J Multiplayer 的目标是实现《暗黑破坏神》类型游戏的核心玩法部分,不会完全照搬。核心玩法部分主要是指在单元格类型的地图上,可以控制玩家角色攻击不断出现的敌人获得经验值。

以下要点之后会作为 J Multiplayer 游戏框架开发的功能列表。

  • 一张游戏地图

  • 敌人在本地机器上移动

    • 暂定一种类型的敌人

    • 不同步全部的敌人

    • 离玩家角色最近的优先移动+攻击

    • 同一层有数十到数百只怪物

    • 路径搜索暂时不做

  • 玩家角色

    • 暂定一种类型

    • 直线移动,数据同步

    • 只攻击指定的目标

    • 生命值(HP)和经验值(EXP)随等级上升

  • 有房屋和门,在房屋和走廊里随机动态生成敌人

  • 只能重新启动游戏来重置游戏状态

  • 原型阶段不添加玩家匹配和聊天功能

  • 同一台电脑启动多个游戏程序时使用不同的端口号

  • 游戏启动时,可以指定 IP 地址和端口号

完成上述功能后,最基本的游戏玩法就可以实现了。

  • 敌人会联合起来一起攻击,如果是单个敌人,会自杀式攻击

  • 有 2 个敌人时,同伴可以分工合作各个击破

  • 门被打开后,同伴也可以通过

为了实现以上概要设计,需要在详细设计阶段将这些要点反映在设计中。

5.2.4 和 C/S MMO 的数据量/规模的比较

J Multiplayer 游戏中,一层关卡的地图和敌人的分布情况可以参考图 5.1。

图 5.1 J Multiplayer 的地图和敌人分布

{%}

图 5.1 是 J Multiplayer 中一层关卡的整体分布图,包括了敌人、入口、出口、Boss 等的分布情况。地图由几百 × 几百的单元格组成(图 5.1 可以看到部分)。这些地图单元格的信息大概几十字节。客户端程序 的内存中只有地图信息。

K Online 有非常大的游戏世界,各个地方都分散着不同的玩家,所以游戏世界的信息有几百兆,没有必要都存在内存中。在 J Multiplayer 中,游戏玩家最多也就 6~8 人,每个人必要的关卡数最多只有几个,这些关卡信息乘以玩家数量得到的总内存消耗也就几百千字节 × 8,最多不过几兆字节而已 6

6因为有好几兆字节,所以可能需要传输所有玩家相关的信息和全部关卡的信息。

当然,如果游戏的设计在不同的关卡频繁移动,每个关卡逗留的时间很短的话,必要的内存使用量有增加的可能性,这种情况需要特殊考虑。

如上所述,J Multiplayer(P2P MO)和 K Online(C/S MMO)相比,使用的数据量有很大不同,所以程序的设计必然也不一样。当然,数据量 / 规模的大小根据游戏题材和设计的不同也会不一样,所以程序员需要正确理解游戏概要设计的内容。

5.3 P2P MO 游戏的设计资料

P2P MO 类型的游戏和 MMO 类型游戏不同,不需要使用服务器来处理游戏数据,也没有必要估算运营成本,所以需要准备的设计资料比较少。这里简单说明应该准备的最少限度的资料。

5.3.1 系统基本结构图

首先作为比较,我们来回顾下 C/S MMO 类型游戏的系统瓶颈。请参考第 4 章图 4.1。第 4 章对 C/S MMO 系统容易产生性能瓶颈的 ❶~❹ 点做了详细说明。

但是,P2P MO 类型游戏没有专用的服务器,所有玩家只在共同游戏的客户端之间进行通信,图 5.2 右侧 ❸、❹ 点那样的性能瓶颈理论上是不会发生的,所以只需要考虑客户端的图像处理性能和玩家所使用的网络带宽。

图 5.2 P2P MO 系统和可能产生性能瓶颈的地方

在设计 K Online(C/S MMO)时格外担心,所以设计师们会在全部服务的系统基本构成图上,标注随着用户的增加会如何产生什么影响以及哪些部分的压力会增长(参考图 4.9)。J Multiplayer 没有服务器端的处理,需要准备服务器的只是和游戏内容没有直接关系的辅助系统。参考 K Online 制作的系统基本构成图如图 5.3 所示。

图 5.3 J Multiplayer 的系统基本构成图

详细内容会在第6章说明,图 5.3 中玩家匹配系统、中继服务和存储服务等各种服务会在不同的服务器上运行。实际的开发中,这些辅助系统都不需要自己开发,索尼、微软和任天堂等企业都提供了这些服务,可以直接使用。

5.3.2 进程关系图

C/S MMO 类型的游戏,包含的进程有客户端程序、游戏服务器、登录服务器、验证服务器和数据库服务器等,按不同的功能划分有十几种。它们之间的关系需要详细定义。但是对于 P2P MO 游戏来说,只有游戏客户端一种,所以可以不用制作系统关系图的资料。

开始开发后,后期比较难更改的部分如下:

  • 使用星状拓扑结构还是网状托普结构(请参考第 3 章图 3.12、图 3.11)

  • 同步、非同步还是网页方式实现

这两点从一开始就需要考虑清楚。

关于第二点,因为 J Multiplayer 是在互联网上通信所以不能使用同步的方式,而网页方式又没有那么多玩家参与,所以采用非同步的方式实现是较为合适的。

相对麻烦的是网络拓扑结构的选择,到底是用星状拓扑还是网状拓扑。

星状拓扑结构还是网状拓扑结构

星状拓扑结构和网状拓扑结构的区别在于,共享全部客户端程序(玩家机器上运行的游戏程序)的数据所必须的通信链路连接数。星状拓扑结构是 2,网状拓扑结构是 1。星状拓扑结构的客户端之间通信需要经过主机,而网状拓扑结构不需要。所以,经过互联网的通信延迟除主机之外,玩家机器之间理论上都有两倍的延迟。但是,网状拓扑结构除了网络的 NAT 问题之外,互相之间不能通信的玩家也比较多,会有无法进行游戏的情况。

因此,就网状拓扑结构来说,只有对反应速度要求较高的对战格斗游戏、街机游戏或者任天堂 DS 等使用 ad-hoc 通信的掌机等,没有 NAT 问题,可以保证通信延迟在几微秒之内的情况下才使用。

另外,在星状拓扑结构中,根据游戏内容的不同,如果有排他限制的功能时,可以在主机的处理程序中实现相应功能,程序也比较简单。

首先来验证星状拓扑结构

通过以上分析,我们可以知道在开发 P2P MO 类型游戏时,基本的流程是首先验证能否采用星状拓扑结构,在追求较快响应速度的特殊情况时再验证网状拓扑结构。面向互联网用户的游戏基本上都是采用星状拓扑结构。

本章的案例游戏 J Multiplayer 也是采用星状拓扑结构实现。也就是说,在玩家中由一人作为主机,剩下的玩家作为客户端。如图 5.4,作为主机的玩家首先初始化并开始游戏。之后其他玩家再加入这个游戏,直到人数达到上限。

图 5.4 游戏开始和玩家(进程)的加入

{%}

中途加入游戏的实现

不论是星状拓扑结构还是网状拓扑结构,都可以实现途中加入游戏的功能。不过星状拓扑结构相对容易实现。

首先,我们来看一下星状拓扑结构的做法。在主机的游戏程序中已经保存了排他限制的相关信息以及所有游戏运行数据,当新的玩家加入后,只需要下载所有的数据就可以保持最新的游戏状态。采用网状拓扑结构时,需要先确认游戏内容都没有排他限制后再直接和其他游戏客户端建立连接。比如典型的赛车游戏,流程如下:❶ 首先和所有的游戏客户端建立连接。❷ 在其他游戏客户端初始化并显示新加入玩家的赛车。❸ 开始传输新加入游戏玩家的操作数据。❹ 更新所有游戏客户端中新加入玩家的赛车位置信息。

实现中途加入游戏功能时,需要传输初始化游戏所需要的数据。在游戏进行的同时传输数据,对客户端程序的处理和带宽有一定要求。如果数据量特别大,则无法实现中途加入游戏的功能。另一种做法是让之后参加游戏的玩家处于等待状态,直到下一个合适的时间点再进入游戏。比如可以在一局游戏尚未结束时禁止新的玩家加入。一般中途参加游戏对程序的负担比较大,增加这个功能时需要慎重考虑。

无论是星状拓扑结构还是网状拓扑结构,根据游戏内容的不同,中途参加游戏功能的开发还有很多难点。就拿赛车游戏来说,信号灯变绿表示游戏开始,实现比较简单,也容易理解。其他的桌游,比如麻将,从游戏内容上来说并不适合中途参加游戏。即时战略类(RTS)游戏也是如此,游戏初期状态和开局非常重要,中途参加游戏很难实现。但是第一人称射击类(FPS)游戏就比较容易实现途中参加游戏的功能。所以根据游戏的题材和设计,途中参加游戏功能的开发难度和工期也有很大不同。

采用星状拓扑结构时,如果游戏进行时主机连接中断,所有的玩家就会失去连接,变成单人游戏状态(图 5.5)。比较典型的情况是由 AI(电脑)代替其他玩家的操作。有些游戏在主机掉线时,其他玩家可以变成主机,并允许新的玩家加入。

图 5.5 正常状态和主机终端时的状态

{%}

与此对应,使用网状拓扑结构时,各游戏程序之间没有依存关系,任何人失去连接也不会有影响。

另外,在实际的商业化游戏中,我们还需要连接玩家匹配服务器、认证服务器等辅助系统,相关介绍请参考后面的章节。

5.3.3 带宽 / 服务器资源计算资料

不需要考虑服务器数量的预算。在服务器端也没有使用专用的网络,除了后面章节会介绍的辅助系统之外也不用考虑带宽的情况。

5.3.4 通信协议定义资料和 API 规格

C/S MMO 类型的游戏需要定义不同进程之间的通信协议,对于 P2P MO 游戏来说,只有游戏客户端一种,所以通信协议也比较简单。

就拿本章的案例游戏 J Multiplayer 来说,除了辅助系统以外游戏只需要处理 ping、getid、guestid、sync 和 delete 这 5 种函数,这其中比较重要的 sync 和 delete 在客户端和服务器端都需要使用。

通信协议的序列图

在 C/S MMO 游戏中,从客户端→游戏服务器→数据库服务器,数据会经过多层服务器,需要保持其一致性。但是在 P2P MO 游戏中,如果是星状拓扑结构,基本上只有主机和客户端之间的通信,不需要画序列图进行详细确认。

函数和常量的定义

J Multiplayer 的通信协议中定义了一下 5 种操作类型。

  • ping、pong

  • getid

  • guestinfo

  • sync

  • delete

  • ping 函数和 pong 函数

    ping 和 pong 函数用来验证主机和客户端直接能否通信,以及网络延迟情况。

    void ping(U64 guestclock);    ←客户端调用的函数,参数为 64 位无符号整数
    void pong(U64 hostclock, U64 guestclock);    ←主机调用的函数
    
    

    如上述例子所示,客户端和主机之间交换时间。客户端和主机都分别管理着自己的时间。在调用 ping 函数时,将程序启动后经过的时间(单位是毫秒)传递给主机,然后主机通过 pong 函数返回主机的时间。如果网络延迟大于一定数值会显示警告信息,并断开与主机的网络连接进入单人游戏模式。

    P2P MO 游戏没有服务器也可以进行游戏,所以大部分游戏会在遇到网络问题时转入单人游戏模式,这也是在游戏逻辑上与 C/S MMO 游戏不同的地方。

  • getid——ID 管理池

getid 函数用来分配在游戏房间内的唯一 ID,使用了 ID 管理池的方式实现,占用位数少。

void getid(U16 num);
void getid_result(U64 ids[1000]);  ← getid_result 函数返回新的ID

ID 管理池用来管理游戏房间内可动物体的唯一 ID,是一种常用的方法。ID 管理池的示意图如图 5.6 所示。

图 5.6 ID 管理池的示意图

{%}

获取特定进程内唯一 ID 的方法很简单,只需要计数器逐渐累加即可。但是如图 5.5 那样有主机和客户端两个进程同时运行时,因为两者的计数器之间并无联系,所以可能会分配到相同的 ID。为了避免这种问题,有两种解决方式。

专栏 什么是“游戏逻辑”

游戏逻辑(Game Logic)一词在本书中经常出现,目前还没有准确的定义 7

7本书撰写时(2010 年 10 月),在维基百科(Wikipedia)的英语版和日语版中都没有该词的解释。翻译时也没有(2013 年 3 月)。——译者注

本书中该词的意思和 Web 程序中经常使用的业务逻辑(Business Logic)类似。在 Web 程序开发中,业务逻辑是指“控制数据库和用户接口之间的数据操作的逻辑”,在游戏开发中,包含以下两点。

  • 游戏运行时的数据:以象棋为例,指棋子的分布情况,和数据库相关联。

  • 用户界面信息:还是以象棋为例,在游戏界面中什么地方应该绘制什么颜色,鼠标可以在什么地方点击等信息。

游戏逻辑就是这些数据背后相应的控制逻辑。

比如下面这一系列的游戏规则。

  • 游戏开始时棋子的位置应该怎么摆放。

  • 不同棋子可以移动的位置。

  • “步”(也称步兵)只能纵向走一格。

  • 不能将己方棋子放在对方棋子之上。

只有遵守一定的游戏规则才能保证游戏的乐趣。

和 Web 程序开发一样,游戏逻辑部分和其他模块之间的界限并不十分明确。所以开发的时候,需要尽可能的让逻辑部分和数据库以及用户界面分离开来,这样才能称之为游戏逻辑部分。

  • 方案 1:ID 由 [ 进程 ID, 程序内部 ID] 两部分组成。例如,host 的进程 ID 是 0,客户端是 1 的话,[0,1] 和 [1,1] 就构成了不同的 ID。这种方法可以叫做进程 ID 法。

  • 方案 2:客户端的 ID 都通过主机分配,由主机的 ID 管理池分配,并自动累加。这种方式叫做 ID 管理池。

ID 管理池是第二种方式 8。可以在最开始采用方法 2 获取了起始 ID 后,再用方法 1 获取之后的 ID。

8理论上兼容第一种方式(大的兼容小的,ID 管理池是大的)。

第一种进程 ID 法在获取最初的进程 ID 时也需要访问主机。例如,使用两个变量 [U32 型的进程 ID,U32 型的内部 ID] 的情况和使用 ID 管理池时,[ 在最开始的时候一次性分配 32 位,也就是 42 亿个 ID] 是差不多的。所以 ID 管理池和进程 ID 法在实际的程序开发中的工作量几乎没有区别。

笔者比较偏向于使用 ID 管理池的方式,因为 ID 可以使用一个原生数据类型(U64 或者 U32)保存,代码简单高效,节省内存性能优异,而且 ID 的位数比较小。ID 的读取、搜索会使程序的循环处理比较多,所以应该尽可能让 ID 的构成简单化,只有一个变量会比较方便使用。

不管是进程 ID 法还是 ID 管理池的方法,如果客户端长时间不能访问主机,会造成不能生成新的 ID 的情况。所以需要根据游戏的内容来决定采用什么管理策略。假设游戏中每秒新产生 3 个敌人,游戏时间在一个小时左右,如果能够保证生成 10 万个 ID,理论上就基本没有问题了。

前面的例程所示生成新 ID 的 getid_result 函数可以返回 ID 列表,例如,一次申请 6 个 ID 时,就会返回连续的 ID[100, 101, 102, 103, 104, 105]。不过这样会造成带宽的浪费,可以使用 start=100、end=105 的方式,让返回结果更经济高效。

  • guestinfo

    guestinfo 函数是主机用来分配所在游戏空间内与主机相连接的客户端的唯一 ID。

    void guestid(U32 id);
    
    

    这个函数是主机告诉所有客户端他们各自的唯一 ID。

    对于一个 ID 来说,理论上主机知道它是客户端申请的还是主机自己使用的,不过逐个搜索会比较慢,所以在物体移动时,会在所有的同步数据包内包含控制该物体移动的 guestid,这样就可以省略搜索了。主机可以使用 guestid 函数来告诉客户端该唯一 ID。在 J Multiplayer 游戏中,主机的 guestid 默认为 0,客户端是 1 以上的值。

  • sync

    sync 函数用来同步可动物体在移动时的数据。可动物体出现时,根据之前不存在的 ID 所对应可动物体的移动来判断。

    void sync(U32 guestid, U32 id, U8 data[200]); ←一个对象的变化在200 字节以内表达
    
    

    guestid 是客户端的 ID,id 是可动物体的 ID,在整个游戏房间内是唯一的。data 是 2 进制的数组,格式如下所示,大小限制在 200 字节以内。

    data:X 列
    X: [1 字节,表示变量类型 ][1 ~ 4 字节,数据 ]
    
    

    J Multiplayer 游戏包含 1 字节的 U8、4 字节的 I32 和 4 字节的浮点数(Float)。很多游戏没有使用 8 字节的双精度浮点数(double)类型,这是因为和精度相比更重视节省带宽。大部分游戏也不需要使用这种双精度浮点数的数据。

    另外,在 J Multiplayer 中,“表示变量类型的 ID”(类型 ID)使用了以下枚举变量定义。

    typedef enum {
      SVT_TYPEID,
      SVT_COORD_X,
      SVT_COORD_Y,
      SVT_DELTAPERSEC_X,
      SVT_DELTAPERSEC_Y,
      SVT_GOAL_X, SVT_GOAL_Y,
      SVT_TOSTOP,
      SVT_TODELETE,
      SVT_TARGETID,
      VT_HP,
      SVT_MAXHP,
      SVT_MP,
      SVT_MAXMP,
      SVT_EXP,
      SVT_LEVEL,
      SVT_SHOOTER_ID,
      SVT_HITTYPE,
    } SyncValueType;
    
    

    例如,在主机上新生成了可动物体,分配的 ID 是 45,调用 sync 函数同步客户端数据,参数为 guestid=0、id=45。客户端首先在内存中搜索,如果 id=45 的物体不存在,就新建该物体;如果存在该物体,就使用服务器端发送来的数据替换本地内存的数据。

  • delete

    delete 函数用来删除可动物体的信息。例如主机要删除45号可动物体则调用 delete(0,45),向各个客户端发送该消息。

    void delete(U32 guestid, U32 id);
    
    

5.3.5 带宽消耗量的估算

下面我们来看一下带宽消耗量的估算。J Multiplayer 运行时的画面如图 5.7 所示。和 C/S MMO 的感觉差不多,图像的质量这里暂且忽略。

图 5.7 J Multiplayer 运行时的画面

{%}

在图 5.7 中,白色的为玩家控制的角色,黑色的是敌方角色,小圆点表示子弹。敌人左上方的数字表示调试时可动物体的 ID。从图 5.7 中可以看到可动物体共计 8 个(玩家角色、敌人角色、子弹)。

在游戏设定中,实际运行时可动物体的峰值大概是 40~50 个(如图 5.8),如果包括屏幕外的可动物体,最多有 1000 个左右。这个游戏是一个会与大量的敌人作战的游戏。在实际的即时战略类游戏(RTS)中,可动物体一般没有 1000 个。

图 5.8 峰值时的 J Multiplayer 的画面**

{%}

该游戏客户端运行时每秒帧数为 60,也就是说,这 1000 个可动物体每秒可以动 60 次。

根据前面的协议说明大部分是调用 sync 函数更新坐标,即计算坐标后调用 sync 函数,如下所示。

[UPD/IP 标头 20 字节 ] [ 函数标头 4 字节 ] [U32 guestid] [U32 id] [U8 变量类型 ID]
[Float4 X 坐标 ] [U8 变量类型 ID] [Float4 Y 坐标 ]9

9Float4 表示 32 位浮点小数。

函数标头包含函数 ID 和数据长度等信息,总共 4 字节。

60×1000×(20 + 4 + 4 + 4 + 1 + 4 + 1 + 4)× 8 位 = 20.0Mbit/s10。4 人同时游戏时,主机需要和 3 个客户端通信,通信负荷为 20.0Mbit/s × 3 = 60 Mbit/s(!)。即便是光纤用 户也会比较紧张。11

1060 表示帧数,1000 是运动物体数。

11开发中常见的失误是在公司内部局域网测试,一直到网络公测时才发现在实际的网络中会有带宽的问题,所以要注意避免这样的问题。

一般的游戏机要求通信量在 50~150kbit/s 左右。大部分用户使用的是普通宽带,实际的带宽达不到几 Mbit/s 这种程度。

“1/600”的实现方法——仔细检查游戏设计寻找解决方案

假如想让主机通信量在 100kbit/s,需要压缩“1/600”左右。这个目标可以实现吗?遇到问题时,我们可以采用相同的办法。即“仔细检查游戏设计寻找解决方案”。这里先不考虑 J Multiplayer 的游戏内容是否有趣,从开发角度可以采用下面的分析步骤。

  • 方案 1:是否可以不同步和自己无关的敌人信息?

    →这个是 P2P MO 游戏必须要考虑的问题。在 J Multiplayer 游戏中,敌人按照下面的顺序移动。

    • 受到玩家角色攻击时,锁定该角色并追击,在一定时间后恢复原状态。

    • 就算没有受到攻击,只要玩家靠近就追击玩家。

    J Multiplayer 的基本游戏玩法是同追击自己的敌人战斗。所以没有追击玩家的敌人就可以不用同步相关信息。这不是“极端情况的处理”,而是在实际开发中应该选择的,也是技术上经常采用的方式。

    分析的结果就是需要同步一部分敌人的信息,但是没有追击玩家的敌人可以不用同步。此外,根据第 3 章的结论,追击玩家的敌人在玩家机器的进程内移动会有更好的游戏体验。

    在实际开发时,两个客户端在同一时间的游戏截图请参考图 5.9,玩家的位置相同,但是敌人的位置不一样。两个画面放在一起比较我们会觉得游戏体验不一样,但实际上是两个不同玩家通过网络进行游戏,不存在体验不同的问题。J Multiplayer 游戏的基准是“敌人数量一致”,只需要确保敌人出现和死亡的时间一致。这样就可以只在出现和删除时进行通信,从而减少大量因为移动而产生的数据通信。

    图 5.9 玩家位置一样但是敌人的位置不同

    {%}

    根据这个结论,假设 1000 个敌人的构成如下所示。

    • 200 个追击玩家 A(主机)

    • 200 个追击玩家 B(客户端)

    • 200 个追击玩家 C(客户端)

    • 200 个追击玩家 D(客户端)

    • 200 个空闲敌人,随机分布

    上述情况,对于主机玩家 A 来说,应该向客户端玩家 B 发送什么信息呢?首先追击玩家 A 的敌人信息是不需要向 B~D 玩家发送的。追击 B 的敌人是在 B 客户端上运算,所以也不需要发送。这样就只需要同步剩下那 200 个空闲敌人的信息。

    这样一来就成功的减少了“1/5”的通信量。在带宽上可以削减 12Mbit/s,而且游戏体验也更好了。另一方面,正如第 3 章所讲,游戏的策划内容中说明了除 Boss 角色之外的敌人在出现后会很快死掉,所以也不需要同步每个角色。如果是那种角色之间相互影响,角色寿命也比较长的游戏就不能使用这个方法了。

    现在距离 100kbit/s 的目标还需要减少 1/120……

  • 方案 2:距离远的敌人信息可以不同步吗?

    J Multiplayer 游戏的地图比较大,没有显示在屏幕上的地方有很多,所以可以每次只同步离玩家近的敌人信息,距离远的偶尔同步一次。通过观察图 5.10 的游戏画面,对于屏幕外一定距离的可动物体,每一帧都同步,其他位置的物体每 100 帧同步一次,这样又可以减少大约一半的通信量。现在是 6Mbit/s,距离 100kbit/s 还有 1/60……

    图 5.10 画面显示区域 / 非显示区域和敌人的移动

    {%}

  • 方案 3:1/60 的话……那可以每 60 帧同步一次吗?

    这样就可以一下减少到 1/60 了……不过需要仔细考虑才行。同步频率降低可能会大幅度影响游戏体验。

    首先,J Multiplayer 中的角色移动很快,每秒移动 4 格。60 帧同步 1 次的话就是 1 秒通信一次。因为移动 1 格是 0.25 秒,1 秒最少可以移动 3 次。如果“向右移动一步再向左移动 1 步”,可能会显示成“没有移动”。这是游戏无法接受的情况,应该“每秒至少同步 5 次”。1 秒 5 次就是 12 帧同步一次,这样可以一下减少到 6Mbit/s/12 = 500kbit/s。

    但是,如果 12 帧才更新一次位置信息,会造成“移动时的画面跳动”,看上去会不舒服,为了有更流畅的移动效果,需要加入“角色移动时的坐标补充”处理 12

    再“削减 1/5”就到 100kbit/s 了。

  • 方案 4:可以只同步变化的数据吗?

    J Multiplayer 中的敌人角色不是一直在移动。处于待机状态的情况也很多,特别是敌人刚出现时大部分是待机的。而且斜着移动的情况也比较少,多数是上下左右十字方向的移动。如果“只在 X 坐标改变时”或者“只在 Y 坐标改变时”同步,也可以减少通信量。在屏幕显示的部分,游戏运行画面中移动的物体(需要同步的)只有原来的一半。这个方法效果也很好,“500kbit/s 的一半”是 250kbit/s,还差最后一点。

  • 方案 5 子弹的处理可以优化吗?

    J Multiplayer 中的子弹是直线飞行。所以知道了发射的时间、方向和速度,只需要一次同步,之后的运行是可以计算的。调用 sync 函数同步子弹信息时,只要在数据包内添加发射时间就可以了。时间差可以通过 ping 函数调整。这个方法有一个问题,就是在子弹发射后进入游戏的玩家会看不到之前的子弹。不过这个问题不大,可以忽略不计。根据游戏画面观察和计算,游戏运行时子弹的数量大概占可动物体的 20% 左右,所以效果应该很明显。

    现在的通信量是 250Mbit/s ×0.8 = 200kbit/s,还差一半。

  • 方案 6:数据包可以优化后再发送吗?

    现在 sync 函数的格式如下。

    [UPD/IP 标头 20 字节 ] [ 函数标头 4 字节 ] [U32 guestid] [U32 id] [U8 变量类型 ID]
    [Float4 X 坐标 ] [U8 变量类型 ID] [Float4 Y 坐标 ]
    
    

    上述格式中 [UPD/IP 标头 20 字节 ] [ 函数标头 4 字节 ] [U32 guestid] 是冗余部分,不包含坐标值,每次都重复发送同样的数据。UDP 数据包最大可以传输 1500 字节的数据,所以

    [UPD/IP 标头  20 字节 ] [ 函数标头 4 字节 ] [U32 guestid] [ 剩余的数据 ]
    
    

    数据包的头 28 个字节只需发送一次,剩下的 1472 字节用来传输移动坐标,我们假设一下最坏的情况,所有的可动物体 X 和 Y 都发生变化。

    [U32 id] [U8 变量类型 ID] [Float4 X 坐标 ] [U8 变量类型 ID] [Float4 Y 坐标 ]
    
    

    (4 + 1 + 4 +1 + 4)= 14 字节,应该可以削减不少。

    全部按低压缩率、X 坐标和 Y 坐标都变化的情况下,1 个可动物体的数据是(20 + 4 + 4 + 4 + 1 + 4 + 1 + 4)= 42 字节,用新的方法只需要(4 + 1 + 4 +1 + 4)= 14 字节,差不多可以压缩“1/3”。标头的大小在 1500 字节中比重很小,可以不用计算。

    我们将按这个协议通信的函数名定义为 sync_multi,通过调用

    void sync_multi(U32 guestid, U32 id_array[100], data_array[]);
    
    

    可以实现数组方式的通信。

12Web 上也叫做 Tween 移动。

* * *

这样 200kbit/s / 3 = 70kbit/s 左右,还能有一些缓冲空间,真是可喜可贺……

  • 方案 7:敌人少时多发送一些数据

    游戏开发者的特点就是不会仅仅满足于达到通信量的需求。已经确保在敌人最多时也不会发生数据传输的问题,那么在敌人少时就能多同步一些信息,给玩家提供更准确的游戏状态。可以按照以下方式划分:敌人数量在 50 个以上时,每 12 帧同步一次;20 个以上时,每 6 帧同步一次;10 个以上时每 3 帧同步一次;10 个以下时每 2 帧同步一次。

* * *

采用了上述 6 种方案终于让 J Multiplayer 游戏达到了 100kbit/s 以内通信量的要求,不过还有很多可以探讨的地方。最极端的一种考虑就是“所有装饰性的可动物体都不同步”,J Multiplayer 游戏中的通信基本都是用来同步“四处分布的敌人”的信息,其他的物体可以忽略不计。但是如果在游戏中连四处分布的敌人信息也不同步,就没有多人游戏的意义了。TPS(Third Persion Shooting)——第三人称射击类的动作类角色扮演(ARPG)游戏的基本原理也是类似的。

尽管我们反复叙述了原理,但是如果不仔细理解游戏内容,在实际开发中还是会碰到很多问题。

5.3.6 其他资料

在此前章节,我们已经详细描述过通信协议定义资料中关于数据包格式、数据大小以及必要的标头等的相关信息,此处不再赘述。

另外,DB 设计图只在排行榜和玩家匹配系统中使用,属于辅助系统的范畴,在后面的章节中,我们会详细说明。

5.4 客户端 / 服务器软件 + 中间件、基本原则

此前章节已经说明了如何准备 P2P MO 游戏开发中最基本的设计资料。本节我们开始学习如何开发实际交付的程序。还是像第 4 章 C/S MMO 的开发案例一样,我们先来确认一下在开发客户端 / 服务器软件和中间件时,程序中应该注意的基本原则。

5.4.1 P2P MO 开发的最终交付产品

P2P MO 游戏和 C/S MMO 游戏的最终交付产品有很大不同。首先就是服务器端程序和客户端程序并没有分开。

笔者开发 J Multiplayer 的原型时使用的文件结构如下所示。客户端程序和服务器端程序最终被编译成了一个可执行文件,所以只有一个文件夹。通信协议的定义文件(j.xml)也只有一个,和可执行文件放在同一位置,没有测试用的机器人程序。

  • 编译用

    • Makefile:笔者使用的是 UNIX 的操作系统,通过 Emacs 调用 GNU Make 编译程序。也可以使用 IDE(集成开发环境)。
  • 源代码

    • app.cpp、app.h:源程序文件,包含每帧的实际处理程序。

    • floor.cpp、floor.h:管理内存中地形数据的类。定义了地图单元格的种类和大小,实现了碰撞检测等。

    • font.cpp、font.h:在游戏画面上控制字符显示。

    • game.h:明确了游戏中使用的地形、敌人种类、坐标值等游戏设定信息。

    • id.h:ID 管理池的实现。

    • movable.cpp、movable.h:可动物体以及其子类等的实现。

    • net.cpp、net.h:从网络接收数据后相关处理的实现,还有之后会提到的 SyncValue 类的实现。

    • sdlmain.cpp:在使用 SDL 开发程序时 main 函数的实现。

    • sprite.cpp、sprite.h:使用 SDL 开发的绘制小精灵的程序。

    • util.cpp、util.h:通用工具类代码。

  • 通信协议定义相关

    • j.xml:J Multiplayer 游戏中 5 种必要 RPC 的定义。

    • jproto.cpp、jproto.h:通过 j.xml 自动生成的 RPC 存根文件(stub file)。

  • 数据

    • fonts:绘制文字所需要的一些必要的英文字母、数字和符号的图像文件

    • images:存放玩家角色、敌人、子弹和地形相关图片资源的文件夹。

以上就是案例代码的文件结构。扩展名为 .cpp/.h 的代码文件一共 3600 行,其中包含 600 行自动生成的代码。商业游戏中的游戏处理会更多,3D 游戏的话代码量大概在 10 万~20 万行,2D 游戏在 5 万~10 万行左右。除此之外,图片等多媒体资源也可能会有几吉字节,这些不在本书的讨论范围内。

5.4.2 P2P MO 中使用的中间件

4.12 节介绍了 C/S MMO 游戏使用的中间件:全功能型、小规模型和通信中间件。但是 P2P MO 类型的游戏还可以使用 Quazal提供的 Net-Z,一款可以在 PC 或者游戏主机等各种平台使用的产品。此外,免费软件 GNE(Game Networking Engine)、Unity 等价格低廉的工具也具备必要的网络功能 13

13其他相关信息请参考如下网页:http://www.thefreecountry.com/sourcecode/games.shtml

另外,游戏主机平台也可以使用 SCE 或者 Microsoft 提供的强大的程序库,出于保密需要,这里不再详述。

5.4.3 编程时应该注意的基本原则——针对 P2P MO 游戏

在 4.13 节针对 C/S MMO 游戏列举了以下 4 个基本原则。

  • 数据结构优先原则

  • 保持游戏状态原则

  • 后台处理延迟原则

  • 连续测量原则

P2P MO 游戏因为没有后台服务器,所以除了第 3 条以外,其他原则都同样适用。

5.5 P2P MO 游戏 J Multiplayer 的实现——正式开始编程

接下来,我们就开始编写 P2P MO 游戏的程序代码。我们先来一起看一下如何分阶段编写程序。

5.5.1 J Multiplayer 的编程计划

和 MMO 类型游戏相比,P2P MO 类型的游戏只有一个程序,所以可以 1 个人开始原型的开发。

这里采用游戏开发中常用的方法,即将游戏逻辑、图像处理和数据处理等模块分开处理。如果有两个以上的程序员,不管在哪一开发阶段,负责开发游戏逻辑的程序员首先要理解网络部分的实现方式,然后再实现各自负责的模块。

特别是在开发初期阶段,如果开发的程序没有考虑网络功能的影响,之后再修改可能会比较困难。所以负责游戏逻辑部分的程序员在最开始一定要充分理解网络通信的设定和相关游戏设计,以防出现网络方面的问题。

另外 C/S MMO 游戏在开发客户端程序之前,需要先开发一个用来自动测试服务器的测试程序 autocli,而 P2P MO 游戏则是首先开发单人游戏部分,不用开发 gmsv,所以也不需要类似的测试程序。

如果是使用简单的矩形或圆形的 P2P MO 游戏原型,一般 1 个人用时一个月,熟悉之后,一周就能开发出可以玩的程序。

此外在描述 C/S MMO 开发的 4.14 节中提到的“不实际运行起来是不行的!——游戏开发的特殊性”这点,在 P2P MO 中也同样适用。

5.5.2 开发流程——K Online 的回顾

本章我们来开发 J Multiplayer 的原型。在此之前我们来回顾一下在开发 C/S MMO 的案例游戏 K Online 时过的流程。

定义通信协议(k.xml)。

初始化并通过 bot 测试 autocli 和 gmsv。

通过 bot 使游戏能够大致完整运行。

从 cli 着手,先实现图像绘制功能,再实现网络功能。

一边运行游戏一边添加和调整游戏功能。

5.5.3 J Multiplayer 开发阶段——开发顺序和内容

接下来,我们以开发备忘为基础,详细解释一下案例游戏 J Multiplayer 的原型开发工作。通过以下描述,我们可以了解和 C/S MMO 相比二者开发内容的一些不同。

首先确定通信方式及原型的开发目标,然后开始开发单人版游戏。接着验证小精灵和字体的绘制以及键盘的输入等。前文介绍的 C/S MMO 游戏的 cli 开发过程,在这里也可以沿用:

开发可以单人进行游戏的版本。

在单机版基础上实现具有 cli 连接和共享内存功能的多人版本。

最后添加玩家匹配、排行榜和交流功能,使其更接近商业化版本(参考第 6 章)。

5.5.4 第 1 阶段的要点

这里整理一下上述第 1 开发阶段的要点。作为参考,下面是以玩家视角记录的一些游戏内容。到了这一步必要的程序算法已经基本确定,之后就可以开始编写代码了。

  • 开发可以单人进行游戏的版本

    • 绘图 UI(从 cli 程序复制)

    • 单屏幕地图

      • TILE =32×32

      • CELL = 128 × 128

      • 玩家角色在屏幕中间

      • 点击鼠标的位置

    →如果是地面则移动到该位置

    →如果是敌人则发射子弹攻击

    →使用弓连续射击时会有冷却时间(冷却时间有上限)

    • 大的房间

      • 敌人 =100 个哥布林左右,1 种类型,用子弹攻击

      • 随机移动 + 玩家角色进入一定范围后开始追击 + 攻击

      • 敌人在本地机器移动

      • 不用同步所有敌人

      • 死亡时同步

      • 新生成时同步

      • 用子弹攻击时同步

      • 每 30 秒同步一次位置(大致 30 秒左右就行,不用太精准)

      • 敌人之间检测碰撞(子弹之间不用检测)

      • ID 唯一(毫秒单位的启动时间 + 计数器)

      • 不考虑路径探索,直线移动

    • 玩家角色(PC)

      • HP

      • EXP

      • LEVEL

    • 网络(Net)

      • 同一台机器可以启动多个客户端,可以通过参数直接指定 IP

5.5.5 客户端程序的开发案例

这个游戏中角色使用的 BMP 图像和前文提过的 cli(K Online 的 cli 程序、图 4.33)相同,所以游戏画面也类似。

如图 5.11 所示,画面中已经实现了显示背景和可动物体,1 个玩家角色来回走动,消灭追击的敌人并获得经验值。当然,如果是在同一台机器上启动两个客户端程序,相互之间也不会有任何影响。

图 5.11 开发中的客户端程序的图像

{%}

5.5.6 “共享内存方式”的实现——开始编码

单机版开发好以后,就可以开始实现“共享内存方式”的功能了。顾名思义,共享内存就是将相同的内存数据共享到不同进程之间。需要共享的具体数据包括可动物体的坐标、种类以及移动方向等信息。

竞争状态——共享内存方式的注意点

使用共享内存时需要注意的是内存容易遇到资源竞争状态(Race Condition)。竞争状态是指处理结果和预期结果不一致。比如“整数变量加 1”,这个最简单的处理也有可能发生竞争状态。

如果用 C 语言来说明,变量 i 初始值为 0,进行加 1 的运算如下所示。

i = i + 1;     ←这里i 的结果应该是1

这行代码在实际编译后可分解为以下处理。

从变量 i 的内存区域中将值拷贝到变量 a 的内存区域

给变量 a 加 1

将变量 a 的值从内存区域中拷贝到变量 i 的内存区域中

该例子执行了两次拷贝和 1 次数据计算。

只有 1 个进程时,按照上述处理顺序执行一定会得到预期的结果。但是有两个进程的时候,❶~❸ 处理的执行顺序就不一定了。

表 5.2 按时间顺序展示了内存中数值的变化情况。表格从上到下表示时间顺序,进程 A 和进程 B 交替执行上述 3 个分解后的处理。两个进程分别进行了“加 1”的处理,所以最终结果 i 在初始值 0 的基础上增加了 2,没有出现问题(和预期结果相同)。

表 5.2 内存中数值的变化 1

进程 A

进程 B

i

a

从变量 i 的内存区域中将值拷 贝到变量 a 的内存区域

 

0

0

 

从变量 i 的内存区域中将值拷贝到变量 a 的内存区域

0

0

给变量 a 加 1

 

0

1

 

将变量 a 的值从内存区域中拷贝到变量 i 的内存区域中

0

2

给变量 a 加 1

 

2

2

 

将变量 a 的值从内存区域中拷贝到变量 i 的内存区域中

2

2

但是参考表 5.3,执行时机稍微不同时会得到什么结果呢?

表 5.3 内存中数值的变化2

进程 A

进程 B

i

a

从变量 i 的内存区域中将值拷贝 到变量 a 的内存区域

 

0

0

给变量 a 加 1

 

0

1

 

从变量 i 的内存区域中将值拷贝到变量 a 的内存区域

0

0

 

给变量 a 加 1

0

1

将变量 a 的值从内存区域中拷贝 到变量 i 的内存区域中

 

1

1

 

将变量 a 的值从内存区域中拷贝到变量 i 的内存区域中

1

1

最终结果变成了 1,和之前的执行结果不同。两个进程都执行了两次“i 的值 +1”的处理,最终结果应该是 2,所以这个结果是不对的。

这种情况我们称为竞争状态。

加锁处理

竞争状态发生的根本原因是同一个处理(i 的值 +1)被分解成了多个操作。所以要避免发生竞争状态,一般可以在处理实际运行前后给资源加锁,让分解的操作集中执行。这个也叫做原子处理。代码如下所示,表 5.4 按时间顺序展示了内存中数值的变化。

表 5.4 内存中数值的变化(加锁)

进程 A

进程 B

i

a

加锁处理

 

0

不定

从变量 i 的内存区域中将值拷贝 到变量 a 的内存区域

 

0

0

给变量 a 加 1

 

0

1

将变量 a 的值从内存区域中拷贝 到变量 i 的内存区域中

 

1

1

解锁处理

 

1

1

 

加锁处理

1

1

 

从变量 i 的内存区域中将值拷贝到变量 a 的内存区域

1

1

 

给变量 a 加 1

1

2

 

将变量 a 的值从内存区域中拷贝到变量 i 的内存区域中

2

2

 

解锁处理

2

2

lock();   ←加锁处理
i = i + 1;
unlock(); ←解锁处理

加锁处理同时只能调用一次,在某一进程调用加锁处理时其他进程会处于等待状态。

P2P MO 和竞争状态

在 C/S MMO 游戏中,管理着全部游戏状态的游戏服务器一般会进行加锁处理。玩家的任何操作都需要请求服务器进行实际的数据操作,游戏状态只能通过服务器进行变更,所以不会发生竞争状态。

P2P MO 游戏也可以采用相同的方式,只是将玩家的机器当做游戏服务器,以“分布式 MMO”的方式实现,从而避免竞争状态的发生。最近这种实现方式的使用逐渐增多,但是其存在的问题是“一旦主机在游戏中途退出”,其他玩家就不能切换到单人模式继续游戏。另外,MMO 游戏的进行完全依赖其他的进程这点会造成网络延迟,所以对于激烈的动作类游戏这种方式也不适合。

P2P MO 游戏其实还有与前面描述的“加锁处理”类似的实现办法。不过这个方法和一般程序中使用的 mutex 或者 FIFO(先进先出)等方式不同,为了避免不同机器上运行的进程之间发生竞争状态,需要实现带有通信功能的加锁处理。在不同机器上运行的进程为了进行加锁处理需要和主机之间进行通信,所以理论上和前面描述的分布式 MMO 有同样的网络延迟问题。但是,好处是中途退出时可以切换到单人模式继续游戏。

5.5.7 P2P MO 游戏开发中该如何防止发生竞争状态

综上所述,在 P2P MO 游戏的开发中,判断该如何防止共享内存中数据发生竞争状态十分重要。对于游戏内容的充分理解可以帮助开发者做出正确的选择。

5.3 节中“带宽消耗的预估”部分为了节省带宽决定“不同步没有追击玩家的敌人”,这个节省带宽的实现方式也是最重要的判断依据。

同步处理的实现例子

下面是关于同步处理正式编码前的记录。这里展示的是未经修改的实际文档,阅读可能稍微有点困难。我们以此为基础来说明在实际编码中应该注意的地方。

* Enemy
  * Create: host -> guest (sync, ALL)
  * Move
    * target(有): guest 处理
    * target(无): host -> guest (sync, coord)
  * Delete: guestAttackKill -> hostKill -> guestKill (delete ALL)
* PlayerCharacter
  * Create, Move, Delete: guest -> host -> guest (sync ALL)
* Bullet
  * Create, Move, Delete: local

上面这些记录是 J Multiplayer 游戏中关于可动物体同步的设计资料,但具体该怎么设计呢?

可动物体的枚举类型和类的设计

首先一起来看看在游戏中出现的所有可动物体。

* Enemy
* PlayerCharacter
* Bullet

J Multiplayer 客户端程序中可动物体的类设计如图 5.12 所示。各个类的要点如下所示。

图 5.12 J Multiplayer 可动物体的类的设计

  • class Movable

    可动物体的基类。包含坐标、移动方向、图像编号等信息。

  • class Character

    游戏角色类,继承 Movable 类。可以互相攻击,包含 HP 等状态信息。

  • class Enemy

    敌人角色类,继承 Character 类。具有管理攻击值的人工智能(AI)。

  • class PlayerCharacter

    玩家角色,继承 Character 类。能通过网络来控制。

  • class Bullet

    子弹类,继承 Movable 类。可以击中 Enemy 或者 PlayerCharacter。

基本操作的矩阵

接下来,像在 DB 中进行 CRUD 那样,将基本操作 Creat、Move、Delete 矩阵化,这些都是机械化的工作。

* Enemy
  * Create
  * Move
  * Delete           ↗
* PlayerCharacter
  * Create
  * Move
  * Delete           ↗
* Bullet
  * Create
  * Move
  * Delete

修改游戏进行状态相关处理的规范

创建敌人、移动玩家角色的操作都属于修改游戏进行状态的处理,这些处理有两点需要考虑。

  • 最初在哪里发生?

  • 应该通知哪里?

下面,我们来针对这两点分别说明各个类的相关处理规范。

“哪里”主要只是主机和客户端。如果是在主机发生,并且需要发送信息到所有客户端的话,记为:host -> guest

玩家角色的处理规范

首先从简单的地方开始规范。

玩家角色最多同时出现 4 个,对带宽的影响很小。这其中,其他玩家的位置和他们正在做什么是最重要的信息,所以这些信息需要一直保持同步。另外,其他进程无法修改玩家角色自身的状态,所以玩家 A 操作的时候,A 的角色信息会得到更新,而其他玩家则不能修改这个角色的信息。

因此,Create、Move、Delete 这些操作只在各自的客户端执行,然后向主机发送信息,再由主机通知全部客户端。可以简化记为 guest -> host -> guest。下面的代码(sync ALL)表示使用 sync 通信协议,向所有的客户端发送信息。

* Create, Move, Delete: guest -> host -> guest (sync ALL)

敌人的处理规范——Enemy/Create

下面是最重要的 Enemy 类,敌人的处理是比较复杂的。

首先 Create 操作是指生成新的敌人。根据笔者的经验,在 P2P MO 游戏中生成新的可动物体有两种方法。

  • 在主机生成。

  • 在客户端生成,但不同步。

这两种方法在设计上不容易出现问题。

这是因为在客户端生成的物体如果要同步到其他机器需要经过两次跳转,系统整体可能出现的状态会增加(图 5.13)。

图 5.13 P2P MO 类型游戏可动物体的新生成

{%}

如图 5.13 所示,如果采用客户端生成并同步的方式,为了调试需要使用 guest - host - guest 三个进程,重现 Bug 比较麻烦。另一方面,系统可能出现的状态增加为以下三种。

  • 在客户端 A 生成后还没有向其他机器同步。

  • 客户端 A 通知了主机,但主机还没有通知其他机器。

  • 客户端 A 通知了所有的客户端。

敌人状态的同步是这个游戏最重要的部分,如果因为对应的状态管理变得复杂而增加了验证的难度,那就有可能导致不必要的技术上的问题。

为了实现 J Multiplayer 的设计内容,应该慎重考虑在客户端生成的可动物体是否需要同步到其他客户端。在示例代码中没有进行同步,所有生成敌人的处理也都由主机来控制,这样可以将产生问题的风险降到最低。不仅游戏内容的开发会变得更加容易,调试会比较方便,而且能够缩短开发周期。

简而言之,就是只在主机生成敌人,然后同步到所有客户端。可以调用 sync 协议(sync, ALL)来进行同步,如下面的代码所示。

* Create: host -> guest (sync, ALL)

以上就是敌人的创建。

  • Enemy/Move

    接下来是 Move 操作,需要规范的是当敌人移动时,应该同步的信息和同步的方式。

    如果 Create 不同步的话,Move 或者 Delete 等后续的操作当然也不需要同步。Move 操作也是 host -> guest

    在 5.3 节中提到过,J Multiplayer 游戏的设定是“追击玩家的敌人在客户端本地机器上进行处理,分散在地图上处于待机状态的敌人由主机控制移动并向各个客户端同步数据”。

    在这里可以将该设定加入到程序的规范中。在 Enemy 类里添加成员变量 targetID,用来记录追击目标的 ID,即被追击的玩家 ID。没有追击目标时设为 0,追击 ID3 的玩家时设为 3。

    * Move
      * target(有): guest 处理
      * target(无): host -> guest (sync, coord)
    
    

    上述“target(有)”表示 targetID 不为 0。这种情况时,“guest 处理”表示在 target 所在的客户端处理;“target(无)”表示没有追击目标,通过 host -> guest 进行同步,使用 sync 通信协议来传输坐标(Coordinate),而且只同步坐标,不用传输 HP 等信息。当然大 Boss 这样需要很长时间才能击倒的敌人可能需要同步 HP,但是虾兵蟹将的话同步下位置信息就可以了。通过这些判断,在实现游戏内容的过程中,也许能够找到如何减少带宽消耗、处理内容、编码量等问题的方法。

  • Enemy/Delete

最后是 Delete 操作,在 J Multiplayer 游戏中敌人死亡的唯一条件是受到攻击。在 5.3 节中提到过的设定是“敌人的数量需要同步”,这也可以直接作为程序的规范。

敌人的死亡有以下两种情况。

在客户端被玩家击倒。

在主机被主机的玩家击倒。

* Delete: guestAttackKill -> hostKill ->guestKill(delete ALL)

第 1 种情况时,如果在客户端 A 被击倒,调用 delete 通信协议以 guestA->host-> 其他客户端的顺序同步。而在主机被击倒时,顺序则是 host-> 其他客户端。用总结成一行的方式来描述可能更为明确。

* Delete: [ guestAttackKill-> ] hostKill -> guestKill(delete ALL)

[] 内表示可以省略

以上大体明确了 Enemy 同步处理的规范。

子弹的处理规范

还剩下子弹相关的处理。

* Bullet
* Create, Move, Delete: local

只有正在追击目标的敌人才发射子弹,四处分布的待机敌人不会发射子弹,因此可以在各个客户端分别处理而不需要同步。

因为设定太过简单反而有些犹豫,但是经过实际测试并没有发现问题,所以还是把它作为规范保留了下来。

在实际开发中随着测试的不断进行,会发现“这里还需要更多同步”、“这个不需要吧?”、“这里的同步频率不提高的话可能会有问题”、“装备这个武器的时候需要提高同步频率”、“只同步这个敌人”等问题,所以需要根据游戏内容,在深入调查的同时不断调整同步的处理。

5.5.8 共享内存开发方式该如何编码——共享内存开发方式和 RPC 开发方式的比较

如果提到共享内存一般是指 POSIX 规格的共享内存(shm_ 函数)。

但是本章的“共享内存”只是为了与 RPC 开发方式进行比较,和 POSIX 的 shm 函数并没有关系。这里稍微有点复杂,所以需要明确说明“共享内存开发方式”和“RPC 开发方式”的区别。

二者在实现上的根本区别是:在游戏逻辑中,游戏进度数据的保存处理(覆盖或者不覆盖数据)所发生的时间和地点不同。

RPC 开发方式——C/S MMO 游戏

C/S MMO 游戏采用的 RPC 开发方式是按照下列顺序更新游戏进度数据的。

  • 玩家在客户端(程序)进行角色移动的操作。

  • 客户端向服务器端发送操作的 RPC 请求。

    例:向服务器发送 move(5, 5) 函数。

  • 服务器接收到 move(5, 5) 函数的请求后,首先检测参数 (5, 5) 是否正确,如果正确就在游戏进度数据中将角色的位置坐标更新为 (5, 5)。

  • 服务器更新数据成功后,将新的值同步到其他客户端。

采用 RPC 开发方式时,网络上传输的是“变更的请求”,而不是“变更的结果”。所以根据处理结果的不同,有可能会发生值并没改变的情况。

共享内存开发方式——P2P MO 游戏

P2P MO 游戏采用的共享内存开发方式要求客户端和服务器端共享相同的游戏数据。在这个前提下按照下列顺序处理游戏数据。

  • 玩家在客户端(程序)进行角色移动的操作。

  • 客户端检测移动操作是否正确,如果没有问题则更新本机管理的游戏进度数据。

  • 客户端将更新后的数值传输到其他客户端。

  • 其他客户端无条件接收更新后的结果。

采用共享内存方式时,网络上传输的是“变更的结果”,而不是“变更的请求”。所以只需要无条件替换对应的数值。

如果灵活运用共享内存开发方式的特性就能够大幅度减少代码量,这也是采用这种方式的优点。

为了体现这个优点,我们以 move(5, 5) 为例来比较 RPC 开发方式和共享内存开发方式在代码量上的区别。

RPC 开发方式的代码量——move(5, 5)

RPC 开发方式的代码参考以下示例。

void player_touched(mouseX, mouseY)
{
  send_move(mouseX,mouseY); ←†
}
void recv_move( int x, int y )     ←†
{
if( check(x,y) == OK ){
data->x = x;
data->y = y;
data->broadcast_move_notify(x,y);       ←†
}
}

void recv_move_notify(int x, int y) ←†
{
character->x = x;
character->y = y;
}

要增加 z 参数的时候,需要在标记了“†”符号的 4 个函数,即客户端的 send_move 函数和服务器端的 recv_move 函数、broadcast_move_notify 函数、客户端的 recv_move_notify 函数中都添加 int z 参数。而包含 int x, int y 两组参数的地方也都必须添加相应参数。

为了避免这样的麻烦,RPC 所有的参数都可以使用同一个参数构造体,但是这样难免会降低代码的可读性。

共享内存开发方式的代码量——move(5, 5)

共享内存开发方式的代码参考以下示例。

要增加 z 参数时,代码如下。

void player_touched(mouseX, mouseY, mouseZ)
{
  character->x = mouseX;
  character->y = mouseY;
  character->z = mouseZ;  ←†
  character->changed(VAL_X);
  character->changed(VAL_Y);
  character->changed(VAL_Z);  ←†
  character->broadcastChanged();
}

void recv_changed(int type, int value)
{
  switch(type) {
    case VAL_X : character->x = value; break;
    case VAL_Y : character->y = value; break;
    case VAL_Z : character->z = value; break;   ←†
    default:break;
  }
}

把上面标记了“†”符号的 3 个地方修改后就可以告诉别的进程 Z 坐标发生了变化。因为是无条件接收改变后的值,并没有返回值,所以代码量也会有很大不同。而且如果想要“只修改 X 和 Z”,采用 RPC 方式不仅需要定义新的函数,还需要重新编译相关的文件。而采用共享内存方式则不需要增加新的函数,只要修改并编译相关文件即可。

共享内存开发方式的优缺点

虽然增加参数这样的调整不能使用 IDE 的重构工具,但是代码的修改还是比较容易的。在游戏开发中,需要一边试玩一边进行无数次的细微调整,因此迭代的速度直接决定了游戏的品质。通过比较可以发现“采用共享内存开发方式更容易进行细微的修改,有利于追求更高的品质”。

共享内存方式的缺点是即使只有少数几个数值发生了变化也需要进行通信,这样可能会产生一些多余的通信量。

[ 补充 ] 你会进行代码清洁工作吗?

众所周知,代码应该尽可能保持精炼。上节“共享内存开发方式的代码量”列出的代码清单中有一处标记“*”号的地方,这个地方可以使用下例中的访问器(Accessor)来实现自动化同步。

void player_touched(mouseX, mouseY, mouseZ)
{
character->setX(mouseX); character->setY(mouseY);
character->broadcastChanged();
}
void Character::setX(float value)
{
this->x = value;
this->changed(VAL_X, value);
}

当然在这种程度下用访问器来实现自动化是可行的,不过当可动物体有成百上千个时,这些处理每次访问成员变量时都要调用 changed 函数,这点会产生性能上的问题。

所以应该直接访问原生类型的变量,处理完结果后再调用 changed 函数。因此实际的 J Multiplayer 案例代码并不像上面例子中的那样简洁,具体情况在后面的章节中会详细说明。不过对于可动物体少的游戏——比如将棋这类游戏——就可以采用上面那样简洁的做法。

5.5.9 SyncValue 类

J Multiplayer 案例代码中,SyncValue 类的实现采用了前面提到的共享内存方式。SyncValue 是用来记录在共享内存中变化部分的类,而实际用来处理可动物体的内存区域则是通过别的方式管理的。

首先来看可动物体类 Movable,为了方便说明做了以下变化。

↓可动物体类
class Movable {
 public:
  U32 id;  ←游戏房间内的唯一ID
  float x,y;        ←现在的位置
  float dx, dy;     ←方向
  U32 typeID;       ←种类ID

  SyncValue *m_pSyncValue;    ←同步用内存指针

  enum {
    SVT_TYPEID,
    SVT_X,
    SVT_Y
  };

  ↓构造函数
  Movable(U32 _id, float _x, float _y,  U32 _typeID)
     : id(_id), x(_x), y(_y), typeID(_typeID)
  {
    m_pSyncValue = new SyncValue();
    m_pSyncValue->registerIntType(SVT_TYPEID);
    m_pSyncValue->registerFloatType(SVT_X);
    m_pSyncValue->registerFloatType(SVT_Y);

    m_pSyncValue->setInt(SVT_TYPEID, typeID);
    m_pSyncValue->setFloat(SVT_X,  x);
    m_pSyncValue->setFloat(SVT_Y,  y);
  }
};

正如前文所述 14,为了保证执行效率,需要直接访问原生 float 型坐标值 x, y。构造函数通过指针在缓存中记录 SyncValue 内的数值变化,使用 registerIntType 函数记录变量类型,调用 setInt 函数修改实际的值。

14是指上一节“[ 补充 ] 你会进行代码清洁工作吗?”的末尾部分提到的“直接访问原生类型的变量”。

在游戏的主循环中,同步可动物体移动后变更值的代码可以简化如下。

x += dx;
y += dy;
this->m_pSyncValue->setFloat(SVT_X, x);
this->m_pSyncValue->setFloat(SVT_Y, y);

通过上述代码在 SyncValue 类的实例中记录了变更结果,所以如果在主循环之外的地方有修改,就可以通过网络发送修改信息。

std::vector<Movable*>::iterator itm;
for (itm = vm.begin(); itm != vm.end(); ++ itm) { ←遍历所有的Movable
  Movable *m = (*itm);
  vce::VUint8 buf[256];
  size_t buflen;
  m->m_pSync->getDiffBuff(buf, &buflen);     ←只取得缓存中变化了的部分

  if (buflen > 0) {   ←如果有需要同步的就发送信息
    broadcast_sync(m->guestid, m->id, buf, buflen);
    }
    m->m_pSync->clearChanges();     ←信息发送完成后删除变更记录
}

broadcast_sync() 函数只取出缓存中变化的部分并发送信息。

收到消息的一方处理如下所示。

void receive_sync(U32 id, const U8 *data, U32 data_qt)
{
  SyncValue sval;   ←声明SyncValue 变量

  ↓注册数据格式
  sval.registerIntType(SVT_TYPEID);
  sval.registerFloatType(SVT_X);
  sval.registerFloatType(SVT_Y);
  ↓从网络中读取缓存数据
  sval.readBuffer(data, data_qt);
  Movable *m = g_app->getMovable(id);   ←通过ID 获取 Movable 实例的指针

  ↓如果有变更就同步(直接覆盖写入本地内存)
  if (sval.isChanged(SVT_TYPEID)) {
     m->typeID = sval.getFloat(SVT_TYPEID);
  }
  if (sval.isChanged(SVT_X)) {
      m->x = sval.getFloat(SVT_X);
  }
  if (sval.isChanged(SVT_Y)) {
      m->y = sval.getFloat(SVT_Y);
  }
}

为了实现上述操作,SyncValue 类需要有以下成员变量。

class SyncValue
{
 private:
  static const int SYNCVALUES_MAX = 32; ←以同步的变量个数上限
  static const int BUFLEN = 128;
  vce::VUint8 m_buf[BUFLEN];   ←不保存大量数据所以设为固定值
  size_t m_offsets[SYNCVALUES_MAX];     ← ID,从哪一位开始;如果是 0 则为 m_buf[0];类型种类决定大小。这个在add 操作时确定
  size_t m_sizes[SYNCVALUES_MAX];       ←不同变量的大小
  bool   m_changes[SYNCVALUES_MAX];     ←记录变化了的ID
  bool m_uses[SYNCVALUES_MAX];          ←使用了哪些域
  size_t m_currentOffset;

在上述代码中,m_buf 变量用来记录使用 setFloat 函数变更后的数据,m_offsets、m_sizes 保存变量和变量所在的位置。m_changes 保存变更的变量 ID,m_uses 表示使用的变量。这些数组都使用了 SVT_ 枚举型中定义的值,当最大值大于 SYNCVALUES_MAX 时就会报错。

SyncValue 类的构造函数仅使用默认值 0 初始化所有的变量。

public:
  ↓ SyncValue 类的构造函数。必要的变量都初始化为0
 SyncValue() : m_currentOffset(0), m_offsets(), m_sizes(), m_changes(), m_uses() {
}

SyncValue 类现在支持的 3 种类型都是比较实用的类型,可以在构造函数内进行无限扩展。reg 是内部使用的函数,计算并保存缓存的使用方式。

void registerCharType(int index) {
  reg(index, 1);
}
void registerIntType(int index) {
  reg(index, 4);
}
void registerFloatType(int index) {
  reg(index, 4);
}

setChar、setInt、setFloat 函数通知发生实际变化的数据。

index 是 SVT_ 枚举型。更新 m_buf 中的数值后将 m_changes 设为 true,之后就可以发送消息同步数据。

void setChar(int index, char val) {
  char prev = ((char*)(m_buf+ofs(index)))[0];
  if (prev != val) {
    ((char*)(m_buf+ofs(index)))[0] = val;
    m_changes[index]=true;
  }
}
void setInt(int index, int val) {
  int prev = ((int*)(m_buf+ofs(index)))[0];
  if (prev != val) {
    ((int*)(m_buf+ofs(index)))[0] = val;
    m_changes[index]=true;
  }
}
void setFloat(int index, float val) {
  float prev = ((float*)(m_buf+ofs(index)))[0];
  if (prev != val) {
    ((float*)(m_buf+ofs(index)))[0] = val;
    m_changes[index]=true;
  }
}

上面是 set 相关函数,接下来是 get 相关函数。

char getChar(int index) {
  return ((char*)(m_buf+ofs(index)))[0];
}
int getInt(int index) {
  return ((int*)(m_buf+ofs(index)))[0];
}
float getFloat(int index) {
  return ((float*)(m_buf+ofs(index)))[0];
}

使用 get 函数取得变量值是很简单的处理,只需从 m_buf 中获取数值。下列 clearChanges、fillChanges、isChanged 函数用来清除变更标记、设定变更标记和取得变更标记。

void clearChanges() {
  fillChanges(false);
}
void fillChanges( bool flag ) {
  for (int i=0;i<SYNCVALUES_MAX;i++) {
    if (m_uses[i]) {
      m_changes[i]=flag;
    }
  }
}

bool isChanged(int index ) {
  assert(index < SYNCVALUES_MAX);
  return m_changes[index];
}

下面的 ❶ 号函数 getDiffBuff 将 m_buf 中变更的部分全部输出到缓存 outbuf 后,返回该变量。

❷ 号函数 readBuffer,从缓存参数中读取数据并且判断哪些变量发生了改变、变更值为多少,从而为 get 操作准备必要的信息。

void getDiffBuff(vce::VUint8 *outbuf, size_t *outbuflen);   ← ①

void readBuffer(const vce::VUint8 *inbuf, size_t inbuflen); ← ②

* * *

以上就是 SyncValue 类的相关内容。

在实际编码中,j.xml 文件中定义的通信协议 IDL 和 C/S MMO 游戏 K Online 中的有所不同,只有 5 种类型,而且无论什么游戏都差不多。这在开发实际通信处理阶段已经做了说明。

经过了这些准备,剩下的就是在实际开发中一边试玩,一边讨论各种细节,在实现游戏内容的过程中不断调试。至此,关于 P2P MO 游戏实现的说明就告一段落。

专栏 数据中心的地理位置分布

K Online 或者 J Multiplayer 这样的网络游戏和 Web 服务相比需要更高的响应速度,为了保证游戏体验应该尽可能在离玩家近的地方,甚至是玩家所在国家或地区设置服务器。在日本和北美两个区域使用同一服务器提供游戏服务已经是能做到的最大限度。

不过也并不是每个游戏公司都有能力在多个国家设置自己的分支机构,一般的做法是将游戏的服务器代码、编译后的程序或者设定文件等授权给不同国家的运营商,由这些运营商来提供服务而己方仅收取授权费(License Fee)。当然利用的是当地的数据中心等资源。

这种情况下应该尽量避免不同地区的数据中心互相连接,让数据库之间没有相互依赖的关系,保持完全独立的运行状态。

现在网络环境在不断改善,有通过使用统一的 Web 站点来降低成本的趋势,但是距离“单一位置”服务器提供游戏服务还需要很长时间的发展。

5.6 支持 C/S MO 游戏的技术 [ 补充 ]

本章最后部分说明一下支持 C/S MO 游戏的技术。需要注意这里是 C/S MO 而不是 C/S MMO。

5.6.1 C/S MO 和 NAT 问题

C/S MO 是 Client/Server 型 MO(少数用户在线的)游戏的简称。之前提到过很多次,P2P MO 游戏最大的问题是“不是任何玩家都可以直接连接”。例如,当 J Multiplayer 使用星状拓扑结构时,直接与网络连接但是没有公网 IP 地址的玩家机器不能做主机,这就是“NAT 问题”。

5.6.2 什么是 NAT 问题

据笔者所知,关于 NAT 问题科乐美公司(Konami Digital Entertainment, Inc.)的佐藤良先生在网上发布的资料最为详细。其中的重点部分请参考图 5.14。

http://homepage3.nifty.com/toremoro/study/voip2008/NATTraversal.pdf

注意图 5.14 中“公开网络(Open Internet)”部分,在韩国可以作为主机的用户比例很高,而在日本几乎为 0,而且不同国家的比率都不相同。

一般说来,如果家庭使用 Softbank 等运营商提供的“宽带路由器”连接网络,PC 的 IP 地址和路由器的外网 IP 地址是不同的,这时 NAT 是有效状态。因为 PC 的 IP 地址和路由器的外网 IP 地址通过某种规则可以转换,所以叫做网络地址转换(Network Address Translation)。使用 NAT 不仅可以解决公网 IP 地址不足问题,还具有提高安全性和简化网络连接等优点。

不过网络游戏需要和网络上的其他 PC 联网,而 NAT 可能会造成网络无法连接的问题。

5.6.3 NAT 遍历——解决 NAT 问题建立通信路径的技术

图 5.14 说明了 NAT 遍历(traversal),即解决 NAT 问题建立通信路径的技术。该技术不仅在网络游戏中使用,在 VoIP(Voice over IP)、网络会议系统、VPN 等领域也有广泛应用,因此市场上除了各种中间件产品,还有很多开源产品。

图 5.14 NAT 问题(NAT 问题的统计数据)

{%}

※ 出处:科乐美公司(Konami Digital Entertainment, Inc.)佐藤良

“P2P 通信技术:NAT 穿越~ STUN、UPnP 和 TURN”(p36“实际标题的统计数据(2)”)

http://homepage3.nifty.com/toremoro/study/voip2008/NATTraversal.pdf

首先按限制程度将互联网用户的 NAT 状态划分为 6 个阶段,如下所示,强度逐渐增加。

公共网络

拥有公网 IP 的终端。

无限制 NAT →图 5.14 中的“完全圆锥型”(Full cone)

访问路由器的指定端口时,固定转换为 LAN 中 PC 的特定端口。可以传输所有的收到的数据包。

限制地址的 NAT →图 5.14 中的“受限圆锥型”(Address——Restricted cone)

只接收来自于 NAT 内部,并且之前有发送过数据包的终端的数据包。通过设定 IP 地址提高安全性,不接受之前没有产生过通信的 IP 地址所发送的数据,这也叫做“受限圆锥形”。

限制端口的 NAT →图 5.14 中的“端口受限型”(Port——Restricted cone)

和限制地址的 NAT 相同,不过又增加了端口号的限制,也叫做“地址 / 端口受限型”

对称 NAT(Symmetric NAT)→图 5.14 中的“对称型 UDP 防火墙”

每次建立通信路径时都使用不同的端口号,不同的地址。这个方式有很多分支类型。

UDP 禁止型 →图 5.14 中的“对称型”

禁止使用 UDP 通信

NAT 遍历技术的限制

以现有的技术基本可以解决 ❶ 到 ❹ 中的 NAT 问题。针对 ❺ 的情况可以使用“UDP Hole Punching”15、“STUN”16 等技术,不过不能保证 100% 解决问题。❺ 的一部分和 ❻ 的 NAT 问题目前还无法解决。

15不同 NAT 路由器下面的局域网内,主机之间经过互联网使用 UPD 协议建立连接的方式。

16Simple Traversal of UDP through NATs 的简称,使用标准化协议的一种 NAT 遍历方法。

以网络游戏玩家作为对象调查了“无法解决的 NAT 问题比率”,其结果请参考之前介绍的资料 17

17十分感谢将这些宝贵数据公开的公司。

使用 NAT 遍历技术的其他缺点

使用 NAT 遍历技术的另一个缺点是,在玩家和其他 PC 实际建立连接交换数据之前需要各种初始化处理和验证处理,这不但需要花费数秒至数十秒的时间,还需要专门的服务器,所以产生了成本问题。此外,E❺ 的一部分玩家和 ❻ 的玩家的 NAT 问题也暂时无法解决。

开发网络游戏的时候,实现 NAT 遍历技术是十分重要的。在 2000 年左右,各个公司都采用 UDP 的方式实现 NAT 遍历功能。但是现在互联网基干网络的性能有了大幅度提高,东京都内企业间的通信基本都在 1 毫秒之内,此外 Amazon 等提供云计算服务的企业在世界各地都建立了数据中心 18,这使全世界范围内构建游戏服务器也变得越来越容易,所以今后为了避免网络游戏中 NAT 遍历技术的缺点,使用 C/S 型架构的游戏会越来越多。本书对于 NAT 遍历相关的探索就不再深入。

18也称作边缘定位(Edge Location)。

5.6.4 NAT 问题的实际解决方法

NAT 问题的一个实际解决案例如下。

  • 在 Microsoft 的 Xbox 360 标准程序库中,为了与别的玩家建立连接,采用了多个阶段的通信确保数据包能够到达。

  • 如果还是有问题,则使用中继服务器建立连接。

因为大部分的开发者并不是为 Xbox360 平台开发游戏,这里主要介绍使用中继服务器的方法。使用中继服务器可以让用户的连接失败率降到几乎为 019

19不能保证完全为 0 是因为有的 ISP 禁止 HTTP 以外的通信。

5.6.5 中继服务器

中继服务器是指第 3 章介绍的“反射型架构”的数据包中继服务器。这种服务器既不检测数据包中的数据,也不需要认证。具体请参考第 3 章图 3.10。

调用 SyncValue 的逻辑在 J Multiplayer 中直接保留,只需要修改物理网络的使用方法。

J Multiplayer 最初的原型没有使用反射型架构,而是采用了直接连接的 P2P/ 星状拓扑结构。所以当 4 个人游戏时,主机接收 3 个客户端的连接。如图 3.12 所示。

使用中继服务器的时候,主机之外的客户端连接的 IP 地址仅从主机地址变成中继服务器的地址,所以主机的操作只需要稍微调整一下即可。

具体说来主机需要添加以下功能。

  • 启动时连接中继服务器,创建新的通信频道(channel)。

  • 广播时,不向客户端直接发送数据,而是向中继服务器发送。

此外在客户端,为了获取中继服务器的通信频道信息,需要使用匹配服务器等辅助系统(参考第 6 章),还要从主机获取通信频道的 ID。

使用中继服务器进行交换的数据和使用直接连接方式时的一样,都是 SyncValue 的差分数据。

5.6.6 中继服务器的折衷方案

实际上中继服务器的实现十分简单,不过在程序开发上有两个需要折衷的地方。

因为通信需要经过服务器,所以会有游戏延迟。

需要消耗服务器带宽。

将影响降低到最小正是需要开发者大显身手的地方,虽然 Xbox 360 平台有提供解决方案,不过还可以进一步得到改善。

❶ 游戏延迟增大的问题

首先关于游戏延迟增大的问题,并不是每个用户都需要使用中继服务器,让那些怎么都无法建立主机的玩家使用就可以了。这样两种方式共存,在一定时间内不能建立通信的情况下切换成使用中继服务器的模式。

❷ 消耗服务器带宽的问题

关于服务器带宽的消耗问题有几个阶段的折衷方案。虽然可以尽可能的压缩带宽消耗,不过这样会增加开发时间。

这里介绍几种方法。

  • 什么都不调整的时候

首先来看看什么都不调整的情况。假设 4 个人同时进行 J Multiplayer 游戏,主机需要 100kbit/s 的带宽,那么 1 个人做主机有 100kbit/s 带宽,向其他 3 个客户端发送数据,每个客户端需要 33kbit/s。

  • 主机:[ 上行 ]33kbit/s×3=100kbit/s、[ 下行 ] 非常少。

  • 客户端:[ 上行 ] 非常少、[ 下行 ]33kbit/s。

通信量 [ 非常少 ] 是因为游戏开发规范中要求同步敌人数据的时候只同步 Create/Delete 操作。

综上所述 4 个人同时连接时需要中继服务器 100kbit/s 的上行下行带宽,如果以同时连接数 4000 为基准,需要 100kbit/s×(4000/4)=100Mbit/s 带宽。目前这个带宽的每月花费大概在几万到几十万日元之间。这不是一笔小数目,所以需要考虑该如何解决这个问题。

  • 解决方案1

    4 个人有中如果有 1 个人可以使用 NAT 遍历,那么就让这个人作为主机开始游戏,同时作为中继服务器进行通信。

    假设按照刚才图 5.14 的数据,在日本可以使用 NAT 遍历的用户大概是 7 成。4 个玩家在线时,不能使用 NAT 遍历的概率是 0.3 除以 4 等于 0.008,不到 1%。所以服务器成本马上缩减为 1/100。不过这样可能会遇到通信测试时间很长的问题,全部玩家都测试的话估计要花费 1~2 分钟。

    在服务器保存通信测试结果也许可以缩短等待时间(不过笔者目前还没遇到这样的做法)。

  • 解决方案2

    将同步数据拷贝到中继服务器。

    主机向其他 3 个客户端发送的同步数据都是一样的,如果主机将发给 3 个客户端的相同数据包改为只发送一次同步数据,由中继服务器将该数据发送给其他 3 个人,这样也可以减少上传数据消耗的带宽。4 个玩家时所需带宽如下。

    • 主机:[ 上行 ]33kbit/s×1=33kbit/s

    • 客户端:[ 下行 ] 33kbit/s

    上行带宽一下减少到 1/3,整体来看可以减少 2 成左右。不过这种方案需要修改中继服务器的程序。

* * *

使用中继服务器开发 MO 游戏时,需要注意上面两点。如果不实现这两点的优化,服务器成本可能会很高,难以实现游戏的商业化运营。

最后中继服务器相关辅助系统的话题会在第 6 章继续说明,读者可以参考其中的内容。至此,MO 游戏开发相关的话题就告一段落。

5.7 总结

本章以 P2P MO 游戏为中心进行了开发相关的说明,同时也对比了其与 C/S MMO 系统的区别。下一章我们将会和读者一起探讨支持网络游戏的辅助系统。

目录

  • 版权声明
  • 前言
  • 专业术语
  • 第 0 章 [ 快速入门 ] 网络游戏编程:网络和游戏编程的技术基础
  • 第 1 章 网络游戏的历史和演化:游戏进入了网络世界
  • 第 2 章 何为网络游戏:网络游戏面面观
  • 第 3 章 网络游戏的架构:挑战游戏的可玩性和技术限制
  • 第 4 章 [ 实践 ] C/S MMO 游戏开发:长期运行的游戏服务器
  • 第 5 章 [ 实践 ] P2P MO 游戏开发:没有专用服务器的动作类游戏的实现
  • 第 6 章 网络游戏的辅助系统:完善游戏服务的必要机制
  • 第 7 章 支持网络游戏运营的基础设施:架构、负荷测试和运营
  • 第 8 章 网络游戏的开发体制:团队管理的挑战