创建数据包
如何将游戏数据编码到数据包中
运行RakNet的系统,事实上所有在因特网上的系统,都是通过人们所熟知的数据包进行通信。或更加准确点在UDP下,它用的是数据报。每一个数据报由RakNet创建,并且包含了一条或多条消息。消息可以由你创建,例如位置或健康(health这个词确实不知道如何翻译好),或者有时由RakNet内部创建的数据,例如pings。按照惯例,消息的第一个字节包含了一个从0到255的数字标识符,它用于表明消息的类型。RakNet有一大组内部使用的消息,或者插件使用的标识符。这些可以在文件MessageIdentifiers.h查看到详细信息。
这个例子,我们在游戏中设置一个定时炸弹。我们需要下面的数据:
1. 地雷的位置,需要3个floats值,float x,float y,float z。可以定义自己使用的向量类型。
2. 定义一些所有系统都一致可以访问“地雷”的方法。NetworkIDObject类是非常好的一个方法。我们假设类Mine从NetworkIDObject派生。我们要存储的就是获取地雷的NetworkID。(更多信息参考Receiving Packets,Sending Packets)。
3. 谁拥有这个地雷。如果有人猜到了地雷,我们要知道积分应该给谁。创建到player,SystemAddress的索引是非常好的,可以使用GetExternalID()方法获取SystemAddress。
4. 地雷爆炸的时间。我们假设10秒之后地雷自动爆炸,那么获取准确的时间就非常重要了,否则炸弹会出现不同电脑上的爆炸时间不同。幸运的是,RakNet内建了这种功能,可以使用Timestamping来实现。
使用结构体或位流?
最后,任何时候发送数据都是发送一个字符流。有两种很容易的方法将数据编码成为这种格式。一种是创建一种结构体,然后将它转化为(char *),另外一种就是使用内置的BitStream类。
创建结构体进行转化的优点是很容易修改结构,并且可以看到你事实上正在发送的数据。由于发送者和接收者能够共享定义了结构体的文件,避免了转化的错误。也没有让数据乱序,或者使用错误类型的危险。创建结构体的不足就是常常不得不改变和重新编译文件。并且丧失了使用Bitstream类进行自动压缩的便利。并且RakNet不能自动转换结构体成员的字节序。
使用Bitstream的优点是不需要改变任何外部文件。仅仅需要一个bitstream,在其中写入你想要写入的数据,然后发送即可。可以使用“Compressed”版本的read和write方法写入相对较少的数据,例如使用它写入bool类型,仅仅需要一位。可以动态写入数据,在某些确定情况下写的值是true或者false。使用Serialize(),Write(), Read()等方法写的数据,Bitstream会自动进行网络字节序的转换。Bitstream的不足就是很容易出现数据处理错误。读取数据的方式与写入的方式不完全相同-错误的序列,或者一个字节的错误数据,或者其他的错误。
下面会介绍两种方法创建数据包。
使用结构体创建数据包
我早先已经提到了,RakNet标识数据包类型的方法是一种惯例。数据域的第一个字节是一个单字节的枚举类型数据,它指明了数据的类型,紧跟着的是传输的数据。在包含了时间戳的数据包中,第一个字节包含了ID_TIMESTAMP,接下来的四个字节是实际的时间戳值,再向后的这个字节才是数据包数据类型的标识,它之后才是传输的实际数据。
没有时间戳的情况
#pragma pack(push, 1)
struct structName
{
unsigned char typeId; // 数据类型
// 放置数据
};
#pragma pack(pop)
注意#prama pack(push, 1)和#prama pack(pop),他们强制编译器(在VC++下)按照字节对齐的方式填充数据结构体。检查你自己的编译器,获得更多的相关配置信息。
带有时间戳
#pragma pack(push, 1)
struct structName
{
unsigned char useTimeStamp; // 赋值 ID_TIMESTAMP值
RakNet::Time timeStamp; // 将由RakNet::GetTime()返回的系统时间值或其他方式返回的类似值
unsigned char typeId; // 你的类型放到这里
// 这里放数据
};
#pragma pack(pop)
注意发送数据的时候,RakNet假设timeStamp是网络字节序。必须使用timeStamp域的函数BitStream::EndianSwapBytes()实现字节序的变换。在接收系统上读取时间戳,使用if (bitStream->DoEndianSwap()) bitStream->ReverseBytes(timeStamp, sizeof(timeStamp)获得时间戳。如果使用的是BitStream这一步就不需要了。
填充数据包。对于我们的定时地雷,我们需要使用timeStamping的格式。最终的结果应该如下这种形式…
#pragma pack(push, 1)
struct structName
{
unsigned char useTimeStamp; // 对它赋值 ID_TIMESTAMP
RakNet::Time timeStamp; // 将RakNet::GetTime()返回的系统时间值放到这里
unsigned char typeId; // 应该将你定义的一个枚举类型数据放到这里,例如ID_SET_TIMED_MINE
float x,y,z; // 地雷的位置
NetworkID networkId; // 地雷的NetworkID, 用于当做一个方法来指向不同电脑上的地雷
SystemAddress systemAddress; // 拥有该地雷的SystenAddress
};
#pragma pack(pop)
像上面的注释中写的,我们必须要为我们自己的数据定义枚举类型,那么当数据到达了接收函数中,我们就知道该如何处理了。你应该定义你自己的枚举类型,格式可以参考下面的格式:
// 定义自己用的数据包ID
enum {
ID_SET_TIMED_MINE = ID_USER_PACKET_ENUM,
// 更多的枚举类型数据....
};
注意在结构体中不能直接或间接包含指针。
人们将指针或带指针的类放到结构体中的现象是非常普遍的错误,并且认为指针指向的数据会通过网络发送。这也不总是这种情况 – 它发送的所有数据是指针地址。
嵌套结构体
使用嵌套数据结构没有问题。但是要记住,第一个字节总是数据类型。
#pragma pack(push, 1)
struct A
{
unsigned char typeId; // ID_A
};
struct B
{
unsigned char typeId; // ID_A
};
struct C // Struct C is of type ID_A
{
A a;
B b;
};
struct D // Struct D is of type ID_B
{
B b;
A a;
};
#pragma pack(pop)
使用BitsStreams创建数据包
使用bitstream写入更少的数据
我们还是以上述的地雷的例子作为例子,在这里使用bitstream将它发送出去。数据与前面是相同的。
MessageID useTimeStamp; // 赋值 ID_TIMESTAMP
RakNet::Time timeStamp; // 将RakNet::GetTime()返回的系统时间放到这里
MessageID typeId; /这里放的是在ID_USER_PACKET_ENUM定义的枚举数据,例如 ID_SET_TIMED_MINE
useTimeStamp = ID_TIMESTAMP;
timeStamp = RakNet::GetTime();
typeId=ID_SET_TIMED_MINE;
Bitstream myBitStream;
myBitStream.Write(useTimeStamp);
myBitStream.Write(timeStamp);
myBitStream.Write(typeId);
// 假设我们有一个地雷对象Mine* mine
myBitStream.Write(mine->GetPosition().x);
myBitStream.Write(mine->GetPosition().y);
myBitStream.Write(mine->GetPosition().z);
myBitStream.Write(mine->GetNetworkID()); // 在结构体中这里是NetworkID networkId
myBitStream.Write(mine->GetOwner()); // 在结构体中这里是SystemAddress systemAddress
如果我们将myBitStream发送到RakPeerInterface::Send(),它会内在地在这块转换为结构体。现在进行一些改进。由于有些原因,假设大多数的地雷是在0,0,0。然后可以这样发送数据。
unsigned char useTimeStamp; //赋值为 ID_TIMESTAMP
RakNet::Time timeStamp; // 将RakNet::GetTime()返回的系统时间值放到这里
unsigned char typeId; //这里赋值一个在ID_USER_PACKET_ENUM定义的枚举类型,例如ID_SET_TIMED_MINE
useTimeStamp = ID_TIMESTAMP;
timeStamp = RakNet::GetTime();
typeId=ID_SET_TIMED_MINE;
Bitstream myBitStream;
myBitStream.Write(useTimeStamp);
myBitStream.Write(timeStamp);
myBitStream.Write(typeId);
// 假设有一个地雷对象 Mine* mine
// 如果雷的位置是0,0,0, 可以使用1位代替
if (mine->GetPosition().x==0.0f && mine->GetPosition().y==0.0f && mine->GetPosition().z==0.0f)
{
myBitStream.Write(true);
}
else
{
myBitStream.Write(false);
myBitStream.Write(mine->GetPosition().x);
myBitStream.Write(mine->GetPosition().y);
myBitStream.Write(mine->GetPosition().z);
}
myBitStream.Write(mine->GetNetworkID()); // 在结构体中此处为 NetworkID networkId
myBitStream.Write(mine->GetOwner()); //在结构体中此处为SystemAddress systemAddress
这里可以节省3个floats类型数据大小,而仅仅需要1位。
共同的错误:
在写入第一个字节的时候,确保将它转换为(MessageID)或(unsigned char)。如果你仅仅直接写入枚举类型数据,会写入一个整数(四个字节)。
正确:
bitStream->write((MessageID)ID_SET_TIMED_MINE);
错误:
bitStream->Write(ID_SET_TIMED_MINE);
在第二种情况下,RakNet中读取出来的第一个字节是0,这个是保留给ID_INTERNAL_PING的,绝对不能忘记。
写入字符串
可以使用BitStream的数组写入字符串。一种方法是先写入长度,然后写入数据,例如:
void WriteStringToBitStream(char *myString, BitStream *output)
{
output->Write((unsigned short) strlen(myString));
output->Write(myString, strlen(myString);
}
解码是类似的。然后,这样不太高效。RakNet存在一个内建的StringCompressor,称为stringCompressor。它是一个全局的实例。使用它将字符串写入BitStream如下:
void WriteStringToBitStream(char *myString, BitStream *output)
{
stringCompressor->EncodeString(myString, 256, output);
}
它不仅编码字符串,sniffers无法很容易读取字符串,而且压缩了字符串。解码字符串可以按照如下的方法。
void WriteBitStreamToString(char *myString, BitStream *input)
{
stringCompressor->DecodeString(myString, 256, input);
}
256是读取和写入的最大的字节数。在EncodeString中,如果字符串少于256,它会写入整个字符串。如果大于256个字符,将截断字符串,那么将解码为256个字符的数组,包括结束符。
RakNet也包含一个字符串类,RakNet::RakString,可以在RakString找到。
RakNet::RakString rakString("The value is %i", myInt);
bitStream->write(rakString);
RakString比std::string的速度快3倍。
RakString支持Unicode。
编程人员注意:
1. 可以直接将结构体写入BitsStream,只需要将结构体转化为(char *)。它会使用内存拷贝memcpy拷贝结构体。使用了结构体,就会将指针废弃,因此不要将指针写入bitstream。
2. 如果使用string非常频繁,可以使用StringTable类来代替,它和StringCompressor类类似,但是可以使用两个字节来代替一个一直字符串。