网站公告 | 泰斗网校全新上线了,可以和论坛用户登录同步,如果遇到登录问题联系管理员解决
查看: 18911|回复: 1
收起左侧

[已翻译] 构建一个端对端的多人在线网络游戏

[复制链接]

[已翻译] 构建一个端对端的多人在线网络游戏[复制链接]

咔咖 发表于 2018-4-17 14:21:44 [显示全部楼层] |只看大图 回帖奖励 |倒序浏览 |阅读模式 回复:  1 浏览:  18911
翻译:陈敬凤(nunu)     审校:张华栋(wcby)
玩一个多人在线网络游戏总是更有意思一点。玩家不再是要击败由人工智能控制的对手,在多人在线网络游戏中他们需要面对的是另外一个真实玩家所创建出来的策略。这篇教程提供了用一种非权威的端对端(P2P)方法来实现基于网络的多人在线游戏的方法。
请注意: 尽管本教程的编程语言是基于AS3和Flash的,但相同的技术和概念完全可以被应用到任何游戏开发环境中。当然前提是,你对本文涉及的网络通信知识有一个基本的理解。当然也需要你对其他的一些编程知识有基本的了解。
你可以通过GitHub(the GitHub repo) 或者压缩的源代码文件(the zipped source files)来下载最终的代码。
最终结果的预览
最终结果的demo,控制方法如下:方向键或者WASD来控制移动,空格键控制开火,B控制布置一个炸弹。
简介
一个基于网络的多人在线游戏有多种不同的实现方式,可以被分为两大类:权威的和非权威的。
在权威这个分类下,最常见的方法是客户端-服务器结构,一个中央实体(全局服务器)来控制整个游戏。每一个连上服务器的客户端会持续的接收网络消息,然后在本地创建并更新游戏状态。这有一点像看电视所发生的行为一样。
DSC0000.png
使用客户端-服务器架构的权威实现
如果客户端执行一个动作,比如从某点移动到另外一点,那么信息会发送给服务器。服务器会检测信息是否正确,然后更新整个游戏的状态。然后它会把这个信息发送给所有连接到它的客户端,所以这些客户端可以根据这些信息来更新他们自己的游戏状态。
在非权威方法里面,并没有中央实体,而是每一端(游戏)都控制着自己的游戏状态。在一个端对端(P2P)的网络结构里,一个端会把数据发送给所有其他的端,然后也从其他的端接收数据,并假设信息是可靠的和正确的(没有任何防作弊处理):
DSC0001.png .
使用P2P架构实现的非权威方法。
我在这个教程里将介绍如何使用非权威的P2P方式来实现一个多人在线网络游戏。这个游戏的类型是死亡竞赛竞技场,每个玩家会控制一个飞船,能够向对方射击和扔炸弹。
我将主要集中介绍下不同的端如何进行状态交流和同步。为了简化,游戏和网络的代码尽可能的抽象。
小提示:权威的方法在防作弊方面更加安全,因为服务器完全控制了游戏状态并且可以忽略任何可疑的信息,比如一个实体说它移动了2000像素,而实际上它只能移动10个像素。(译注:因为所有的游戏状态更新都是以服务器为主,服务器又有完全的数据,客户端的所有请求服务器都可以验证真伪,并且以服务器为准,相当于服务器进行所有的计算,客户端仅仅是负责显示)。
定义一个非权威的网络游戏
一个非权威的多人在线网络游戏并没有一个中央实体在控制整个游戏的状态,所以每一个端必须负责来控制自己本身的游戏状态,并且与其他端进行通信,传递任何的游戏状态变化以及重要的行为。因此,玩家会看到两种不同的同步方式: 自己的飞船的移动是根据自己的输入来执行的,而其他飞船的行为不过只是一个模拟,而且是由其它对手进行控制的。
DSC0002.jpeg
玩家自己的飞船是通过本地进行控制的,而对手的飞船是通过网络通信传递消息,进而根据消息的内容来对模拟对手飞船的行为。
玩家的飞船的移动和动作都是由本地输入直接控制和指引的,所以玩家自己的游戏状态几乎是立刻就会进行更新。但是对于其他玩家的飞船的移动,就要等到玩家接收到每一个对手关于飞船当前信息的网络消息才能进行更新。
这些记录这飞船移动和动作的网络消息需要花费时间穿越网络才能从一台电脑到另外一台电脑,所以当玩家接收到一条消息说对手的飞船在(x,y)的时候,它可能已经不在那个位置了-这就是为什么说本地游戏状态关于其他玩家飞船的部分就是一个模拟的原因。
DSC0003.png
由于网络导致的通信延迟。
为了让模拟变得尽可能的准确,每一个端都有责任只传播自己飞船的信息而不包含其他的游戏状态。这意味着,如果这个游戏有四个玩家,比方说A,B,C和D。玩家A是唯一一个能够广播A的飞船的信息的玩家,这些信息包括A的飞船在哪里、是否被击中、是否开火发射子弹或者扔下炸弹等等。所有其他的玩家都是从A那里接受信息,才能得知他飞船的行动,然后他们会对此做出响应,比如说A的子弹击中了C的飞船,那么C就会广播一条消息说它的飞船已经被摧毁了。
因此呢,每个玩家都能根据接收到的网络消息进行模拟,看到其他玩家的飞船(还有他们的动作)。在一个完美的世界里是没有网络延迟的,所以消息在发出的瞬间就到达了其他玩家的电脑上,所以其他玩家的游戏状态里关于模拟的部分都是极度准确的。
但是呢,随着网络延迟的增大,游戏状态里面对于其他玩家飞船的位置和行为的模拟就会变得越来越不准确。比如说,玩家A的飞船进行了射击,并且在本地看到了子弹击中了B的飞船,但是什么也没有发生。这是因为在A玩家的游戏状态里所有有关B的行为都会由于网络延迟导致一定的滞后。当B真正收到A的子弹信息的时候,B的飞船已经在另外一个位置了,它会发现子弹并没有击中他。
映射相关的动作
游戏实现里面一个非常重要的步骤就是确保每个玩家能够精确的看到相同的模拟,为了做到这一点,需要对相关的动作进行识别。这些动作会去改变当前的游戏状态,比如飞船从一个点移动到另外一个点,扔下一枚炸弹等等。
在我们的游戏里,重要的行为包括以下这些:
·         射击(玩家的飞船发射了一发子弹或者扔了一个炸弹)
·         移动(玩家飞船的移动)
·         死亡(玩家的飞船被摧毁了)
DSC0004.jpeg 在游戏中玩家的行为。
每一个动作都需要在网络上进行发送,所以在动作的数目和需要产生的网络消息的大小之间找到一个平衡是非常重要的。消息越大(也就是说包含了更多的信息),它从网络的一端传递到另外一端所需要花费的时间就会变得更长。这是因为越大的消息会需要越多的网络包。
越短的消息会要求越少的CPU时间来处理封包、发送和解包。小的网络消息也会导致同时有更多的网络消息可以同时被传递,这也提高了吞吐量。
独立地执行动作
相关的动作被映射以后,现在需要做的就是无需用户输入就能够在本地重现。即使这从软件工程的角度看是一个非常好的设计原则,但是从多人在线网络游戏的视角来看未必有那么明显。
以我们游戏中的射击行为作为一个例子来说明下如何本地重建,如果它与输入逻辑是紧密相关的,在不同的环境下重用相同的射击代码几乎是不可能的。
DSC0005.png
独立地执行动作
当射击部分的代码与输入逻辑解耦的时候,比如说,它既能够用来作为玩家发射子弹的代码,又能作为对手方发射子弹的代码(当接收到一个对手发来的发射子弹的网络消息的时候)。它能避免代码重复并且减少很多开发的烦恼。
举个例子来说明,我们游戏的飞船(Ship)类没有多人网络游戏部分的代码,所以它是完全解耦的。它用来描述一个飞船,但是不在乎这个飞船是不是本地模拟的。这个类还提供了几个方法来控制飞船,比如说rotate(),还有一个改变位置的设置方法。因此,多人网络游戏的代码可以用与玩家本地输入代码相同的方式来旋转一艘飞船,区别仅仅是一个是基于本地玩家的输入,而另外一个是基于网络消息。
基于动作进行数据交换
现在所有的相关动作都被映射了,是时候来处理端与端的消息交换来创建本地游戏状态的模拟了。在进行数据交换之前,首先定制通信协议。对于一个多人在线网络游戏的通信来说,协议可以被定义为一组描述消息的结构的规则,因此每个人都可以发送、阅读和理解这些信息。
在游戏中进行交换的消息将被描述为对象,每个对象都会包含一个被称为op(操作码)的强制属性。op是用来识别消息类型和表示消息对象会有的属性。下面是所有消息的结构:
DSC0006.png
网络消息的结构。
·         OP_DIE消息表明一艘飞船被摧毁了。它的x和y属性包含了飞船被摧毁的时候的位置。
·         OP_POSITION消息包含了发送端玩家飞船的当前位置信息。它的x和y属性记录了飞船在屏幕上的坐标,而角度(angle)代表了飞船当前的旋转角度。
·         消息OP_SHOT表明飞船发射了什么(可能是子弹或者是炸弹)。它的x和y属性记录了在飞船发射子弹或者炸弹时候飞船在屏幕上的坐标。dx和dy属性表明了飞船的方向,这将确保子弹可以在所有端重现出来,而且可以在重现的时候让飞船的角度完全一致。b属性定义了抛射体的类型(子弹或者炸弹)。
多玩家(Multiplayer)类
为了组织多玩家部分的代码,我们创建了多玩家(Multiplayer)类。它将负责发送和接收网络消息,同时负责根据接收到的网络消息更新模拟出来的游戏状态,也包括了对本地飞船的更新。
它最初的结构,只包含了消息代码,如下所示:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class Multiplayer
{
    public const OP_SHOT        :String = "S";
    public const OP_DIE         :String = "D";
    public const OP_POSITION    :String = "P";
    public function Multiplayer() {
        // Connection code was omitted.
    }
    public function sendObject(obj :Object) :void   {
        // Network code used to send the object was omitted.
    }
}
发送动作有关的消息
对于之前映射的每一个相关动作,都要在执行的时候发送一个网络消息,所有每一端都会收到这个动作的信息。

