Skip to content

Latest commit

 

History

History
362 lines (254 loc) · 23.2 KB

File metadata and controls

362 lines (254 loc) · 23.2 KB

十、多个玩家

自从我最早的游戏冒险以来,我发现分享经历总是会让它更加难忘。回想当年,多人游戏的概念围绕着在沙发上和朋友一起玩,或者和其他游戏爱好者聚在一起进行史诗般的 LAN ( 局域网)聚会。自那以后,情况发生了巨大变化,在线、全球共享的游戏体验成为新的常态。在这一章中,我们将介绍在你的游戏项目中增加多人支持的概念,特别关注联网多人。正如我以前说过的那样,计算机网络是一个非常庞大和多样化的主题,需要的时间和空间比我们必须全面讨论的还要多。相反,我们将专注于高级概述,并深入到需要的地方。在本章中,我们将涵盖以下主题:

  • 游戏中的多人游戏介绍
  • 网络设计和协议开发
  • 创建客户端/服务器

游戏中的多人游戏介绍

简单来说,多人游戏是一种多人同时玩的电子游戏。虽然单人电子游戏通常是围绕一个玩家与人工智能对手竞争并实现预定目标而设计的,但多人游戏是围绕与其他人类玩家的互动而设计的。这些互动可以是竞争、伙伴关系或简单的社会参与。这些多玩家互动是如何实现的,取决于位置和类型等因素,从同屏多人对战的战斗机游戏到在线多人角色扮演游戏,用户共享一个共同的环境。在下一节中,我们将了解视频游戏中多人互动的各种方式。

本地多人游戏

游戏中多人游戏的想法最早是以本地多人游戏的形式出现的。很早以前,很多游戏都有双人模式。一些游戏会实现一个两人模式,即多人游戏,玩家可以轮流玩游戏。尽管如此,但即使在早期,开发人员也看到了共享体验的好处。甚至最早的游戏如太空战! (1962 年)和 PONG (1972 年)让选手们相互对抗。街机游戏场景的兴起有助于推动本地多人游戏,如战书 (1985)等游戏提供了多达四名玩家的合作游戏体验。

大多数本地多人游戏可以分为几个类别之一,基于回合的,共享的单屏或分屏多人游戏。

基于回合的,顾名思义,是一种多人模式,玩家轮流使用一个屏幕玩游戏。回合制多人游戏的一个很好的例子是最初的超级马里奥兄弟(T1),用于任天堂娱乐系统(T3)(T4 NES)。在这个游戏中,如果选择两人模式,第一个玩家扮演马里奥角色;当玩家死亡时,第二个玩家就轮到他们了,并扮演另一个兄弟,路易吉。

共享单屏多人模式是一种常见的本地多人模式,每个玩家的角色都在同一个屏幕上。每个玩家可以同时控制自己的角色/头像。这种模式非常适合对抗游戏,如体育和格斗游戏,以及合作游戏,如平台和解谜游戏。这种模式在今天仍然非常流行,最近发布的 Cuphead 标题就是一个很好的例子。

单屏幕多人游戏

分屏多人模式是另一种流行的本地多人模式,每个玩家都有整个本地屏幕的一部分作为他们的游戏视图。每个玩家同时控制自己的角色/头像。这种模式非常适合对战游戏,比如射击游戏。尽管大多数选择实现分屏模式的游戏都是双人游戏,但有些游戏支持多达四名本地玩家,本地屏幕被垂直和水平分成四个部分。实现分屏多人游戏的一个很好的例子是第一人称射击游戏光环

局域网

随着 20 世纪 90 年代初个人电脑的激增,将电脑连接在一起共享信息的想法很快发展成为大多数电脑用户的核心需求。将多台计算机连接在一起的一种早期方法是通过局域网。局域网允许有限区域内的计算机,如大学、办公室、学校,甚至个人住宅。默认情况下,局域网是不可连接,除非您在局域网所在的有限区域内。虽然商业计算世界已经采用了局域网计算的理念,但随着 1993 年 DOOM 的发布,游戏行业真正开始将该技术用于多人游戏。

