手游框架设计<三>

2015年09月28日 11:06 0 点赞 0 评论 更新于 2025-11-21 13:33

网游协议开发

1. 使用中间语言定义协议

在过去的项目中,协议定义通常采用如下方式:

struct MSG_XX_MOVE {
int x,y;
int vx,vy;
int ax,ay;
};

前后端均使用 C++ 开发,并共同包含这份文件。然而,这种方式存在明显不足。C++ 在描述协议方面不够简洁,难以方便地表达可选情形。而且,一旦在类中加入各种方法(如流处理等),代码会迅速变得庞大。更严重的是,每次修改协议,代码编译时间会变得极长。

若采用中间格式来定义协议,情况将大为简化,例如使用 Protocol Buffers(protobuf):

message MSG_XX_MOVE {
required int32 x = 1;
required int32 y = 2;
optional int32 vx = 3;
optional int32 vy = 4;
}

为使协议更加规范清晰,我们规定协议分为两种模型:请求 - 应答和推送(服务器主动通知),这通常能满足游戏的需求。同时,规定协议后缀名如下:

  • XX_MOVE_Request:用于请求数据。
  • XX_MOVE_Response:用于接收数据。
  • XX_MOVE_Notify:用于推送数据。

其中,请求必定会有应答返回,而推送则是服务器主动发包。此外,Response 必定包含 ReturnCode,用于告知业务上的情况。以下是一个聊天协议的示例:

message CharMsgRequest {
required int32 recvId;
required string content;
}
message CharMsgResponse {
enum ErrorCode {
Error = 0;
Success = 1;
}
required ErrorCode returnCode = 1;
optional int32 recvId = 2;
}
message CharMsgNotify {
required int32 senderId;
required string content;
}

通过这样的设计,无需过多解释,就能清晰了解整个聊天流程。借助 protobuf,我们可以高效地设计协议,从而提升开发生产力。

2. 自动编码、解码

在某些项目中,数据包需要手动进行流处理,例如:

-- 编码
stream.writeInt(10)
stream.writeString("hi")

云风的 pbc 库可以实现自动编码和解码,示例代码如下:

-- 编码
pbc.encode("CharMsgRequest", {recvId = 10, content = "hi"})
-- 解码
local buf = getBuf()
local data = pbc.decode("CharMsgResponse", buf)
print(data.returnCode)

将编码和解码封装在网络层,使用起来会更加便捷。

3. 事件机制

在游戏开发中,我们期望各个功能模块尽可能独立,事件机制是实现这一目标的有效手段。例如,排行榜系统希望点击榜中角色时能够跳转到详情界面,此时只需向系统派发跳转详情消息,系统会将事件传递给感兴趣的对象并进行相应处理。

与直接调用相比,事件机制具有以下优点:

  • 松耦合:无需依赖详情系统,只需派发事件即可。
  • 简洁:详情系统只需专注于提供服务。
  • 灵活:其他系统可以对事件进行监听和拦截,使系统更具弹性。

4. 网络层

网络层的核心功能包括高效、低延迟、省流量、安全地与服务器通信,以及方便调试。游戏的流畅度在很大程度上与协议设计相关,而网络层的性能有时是次要因素。以下是一个粗略的设计,协议使用 protobuf 描述:

4.1 高效

在协议设计上,我们提供合并数据包的机制。网络包定义如下:

message NetworkPackage {
required int32 msgCount = 1;
repeated GameMsg msg = 2;
}

一次发包时,应尽可能将多个要发送的数据合并。通过上述定义,我们可以知道数据包中包含多少条消息,进而实现自动合并数据,减少传送次数,提升用户体验。

4.2 低延迟

网络性能在很大程度上与使用方式有关。对于对时服务、位置同步等对延迟敏感的功能,使用 UDP 比 TCP 效果更好。此外,关闭 Nagle 算法可以有效降低延迟。

4.3 省流量

对网络包进行压缩编码可以显著减小其体积,结合合并消息包的方式,能够节省大量数据量。

4.4 安全

一般情况下,使用 OpenSSL 进行加密就足以满足安全需求。

4.5 方便调试

我们希望网络层便于调试,并且能够像沙盒一样使用假数据。为此,我们可以设计一个“假服务器”,它可以像真实服务器一样接受和返回数据,但所有操作都在本地进行,返回的数据为假数据。“假服务器”的好处在于方便调试,特别是在项目早期,服务器功能不完善时,使用“假服务器”可以加快开发速度,减少 bug 数量,许多 bug 可以在本地被及时发现。

5. 利用协程

通过协程,我们可以采用与传统异步方式不同的编程模式,甚至在代码中难以看出是异步操作。作者第一次接触协程是在使用 Lua(Lua 5.1)时,当时非常兴奋并重新编写了框架,将协程封装起来供使用者调用。但后来发现 Lua 对协程的支持存在问题,当在 Lua 的 C 函数中调用 yield 后,无法再调用 resume(原因是 C 函数的调用栈帧已被释放)。不过,现在的 Lua 版本已经有了较好的支持。以下是使用回调和协程实现异步操作的区别:

回调方式

player.login(function()
print("I'm login")
end)

协程方式

player.login()
print("I'm login")

在上述示例中,当调用 player.login() 时,网络层以非阻塞方式完成发包。协程实际上是对回调进行了包装,当接收到数据包并调用回调函数时,会恢复到 yield 处继续执行,就像没有发生等待一样。作者建议尽量将协程隐藏在底层,例如网络层,这样可以使代码更加自然优雅。

6. 游戏世界的高层同步

此概念源自《游戏编程精粹 7》,有兴趣深入了解的同学可以自行查阅相关资料。其基本思想是通过配置元数据来实现某些玩家属性的自动同步,从而简化逻辑。以下是一个英雄强化协议的示例:

message TrainHeroRequest {
required int32 heroId = 1;
}
message TrainHeroResponse {
enum ErrCode {
Success = 0;
NoItem = 1;
NotEnoughMoney = 2;
NoEmptyTrainRoom = 3;
Fail = 4;
}
required ErrCode returnCode = 1 [default = Success];
optional GameCoin consume = 2;
}

在上述协议中,服务器会告知客户端实际花费的金额,客户端据此扣除相应金额。在手游中,这种简单的设计是合理的。但在情况较为复杂时,最好采用自动同步的方式,无需专门编写协议通知客户端进行同步。

自动同步的优缺点如下:

  • 优点:节省了大量重复的数据同步定义,完善后不易出错。
  • 缺点:跟踪较为困难(可通过为每次属性变动添加 reason 并自动记录日志来解决),实现自动同步需要环境支持服务器推送消息。许多手游基于 HTTP 协议,实现这种同步推送不太方便,需要另外建立长连接。此外,开发一套同步代码也需要花费一定的精力。

作者信息

洞悉

洞悉

共发布了 3994 篇文章