如何用非命令式P2P方法执行联网游戏

发布时间:2013-08-28 17:22:28 Tags:客户端服务器架构,网络通信,联网游戏,非命令式P2P

作者:Fernando Bevilacqua

玩多人游戏总是更有趣,因为玩家面对的不是AI控制的对手,而是另一名真人玩家。本教程将介绍如何使用非命令式P2P方式执行一款多人联网游戏。

注:尽管本教程中的案例是用AS3和Flash制作的,你仍然可以在几乎任何游戏开发环境中使用相同的技术和概念。另外,你必须掌握网络通信的基础知识。

简介

联网多人游戏具有若干不同的执行办法,可分为两类:命令式的和非命令式的。

对于命令式,最常用的方法是客户端服务器架构,即由中央实体(命令服务器)控制整个游戏。每一个连接到服务器的客户端都要不断地接收数据,然后本地生成游戏状态并显示出来。这有点像看电视。

p2p联机通讯_第1张图片

img1_server_client(from gamedev.tutsplus)

(使用客户端服务器架构的命令式执行方法)

如果客户端执行一个动作,如从一个点移动到另一个地,那么这个信息就会发送给服务器。服务器查看这个信息是否正确,然后更新它的游戏状态。之后,服务器把信息传送给所有客户端,这样它们就可以相应地更新自己的游戏状态。

对于非命令式,没有中央实体,各个P(游戏邦注:这里的“P”是指联网中的电脑运行的游戏)控制自己的游戏状态。在P2P方法中,P发送数据给所有其他P,并接收来自它们的数据,假设那些信息都是正确可靠的(无作弊):

p2p联机通讯_第2张图片

img2_p2p(from gamedev.tutsplus)

(使用P2P架构的非命令式执行方法)

在本教程中,我将介绍如何使用非命令式P2P办法执行多人联网游戏。这款游戏是一个死亡竞技场,各玩家分别控制一架可以射击和投弹的飞船。

我重点解释P状态的通信和同步。为了简化,我尽量把游戏和网络代码抽取出来。

注:命令式方法更不容易受到作弊行为的损害,因为服务器完全控制游戏状态,忽略任何可疑的信息,如一个客户端说自己移动了200象素而实际上只有10象素。

什么是“非命令式游戏”?

非命令式多人游戏没有控制游戏状态的中央实体(服务器),所以各个P都必须自己控制自己的游戏状态、交流任何变化和与其他P有关的动作。结果是,玩家会同时看到两个场面:他的飞船根据他的输入移动,和所有由其他玩家控制的飞船的模拟结果。

p2p联机通讯_第3张图片

img3_simulation(from gamedev.tutsplus)

(玩家的飞船是本地控制的。对手飞船是根据网络通信模拟的。)

玩家的飞船移动和动作是由本地输入控制的,所以玩家的游戏状态几乎是立即更新的。在所有其他飞船移动时,玩家必须接收来自所有关于对手飞船当前位置的网络信息。

那些信息在电脑之间传输是需要时间的,所以当玩家收受告之对手飞船的位置信息时,对手飞船可能已经改变位置了—-这就是要先模拟的原因:

p2p联机通讯_第4张图片

由网络导致的通信延迟(from-gamedev)

为了保持模拟的准确,各个P都只负责传播自己的飞船的信息,而不管其他飞船的信息。这意味着,如果游戏中有四名玩家A、B、C和D,玩家A只能告之飞船A的位置、它是否被击中、它是否发射或投弹,等等。所有其他玩家会接收到来自A的信息,然后做出相应的反应,所以如果A的***打到C的飞船,那么C只会把自己被打中的信息发给A。

所以,所有其他飞船只会根据自己接收到的信息做出行动。在理想的情况下,也就是没有网络延迟的情况下,信息传输会非常及时,模拟会极其精确。

然而,随着延迟增加,模拟会变得不准确。例如,玩家A射击,本地看到***打中B的飞船,但什么事也没发生;那是因为网络延迟。当B确实收到A的***发射信息时,B已经在另一个位置了,所以它不会被击中。

映射相关动作

执行游戏和保证所有玩家能够看到相同的准确的模拟的重要一步是,识别相关动作。那些动作会改变当前游戏状态,如从一个点移动到另一个点、投弹,等等。

在我们的游戏中,重要的动作是:

射击(玩这的飞船发射***或投×××)

移动(玩家的飞船移动)

死亡(玩家的飞船被摧毁)

p2p联机通讯_第5张图片

img5_actions(from gamedev.tutsplus)

(玩家飞船在游戏中的三种状态)

所有动作都必须在网络之间传送,所以必须在动作量和它们产生的网络信息量之间寻找平衡点。信息越大(也就是它包含的数据越多),它需要的传输时间就越长,因为它需要更多网络包。

简短的信息需要的CPU打包、发送和解压的时间更短。网络信息越小,相同时间内传送的信息就越多,这意味着吞吐率更高。

独立执行动作