自从互联网被广泛采用以来,基于局域网的多人游戏的普及已经停止了。也就是说,局域网仍然是当今电子竞技联盟等比赛中多人游戏的玩法。基于局域网的多人游戏的想法也催生了一种被称为“局域网聚会”的现象。局域网聚会是游戏玩家聚集在同一个物理位置,将他们所有的电脑连接在一起,以便相互游戏。这些活动通常持续多天,玩家要长途跋涉才能参加。局域网聚会是 20 世纪 90 年代初至后期游戏领域的主要活动,对于任何参与的游戏玩家来说,这都是一种与其他游戏玩家联系的难忘方式。

在线多人游戏

互联网的普及为全世界的游戏玩家带来了一种全新的联系和游戏方式。与过去的局域网聚会不同,游戏玩家现在可以在不离开自己舒适的家的情况下,与来自世界各地的游戏玩家一起玩耍和竞争。在线多人游戏的历史可以追溯到早期的例子,比如 MUD ( 多用户地下城),用户可以在互联网上玩简单的 RPG。在线多人游戏几乎涵盖了当今的所有游戏类型,从第一人称射击游戏到实时战略游戏。基于互联网的游戏也催生了一种新的游戏类型,称为大型多人在线 ( MMO )游戏。在多人在线游戏中,大量的玩家可以在一个单一的实例或世界中连接和互动。迄今为止最受欢迎的 MMO 游戏之一是魔兽世界

网络设计和协议开发

在设计和开发多人游戏时,最大的两个考虑因素是决定要使用的网络拓扑和连接协议。每一个选择都对实现和游戏本身有重大影响。在本章的下一部分,我们将介绍不同的网络拓扑和使用的协议,并讨论它们的各种影响和注意事项。

网络拓扑结构

简单来说,网络拓扑就是网络上的计算机相互连接的方式。对于在线游戏,网络拓扑将决定网络上的计算机如何组织,以允许用户接收游戏更新。计算机如何联网将决定整个多人游戏设计的许多方面,每种类型的拓扑都有自己的优缺点。在下一节中,我们将介绍游戏开发中最流行的两种拓扑,客户端/服务器和对等模型。

点对点

在对等网络中,每个玩家都与游戏实例中的其他玩家相连:

对等网络通常以非权威的设计实现。这意味着没有控制游戏状态的单一实体,因此每个玩家都必须处理自己的游戏状态,并将任何本地更改传达给连接的其他玩家。这意味着,作为这种拓扑的结果,我们有几个问题需要考虑。首先是带宽;正如你可能想象的那样,这种设计有大量的数据需要在玩家之间传递。事实上,连接数可以表示为二次函数,其中每个参与者将有 O(n-1)个连接,这意味着对于该网络拓扑,总共将有 O(2n)个连接。这种网络设计也是对称的,这意味着每个玩家都必须有相同的可用带宽用于上传和下载流。我们需要考虑的另一个问题是权威的概念。

正如我在这里提到的,在对等网络中处理权限的最常见方法是让所有玩家共享网络上其他玩家的更新。以这种方式处理权限的结果是,玩家同时看到两种情况,玩家自己的输入即时更新游戏状态,并且模拟另一个玩家的动作。因为来自其他玩家的更新必须经过网络,所以更新不是即时的。当本地玩家收到更新时,比如将对手移动到(x,y,z),在收到更新时对手仍然在那个位置的几率很低,这就是为什么模拟其他玩家的更新。模拟更新的最大问题是,随着延迟的增加,模拟变得越来越不准确。我们将在本章的下一节讨论处理更新延迟和模拟问题的技术。

客户端/服务器

在客户机-服务器拓扑中,一个实例被指定为服务器,所有其他播放器实例都连接到它。每个玩家实例(客户端)将只与服务器通信。服务器反过来负责将玩家的所有更新传送给网络上连接的其他客户端。下图演示了该网络拓扑:

虽然不是唯一的方法,但是客户机-服务器网络通常实现权威的设计。这意味着,当玩家执行一个动作时,比如将他们的角色移动到另一个地方,该信息会以更新的形式发送到服务器。服务器检查更新是否被认为是正确的,如果是,则服务器将此更新信息转发给网络上连接的其他玩家。如果客户端和服务器在更新信息上存在分歧,则服务器被认为是正确的版本。就像对等拓扑一样,在实现时需要考虑一些事情。说到带宽,理论上每个玩家的带宽需求不会因为连接的玩家数量而改变。如果我们以二次公式的形式来看,给定 n 个玩家,连接的总数将是 O(2n)。但是,与对等拓扑不同,客户机-服务器拓扑是非对称的,这意味着服务器将只有 O(n)个连接,或者每个客户机有一个到一个连接。这意味着随着连接的玩家数量增加,支持连接所需的带宽将线性增加。也就是说,在实践中,随着更多玩家的加入,需要模拟更多的对象,这可能会导致客户端和服务器的带宽需求略有增加。

