18、序列化与游戏开发:游戏存档的序列化、网络游戏数据包设计
游戏开发里,序列化是个绕不开的话题。你想想看,玩家辛辛苦苦打了一关,存档要是坏了,那不得骂娘?还有网络游戏,你发一个数据包过去,对方解析出来全是乱码,这游戏还怎么玩?
我这些年做嵌入式,也顺手帮朋友搞过几个小游戏的存档模块。说白了,游戏里的序列化,跟嵌入式里把结构体写到Flash里,本质是一回事。都是把内存里的数据,变成一串字节流,存起来或者发出去。
18.1 游戏存档:把内存“拍扁”存起来
游戏存档,最常见的就是保存玩家状态。比如角色等级、血量、坐标、背包物品。这些数据在内存里是一个个结构体,存到文件里就得变成连续的字节。
我习惯的做法,是定义一个存档结构体,然后直接写二进制。为什么不用文本?因为快,而且省空间。你想想看,一个int是4字节,写成文本“12345”要5个字节,还得多做一次转换。
核心原则:存档序列化,就是定义好内存布局,然后整块读写。但要注意字节对齐和跨平台问题。
18.1.1 一个简单的存档结构
// 游戏角色存档结构
#pragma pack(push, 1) // 取消字节对齐,避免结构体里有空洞
typedef struct {
uint32_t magic; // 魔数,用于校验文件完整性
uint32_t version; // 版本号,方便以后升级
uint32_t level; // 等级
float hp; // 血量
float max_hp; // 最大血量
float pos_x; // 坐标X
float pos_y; // 坐标Y
uint8_t item_count; // 背包物品数量
uint32_t item_ids[32]; // 物品ID数组
uint32_t checksum; // 校验和,防止文件损坏
} SaveData;
#pragma pack(pop)
嗯,这里要注意。我早期做存档时,没加magic和checksum。结果有一次玩家存档文件被截断了,程序直接崩溃。从那以后,我每个存档开头都放一个魔数,结尾放校验和。加载时先检查魔数对不对,再算一遍校验和,不对就提示“存档损坏”。
18.1.2 存档的写入与读取
// 写入存档
int save_game(const char* filename, SaveData* data) {
// 计算校验和
data->checksum = 0;
// 跳过magic和checksum字段,计算中间数据的CRC32
uint8_t* buf = (uint8_t*)data;
uint32_t len = sizeof(SaveData) - 8; // 减去magic和checksum
data->checksum = crc32(buf + 4, len); // 从version开始算
FILE* fp = fopen(filename, "wb");
if (!fp) return -1;
size_t written = fwrite(data, 1, sizeof(SaveData), fp);
fclose(fp);
return (written == sizeof(SaveData)) ? 0 : -1;
}
// 读取存档
int load_game(const char* filename, SaveData* data) {
FILE* fp = fopen(filename, "rb");
if (!fp) return -1;
size_t read = fread(data, 1, sizeof(SaveData), fp);
fclose(fp);
if (read != sizeof(SaveData)) return -1;
// 校验魔数
if (data->magic != SAVE_MAGIC) return -1;
// 校验数据完整性
uint32_t saved_checksum = data->checksum;
data->checksum = 0;
uint32_t calc_checksum = crc32((uint8_t*)data + 4, sizeof(SaveData) - 8);
if (calc_checksum != saved_checksum) return -1;
return 0;
}
我的习惯:存档里一定要留版本号字段。游戏更新后,结构体可能变了。有了版本号,加载时可以根据版本做兼容处理。我曾经因为没留版本号,导致老存档全部报废,被玩家追着骂了一个星期。
18.2 网络游戏数据包:字节流里的“信封”
网络游戏跟单机游戏不一样。数据要经过网络,你不能直接发一个结构体过去。为什么?因为两台机器的字节序可能不同,结构体对齐也可能不同。你发一个int,对方可能读成两个short。
所以,网络数据包的设计,核心就是定义一套双方都认的协议。说白了,就是约定好:第一个字节是什么,第二个字节是什么,每个字段占多少位。
18.2.1 数据包的基本结构
我一般把数据包分成三部分:包头、包体、校验。包头固定长度,包体可变长度。
// 网络数据包头
typedef struct {
uint16_t length; // 包体长度(不包含包头自身)
uint16_t cmd_id; // 命令ID,比如 0x01=移动,0x02=攻击
uint32_t seq; // 序列号,用于防重放和乱序
} PacketHeader;
// 一个移动数据包的例子
typedef struct {
float x;
float y;
float z;
float angle;
} MovePacket;
发送时,先把包头和包体拼起来,然后统一转成网络字节序(大端)。接收时,先收包头,解析出长度,再收包体,然后转回主机字节序。
注意:千万不要直接memcpy结构体到网络缓冲区!不同平台的字节对齐、填充字节都不一样。我曾经见过一个项目,在x86上跑得好好的,移植到ARM上就全乱了。原因就是结构体对齐方式变了。
18.2.2 手动序列化:最稳妥的方式
为了避免平台差异,我建议手动序列化。每个字段都按字节拆开,放到缓冲区里。
// 手动序列化一个移动包
int serialize_move_packet(uint8_t* buffer, size_t buf_size, MovePacket* pkt) {
if (buf_size < sizeof(PacketHeader) + sizeof(MovePacket)) {
return -1; // 缓冲区不够
}
PacketHeader hdr;
hdr.length = htons(sizeof(MovePacket)); // 转网络字节序
hdr.cmd_id = htons(CMD_MOVE);
hdr.seq = htonl(get_next_seq());
// 拷贝包头
memcpy(buffer, &hdr, sizeof(PacketHeader));
// 手动序列化包体
uint8_t* body = buffer + sizeof(PacketHeader);
uint32_t x_net = htonf(pkt->x); // 自定义的float转网络字节序
uint32_t y_net = htonf(pkt->y);
uint32_t z_net = htonf(pkt->z);
uint32_t angle_net = htonf(pkt->angle);
memcpy(body, &x_net, 4);
memcpy(body + 4, &y_net, 4);
memcpy(body + 8, &z_net, 4);
memcpy(body + 12, &angle_net, 4);
return sizeof(PacketHeader) + sizeof(MovePacket);
}
你可能会问,float怎么转网络字节序?标准C库没有htonf。我的做法是:先把float转成uint32_t(用memcpy或者union),然后调用htonl。接收端反过来。
避坑指南:我曾经在序列化float时,直接用了强制类型转换 *(uint32_t*)&f。这在某些编译器上会触发未定义行为,因为指针别名规则。正确的做法是用memcpy或者union。
18.3 数据包设计中的常见模式
做网络游戏数据包,有几个模式我经常用。这里列出来,供你参考。
| 模式 | 说明 | 适用场景 |
|---|---|---|
| 固定长度包 | 每个包长度固定,解析简单 | 心跳包、小数据量命令 |
| 变长包(TLV) | Type-Length-Value,灵活但解析稍复杂 | 聊天消息、物品列表 |
| IDL生成 | 用接口描述语言定义协议,自动生成序列化代码 | 大型项目,多人协作 |
我个人比较喜欢TLV模式。虽然解析起来多几行代码,但扩展性极好。比如你要在移动包里加一个“速度”字段,TLV模式直接加一个新Type就行,老客户端解析到不认识Type就跳过,不影响兼容性。
18.4 序列化与反序列化的性能考量
游戏里,序列化性能很重要。尤其是网络游戏,每帧可能收发几百个数据包。如果序列化太慢,CPU时间都花在数据转换上了。
我总结了几条优化经验:
- 减少内存拷贝:能直接写缓冲区就别用临时变量。我见过有人序列化一个包,先拼到一个临时结构体,再memcpy到发送缓冲区,多了一次拷贝。
- 批量处理:如果一帧内要发多个小包,可以合并成一个大数据包,减少系统调用次数。
- 预分配缓冲区:不要在序列化过程中动态分配内存。提前算好最大包长,分配好缓冲区。
- 使用零拷贝技术:如果操作系统支持,可以用sendfile或直接I/O,避免数据在内核态和用户态之间拷贝。
我的经验:有一次做一款MMO手游,服务器每帧要处理上千个玩家移动包。一开始用TLV模式,每个字段都单独序列化,结果CPU占用率飙到80%。后来改成固定长度包,把移动数据打包成一个紧凑的结构,CPU占用率降到了30%。所以,性能不够时,牺牲一点灵活性是值得的。
18.5 本章核心知识体系
下面这张图,概括了游戏开发中序列化的核心逻辑。从数据源到字节流,再到存储或网络,每一步都有坑,每一步都有技巧。
你看,从数据源开始,经过序列化,再到存储或网络,每一步都有对应的技术点。字节对齐、字节序、校验和、TLV模式、性能优化……这些不是孤立的知识,而是环环相扣的。你掌握了这些,不管是做单机存档还是网络游戏,都能游刃有余。
嗯,这一章就到这里。序列化这东西,说难不难,说简单也不简单。关键是要理解背后的原理,而不是死记硬背代码。下次你写游戏存档时,记得加上魔数和校验和,别再让玩家的存档坏掉了。
公众号:蓝海资料掘金营,微信deep3321