映射相关动作后,就应该让它们在不需要玩家输入的情况下复写。即使那是优秀的软件开发的原则,但这一点对于多人游戏可能并不明显。

以游戏中的射击动作为例,如果它与那种输入逻辑有深刻的联系,那么在不同的情形下,游戏就不可能再次使用相同的射击代码:

p2p联机通讯_第6张图片

独立执行动作(from gamedev.tutsplus)

当射击代码与输入逻辑不挂钩时,游戏就可以使用相同的代码来执行玩家的射击和对手的射击(当这种网络信息到达时)。这样就避免代码重复和节省工作量了。

在我们的游戏中,飞船类没有多人代码;它只描述飞船是本地的或非本地的。然而,这个类有数种操作飞船的办法,如旋转()和改变位置。所以,多人代码可以用玩家输入代码一样的方式旋转飞船—-区别是,一个是根据本地输入,而另一个是根据网络信息民。

根据动作交换数据

现在,所有相关动作都映射好了,可以在不同P之间交换信息,以生成模拟。在交换数据以前,通信协议必须格式化。对于多人游戏通信,协议可以定义为一系列描述信息如何构建以便所有人可以发送、读取和理解信息的规则。

游戏中的交换的信息就是一个对象,包含一个强制属性即op(操作码)。这个op用于识别信息类型和表明这个信息对象具有的属性。以下是所有信息的结构:

p2p联机通讯_第7张图片

网络信息的结构(from gamedev.tutsplus)

OP_DIE表示飞船被摧毁。它的x和y属性表示飞船被摧毁时所处的位置。

OP_POSITION表示飞船的当前位置。它的x和y属性表示飞船在屏幕上的坐标,angle表示飞船当旋转角度。

OP_SHOT表示飞船朝某飞船射击或投弹。它的x和y属性表示飞船射击时所处的位置;dx和dy属性表示飞船的方向,这保证其他P会使用相同的角度 模拟射击的飞船。b属性表示射击物的类型(***或×××)。

Multiplayer类

为了组织多玩家代码,我们创建了一个Multiplayer类。它负责发送和接收信息,以及根据接收到的反映游戏模拟的当前状态的信息来更新本地飞船。

它的初始结构,只包含一条信息代码,即:

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.
}
}

发送动作信息

为提前映射所有相关动作,网络信息必须发送,这样才能让所有P知道那个动作是什么。

当玩家被***或×××命中时,OP_DIE动作应该发送。游戏代码中已经有一个当飞船被击中而摧毁的方法了,所以游戏更新,以传送那个信息:

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动作就要发送一次。在游戏代码中添加multiplayer代码来传播那个信息:

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都要发送一次。这个信息包含发射物类型,这样所有P就会看到正确的发射物:

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类就会增加新飞船到那个数组中:

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()到Multiplayer类中了。这个方法是调用新信息到达的时间:

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;
}
}
}

当新信息到达时,handleGetObject()方法调用两个参数:author ID(唯一识别符)和信息数据。分析信息数据,据此,要提取操作代码和所有其他属性。

使用提取后的数据,multiplayer代码重新产生所有通过网络接收到的动作。以OP_SHOT信息为例,更新当关游戏状态有以下几步:

1、查看本地飞船的userId

2、根据接收到的信息更新飞船的位置和角度

3、根据接收到的信息更新飞船的方向

4、调用负责射击属性的游戏方法,发射***或×××

正如之前描述的,射击代码与玩家和输入逻辑不挂钩,所以这个射击动作表现得与玩家本地射击完全一样。

缓解延迟问题

如果游戏只根据网络更新来移动实体,那么任何丢失的或延迟的信息都会导致实体从一个位置“超时空转换”到另一个位置。我们可以用本地预测来缓和这个问题。

例如使用插值法,实体移动是在本地用一个点替换另一个点(都被网络更新接收)。结果,实体会流畅地在这些点之间移动。理想情况下,延迟应该不会超过实体从一点替换到另一点所需的时间。

另一个技巧是外推法,也就是根据实体的当前状态本地移动它。假设实体不会改变它的当前路径,那么就可以根据它的当前方向和速度移动。如果延迟不太严重,外推法就可以准确地重新产生预期的实体移动,直到新的网络更新到达,这样移动也会是流畅的。

尽管有了这些技巧,有时候网络延迟还是非常严重,无法控制。早期解决这个问题的办法是断开有问题的P。比较安全的办法是暂停游戏:如果P在一定的时间内不响应,就断开它的连接。

总结

执行多人联网游戏是一件有难度又令人兴奋的任务。它需要你兼顾不同的事,因为所有P都要发送和重新产生所有相关的动作。这样,所有玩家才能及时地看到除了不会延迟的本地飞船外,其他飞船的模拟情况。

本教程描述了如何使用非命令式P2P方法执行多人游戏。以上所有概念都可以拓展到不同的多人机制中。(本文为游戏邦/gamerboom.com编译,拒绝任何不保留版权的转载,如需转载请联系:游戏邦