An authoritative design is considered more secure against cheating. This is because the server fully controls the game states and update. If a suspicious update is passed from a player, the server can ignore it and provide the correct update information to the other clients instead.

理解协议

在开始实施多人游戏之前,重要的是要了解如何在幕后处理事情。最重要的方面之一是两台计算机之间的数据交换方式。这就是协议的作用。虽然网络上有很多不同的数据交换方式,但在本节中,我们将关注传输控制协议/互联网协议 ( TCP/IP )模型,重点是主机到主机层协议。

TCP/IP 模型

TCP/IP 模型是对协议套件的描述,协议套件是一组协议的集合,旨在协同工作,将数据从一台计算机传输到另一台计算机。它以两种主要协议(TCP 和 IP)命名。TCP/IP 被认为是当今事实上的标准协议,已经取代了旧的协议套件,如 IPX 和 SPX。TCP/IP 协议套件可以分解为 4 层模型,如下图所示:

Most modern networking courses teach the 7-layer OSI model. The OSI model is an idealized networking model and, as of yet, not a practical implementation.

这四层分为应用层、传输层、网络层和数据链路层。应用是向用户表示数据并处理编码和对话控制的层。一种常见的应用层协议是超文本传输协议(HTTP ,这是一种为我们日常使用的网站提供动力的协议。传输层,也称为主机到主机层,是支持各种设备和网络之间的低层通信的层,与所使用的硬件无关。接下来我们将深入到这一层。网络层是确定数据通过网络的最佳路径并处理寻址的层。这一层最常见的协议是互联网协议 ( IP )。IP 有两个版本:IPv4 标准和 IPv6 标准。第四层也是最后一层是数据链路层或网络接入层。数据链路层指定组成网络的硬件设备和介质。常见的数据链路协议是以太网和无线网络。

现在我们已经对各层有了一个大致的了解,让我们来仔细看看游戏开发中最常用的两个网络层协议:TCP 和 UDP。

用户数据报协议

首先,让我们看看用户数据报协议 ( UDP )。UDP 是一种非常轻量级的协议,可用于将数据从一台主机上的指定端口传递到另一台主机上的另一个指定端口。在一个实例中发送的一组数据被称为数据报。数据报由一个 8 字节的报头和要传递的数据组成,称为有效载荷。下表描述了一个 UDP 报头:

| 位# | Zero | Sixteen | | 0-31 | 源端口 | 目的港 | | 32-63 | 长度 | 校验和 |

UDP header

要一点一点地分解它:

  • 源端口 : (16 位)这标识了传输数据的起始端口。
  • 目的端口: (16 位)这是正在传递的数据的目标端口。
  • 长度 : (16 位)这是 UDP 报头和数据有效负载的总长度。
  • 校验和 : (16 位,可选)这是根据 UDP 报头、有效负载和 IP 报头的某些字段计算的校验和。默认情况下,该字段设置为全零。

因为 UDP 是如此简单的协议,所以它放弃了一些特性来保持它的轻量级。缺少的一个特性是两台主机之间的共享状态。这意味着没有努力确保数据报的完全通过。即使数据到达,也不能保证数据到达时的顺序是正确的。这与我们将看到的下一个协议,TCP 协议有很大不同。

传输控制协议

与传输单个数据报的 UDP 不同,TCP 协议,顾名思义,为两台主机之间的传输创建了一个持续的连接。这允许可靠的数据流在主机之间来回传递。TCP 还试图确保发送的所有数据都被实际接收,并且顺序正确。有了这些额外的功能,就有了一些额外的开销。TCP 连接的报头比 UDP 的报头大得多。表格式的 TCP 报头描述如下:

TCP header

对于 TCP 连接,数据传输的单位称为段。一个数据段由 TCP 报头和在该数据段中传递的数据组成。

让我们一点一点地把它分解如下:

  • 源端口 : (16 位)这标识了传输数据的起始端口。
  • 目的端口 : (16 位)这是正在传递的数据的目标端口。
  • 序列号 : (32 位)这是一个唯一的标识符号码。由于 TCP 试图让接收方按照发送的顺序接收数据,因此通过 TCP 传输的每个字节都会收到一个序列号。这些数字允许收件人和发件人通过遵循这些数字的顺序来确保顺序。
  • 确认号 : (32 位)这是发送方正在传递的下一字节数据的序列号。本质上,这是对序列号低于该数字的所有数据的确认。
  • 数据偏移量 : (4 位)这指定了 32 位字中报头的长度。如果需要,它允许添加自定义标题组件。
  • 控制位 : (9 位)该位保存关于报头的元数据。
  • 接收窗口 : (16 位)这传达了发送方用于传入数据的剩余缓冲空间量。当试图保持流量控制时,这一点很重要。
  • 紧急指针 : (16 位)这是该段数据的第一个字节和紧急数据的第一个字节之间的差值。这是可选的,并且只有在标题的元数据中设置了URG标志时才相关。

引入插座

在现场视察模型中,有几种不同类型的套接字决定了传输层的结构。两种最常见的类型是流套接字和数据报套接字。在本节中,我们将简要介绍它们以及它们的不同之处。

流套接字

流套接字用于不同主机之间的可靠双向通信。您可以认为流套接字类似于打电话。当一台主机呼叫时,另一台主机的连接被启动;一旦建立了连接,双方就可以来回通信。这种联系就像一条小溪一样持续不断。

我们在本章前面讨论的传输控制协议中可以看到流套接字的使用示例。使用 TCP 允许数据以序列或数据包的形式发送。如前所述,TCP 维护状态,并提供一种方法来确保数据到达,并且与发送的顺序相同。这对于许多类型的应用都很重要,包括 web 服务器、邮件服务器及其客户端应用之间的通信。

在后面的部分,我们将研究如何使用传输控制协议实现自己的流套接字。

数据报套接字

与流套接字相反,数据报套接字不太像打电话,更像是通过邮件发送信件。数据报套接字连接是单向的,并且是不可靠的连接。不可靠是指您不能确定数据报套接字数据何时或是否会到达接收方。没有办法保证数据到达的顺序。

如前一节所述,用户数据报协议使用数据报套接字。虽然 UDP 和数据报套接字更轻量级,但当您只需要发送数据时,它们提供了一个很好的选择。在许多情况下,创建流套接字、建立并维护套接字连接的开销会过大。

数据报套接字和 UDP 通常用于网络游戏和流媒体。当客户端需要向服务器进行简短查询,并且期望收到单个响应时,UDP 通常是一个很好的选择。为了提供这种发送和接收服务,我们需要使用套接字实现中的 UDP 特定函数调用sendto()recvfrom()instead of read()write()

创建一个简单的 TCP 服务器

在本节中,我们将研究使用前面章节中讨论的套接字技术实现一个简单的 TCP 服务器示例的过程。这个例子可以扩展到支持各种游戏需求和功能。

由于每个平台创建服务器的过程略有不同,因此我将示例分成了两个不同的版本。

Windows 操作系统

首先,让我们看看如何使用 Windows 平台上的 WinSock 库来创建一个简单的套接字服务器,该服务器将侦听连接,并在建立连接时打印出一条简单的调试消息。要获得完整的实现,请查看代码库的Chapter10目录:

…
#include <stdio.h>
#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>

#define PORT "44000" /* Port to listen on */

首先,我们有自己的选择。这使我们能够访问创建套接字所需的库(这与其他平台不同)。

if ((iResult = WSAStartup(wVersion, &wsaData)) != 0) {
     printf("WSAStartup failed: %d\n", iResult);
     return 1;
 }

跳到主方法,我们从初始化底层库开始。在这种情况下,我们使用的是 WinSock 库。

 ZeroMemory(&hints, sizeof hints);
 hints.ai_family = AF_INET;
 hints.ai_socktype = SOCK_STREAM;
 if (getaddrinfo(NULL, PORT, &hints, &res) != 0) {
     perror("getaddrinfo");
     return 1;
 }

接下来,我们设置套接字的寻址信息。

 sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
 if (sock == INVALID_SOCKET) {
     perror("socket");
     WSACleanup();
     return 1;
 }

然后我们创建我们的套接字,传递我们在寻址阶段创建的元素。

    /* Enable the socket to reuse the address */
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char *)&reuseaddr,
        sizeof(int)) == SOCKET_ERROR) {
        perror("setsockopt");
        WSACleanup();
        return 1;
    }