当玩家的飞船被子弹击中或者炸弹爆炸的时候,OP_DIE的动作需要被发送出去。在游戏的代码中已经有了一个方法会负责在飞船被击中的时候摧毁玩家的飞船,现在来更新这个方法添加传播网络消息部分:
1
2
3
4
5
6
7
8
9
public function onPlayerHitByBullet() :void {
    // Destoy player's ship
    playerShip.kill();
    // MULTIPLAYER:
    // Send a message to all other players informing
    // the ship was destroyed.
    multiplayer.sendObject({op: Multiplayer.OP_DIE, x: platerShip.x, y: playerShip.y});
}
每一次玩家的飞船改变当前位置的时候,OP_POSITION的动作都需要作为网络消息发送一次。多玩家的代码需要被注入到游戏代码中来对消息进行广播:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public function updatePlayerInput():void {
    var moved :Boolean = false;
    if (wasMoveKeysPressed()) {
        playerShip.x += playerShip.direction.x;
        playerShip.y += playerShip.direction.y;
        moved = true;
    }
    if (wasRotateKeysPressed()) {
        playerShip.rotate(10);
        moved = true;
    }
    // MULTIPLAYER:
    // If player moved (or rotated), propagate the information.
    if (moved) {
        multiplayer.sendObject({op: Multiplayer.OP_POSITION, x: playerShip.x, y: playerShip.y, angle: playerShip.angle});
    }
}
最后呢,每一次玩家的飞船进行射击的时候,OP_SHOT的行为都必须作为网络消息进行发送。发送出去的消息的包括了发射的子弹类型,这样每一端就能看到正确的发射物类型:
1
2
3
4
5
6
7
8
if (wasShootingKeysPressed()) {
    var bulletType :Class = getBulletType();
    game.shoot(playerShip, bulletType);
    // MULTIPLAYER:
    // Inform all other players that we fired a projectile.
    multiplayer.sendObject({op: Multiplayer.OP_SHOT, x: playerShip.x, y: playerShip.y, dx: playerShip.direction.x, dy: playerShip.direction.y, b: bBulletType)});
}
基于接受的数据进行同步
到目前为止,每个玩家都能够控制和看到他们的飞船。而在底层,相关动作的网络消息被发送。唯一缺少的部分就是看不到其他新加入的玩家,只要增加了这部分功能,每个玩家就可以看到其他玩家的飞船并且与它们进行交互。
在游戏里,管理飞船的结构是一个数组。这个数组到目前为止只有一个飞船(玩家的飞船)。为了创建其他玩家行为的模拟,多人玩家类(Multiplayer)将需要做一些改变,这样当新的玩家加入的时候会在数组里面增加新的飞船。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class Multiplayer
{
    public const OP_SHOT        :String = "S";
    public const OP_DIE         :String = "D";
    public const OP_POSITION    :String = "P";
    (...)
    // This method is invoked every time a new user joins the arena.
    protected function handleUserAdded(user :UserObject) :void {
        // Create a new ship base on the new user's id.
        var ship :Ship = new ship(user.id);
        // Add the ship the to array of already existing ships.
        game.ships.add(ship);
    }
}
消息交换代码自动的给每个玩家分配了一个独特的身份(也就是上面代码中的user.id)。当一个玩家加新入到竞技场的时候,这个身份会被多人玩家类用来创建一个新的飞船。这样的话,每个飞船都有一个独特的身份。通过每条接收到的消息里面的验证身份信息,就可以对数组里面的飞船进行查找。
最后,现在是时候在多人玩家类(Multiplayer)里面添加handleGetObject方法了。这个方法会在每次有新的网络消息到达的时候进行调用:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class Multiplayer
{
    public const OP_SHOT        :String = "S";
    public const OP_DIE         :String = "D";
    public const OP_POSITION    :String = "P";
    (...)
    // This method is invoked every time a new user joins the arena.
    protected function handleUserAdded(user :UserObject) :void {
        // Create a new ship base on the new user's id.
        var ship :Ship = new ship(user.id);
        // Add the ship the to array of already existing ships.
        game.ships.add(ship);
    }
    protected function handleGetObject(userId :String, data :Object) :void {
        var opCode :String = data.op;
        // Find the ship of the player who sent the message
        var ship :Ship = getShipById(userId);
        switch(opCode) {
            case OP_POSITION:
                // Message to update the author's ship position.
                ship.x  = data.x;
                ship.y  = data.y;
                ship.angle  = data.angle;
                break;
            case OP_SHOT:
                // Message informing the author' ship fired a projecle.
                // First of all, update the ship position and direction.
                ship.x  = data.x;
                ship.y  = data.y;
                ship.direction.x = data.dx;
                ship.direction.y = data.dy;
                // Fire the projectile from the author's ship location.
                game.shoot(ship, data.b);
                break;
            case OP_DIE:
                // Message informing the author's ship was destroyed.
                ship.kill();
                break;
        }
    }
}
当一条新的网络消息到达的时候,会传2个参数来调用handleGetObject方法。这2个参数是:验证的ID(独特的身份)和消息的数据体。通过分析消息的数据体,操作代码会被提取出来,然后根据提取出来的操作代码,所有其他属性数据都会被提取出来。
通过提取出来的数据,多人在线游戏的代码会重现从网络上接收的所有动作。以OP_SHOT这条消息为例,有以下的步骤来执行当前游戏状态的更新:
1.      通过userId来在本地对飞船进行查找。
2.      根绝接收的数据对飞船的位置和角度进行更新。
3.      根绝接收的数据对飞船的方向进行更新
4.      调用负责发射导弹、子弹或者扔炸弹的游戏方法。
正如之前描述的那样,飞船射击代码是与玩家和输入逻辑相解耦的。所以发射导弹的行为就像是本地玩家操作发射的一样。
减少延迟的问题
如果游戏只是根据网络消息的更新来移动实体,任何丢失或者延迟的信息都会导致实体从一个点闪现到另外一个点。这个问题可以通过本地预测来消除。
比如说使用插值(https://en.wikipedia.org/wiki/Lag#Client-side),实体从一个点到另外一个点的移动是在本地做了插值(起点和终点都是通过网络消息收到的)。这样的话,实体可以在这些点之间平滑的移动。理想状态下,消息的延迟不应该超出实体在一个点到另外一个点插值移动所花的时间。
另外一个技巧是推断(https://en.wikipedia.org/wiki/Lag#Client-side),可以基于实体当前的状态在本地对实体进行移动。这样做需要假设实体不会改变它当前的路线和方向。这样的话,根据实体当前的方向和速度来继续移动实体就是安全的。如果网络延迟不太高,那么推断这种方法可以准确再现实体的期望移动直到一个新的网络更新消息到达,这样做会导致一个平滑的移动模式。
尽管这些技巧在某些程度上可以减少网络延迟带来的影响,但是网络延迟有时候会特别的高而且完全不受控制。最简单的处理方法就是断开与有问题的端的连接。一个安全一点的方法是使用超时:如果一个端超过某个时间还没有回答,那么就断开与它的连接。
总结
让一个多人在线游戏能够在网络上运行起来是一个既有挑战又刺激的任务。它需要一种不同的看待事物的方式,因为所有相关的动作都必须发送给所有的客户端并在每一个客户端上重现。因此,所有的玩家看到的都是已经发生事情的模拟,除了主机以外,因为主机没有网络延迟。
这个教程描述了如何使用非权威的P2P方式来实现一个多人在线游戏。这里面提出的所有概念都可以被扩展用来实现不同的多人在线游戏机制。现在让我们开始制作多人在线游戏吧!
关于作者
Fernando是一名计算机科学方向的教授,他在业余时间会作为独立开发者开发一些好玩的项目。他运营着一个Flash游戏开发网站: As3GameGears.
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

+1
18904°C
1
  • GRYTAIN
过: 他们
因分享而快乐,学习以自强!
GRYTAIN 发表于 2018-5-9 10:59:10 显示全部楼层
感谢分享
因分享而快乐,学习以自强!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

VR/AR版块|Unity3d|Unreal4|新手报道|小黑屋|站点地图|沪ICP备14023207号-9|【泰斗社区】-专注互联网游戏和应用的开发者平台 ( 浙ICP 备 13006852号-15 )|网站地图

© 2001-2013 Comsenz Inc.  Powered by Discuz! X3.4

1
QQ