在我们创建了套接字之后,最好设置我们的套接字,以便能够重用我们在 closer 或 reset 上定义的地址。

    if (bind(sock, res->ai_addr, res->ai_addrlen) == SOCKET_ERROR) {
        perror("bind");
        WSACleanup();
        return 1;
    }
    if (listen(sock, 1) == SOCKET_ERROR) {
        perror("listen");
        WSACleanup();
        return 1;
    }

现在我们可以绑定我们的地址,并最终监听连接。

while(1) {
        size_t size = sizeof(struct sockaddr);
        struct sockaddr_in their_addr;
        SOCKET newsock;
        ZeroMemory(&their_addr, sizeof (struct sockaddr));
        newsock = accept(sock, (struct sockaddr*)&their_addr, &size);
        if (newsock == INVALID_SOCKET) {
            perror("accept\n");
        }
        else {
            printf("Got a connection from %s on port %d\n",
                inet_ntoa(their_addr.sin_addr), ntohs(their_addr.sin_port));
 …
        }
    }

在我们的主循环中,我们检查新的连接,在收到一个有效的连接时,我们向控制台打印一个简单的调试消息。

    /* Clean up */
    closesocket(sock);
    WSACleanup();
    return 0;
}

最后,我们要自己清理。我们关闭套接字,调用WSACleanup函数初始化清理 WinSock 库。

就这样。我们现在有了一个简单的服务器,它将在我们指定的端口上侦听传入的连接,在本例中为44000

苹果电脑

对于 macOS(和其他基于*nix 的操作系统)来说,这个过程与 Windows 示例非常相似,但是,我们需要使用不同的库来帮助支持我们。

#include <stdio.h>
#include <string.h> /* memset() */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netdb.h>
#define PORT    "44000"

首先,我们有 include,这里我们使用系统套接字,它在*nix 系统上基于 BSD 实现。

int main(void)
{
    int sock;
    struct addrinfo hints, *res;
    int reuseaddr = 1; /* True */
    /* Get the address info */
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    if (getaddrinfo(NULL, PORT, &hints, &res) != 0) {
        perror("getaddrinfo");
        return 1;
    }

在我们的主要功能中,我们从设置寻址信息开始。

    /* Create the socket */
    sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (sock == -1) {
        perror("socket");
        return 1;
    }

然后我们创建我们的套接字,传递我们在寻址阶段创建的元素。

    /* Enable the socket to reuse the address */
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuseaddr, sizeof(int)) == -1) {
        perror("setsockopt");
        return 1;
    }

创建套接字后,最好设置套接字,以便能够重用我们在 closer 或 reset 上定义的地址。

    if (bind(sock, res->ai_addr, res->ai_addrlen) == -1) {
        perror("bind");
        return 1;
    }

    if (listen(sock, 1) == -1) {
        perror("listen");
        return 1;
    }

现在我们可以绑定我们的地址,并最终监听连接。

    while (1) {
        socklen_t size = sizeof(struct sockaddr_in);
        struct sockaddr_in their_addr;
        int newsock = accept(sock, (struct sockaddr*)&their_addr, &size);
        if (newsock == -1) {
            perror("accept");
        }
        else {
            printf("Got a connection from %s on port %d\n",
                    inet_ntoa(their_addr.sin_addr), htons(their_addr.sin_port));
            handle(newsock);
        }
    }

在我们的主循环中,我们检查新的连接,在收到一个有效的连接时,我们向控制台打印一个简单的调试消息。

    close(sock);
    return 0;
}

最后,我们要自己清理。在这种情况下,我们只需关闭插座。

就这样。我们现在有了一个简单的服务器,它将在我们指定的端口上侦听传入的连接,在本例中为44000

为了测试我们的例子,我们可以使用一个现有的程序,比如 putty 来连接到我们的服务器。或者我们可以创建一个简单的客户,我会留给你一个外卖项目。虽然只是一个简单的服务器,但这为构建您自己的实现创造了一个起点。

摘要

在这一章中,我们采取了很大的步骤来理解多人游戏是如何在较低的层次上实现的。您了解了 TCP/IP 堆栈以及用于游戏开发的不同网络拓扑。我们研究了使用 UDP 和 TCP 协议在客户机-服务器设置中传递数据。最后,我们研究了开发人员开始实现多人游戏功能时面临的一些问题。在下一章中,我们将看看如何将我们的游戏带到一个新的领域——虚拟现实。