Skip to content

Phase 2 学习笔记:协议层设计

这一阶段做了什么

建立了独立的 internal/protocol 包,定义了游戏所有消息的结构和编解码工具。

与 Phase 1 的网络层分工:


internal/network/   — 管"字节怎么传"(帧的边界、连接的生命周期)

internal/protocol/  — 管"传的是什么"(消息的语义、结构、可见性)

核心概念 1:为什么协议层要和网络层分开?

如果把消息定义全塞进 network 包,会产生一个问题:

业务逻辑(游戏引擎)想用消息结构体时,必须 import 网络层

而网络层本来应该对业务完全透明。

分层的好处:

  • game 包(Phase 4-5)import protocol,不需要知道 network 的存在

  • 将来换掉序列化方案(JSON→Protobuf),只改 protocol

  • 写单元测试时可以单独测试消息的编解码,不需要真实网络连接


核心概念 2:消息 ID 分段规划

消息 ID 是客户端和服务端的"契约",一旦发布就很难改。

好的规划从一开始就要做:


1   - 99   : 系统(心跳)

1000-1099  : 认证

2000-2099  : 匹配

3000-3099  : 游戏状态同步(S→C)

4000-4099  : 玩家操作(C→S)

5000-5099  : 游戏事件推送(S→C)

为什么要分段?

  • 方便按功能分组查阅,不用记具体数字

  • 日志里看到 4xxx 就知道是玩家操作,5xxx 就知道是服务端事件

  • 预留扩展空间(比如 6000 以后可以留给 PvE/剧情模式)

命名规范:

  • MsgXxxReq = 客户端发给服务端的请求

  • MsgXxxResp = 服务端对该请求的直接响应

  • MsgXxxEv = 服务端主动推送的事件(不对应任何 Req)


核心概念 3:Go 泛型实现类型安全的解码

传统做法(没有泛型):

go

var req LoginReq

json.Unmarshal(data, &req)  // 每次都要声明变量,容易忘记检查错误

使用泛型的 Decode[T]

go

req, err := protocol.Decode[LoginReq](data)

// req 的类型直接是 *LoginReq,不需要类型断言

// 错误处理被强制要求(返回值有 err)

这是 Go 1.18+ 的特性。泛型让工具函数更通用,同时保持类型安全。

面试会问:Go 泛型的限制是什么?(答:目前不支持泛型方法,只支持泛型函数和类型)


核心概念 4:信息遮蔽(Information Hiding)

这是卡牌游戏服务端与普通业务系统最大的区别之一。

问题

服务端有完整的游戏状态:


玩家A的手牌:[攻击牌3点, 技能牌2点, 能耗牌4点, ...]

玩家B的手牌:[攻击牌5点, 能耗牌1点, ...]

如果把完整状态直接发给两个玩家,任何一方都能看到对方手牌,游戏就废了。

解决方案:视图层(View Layer)


完整 GameSnapshot(服务端私有)

          ↓ BuildView(snap, seat=0)         ↓ BuildView(snap, seat=1)

   玩家0 的 GameStateView             玩家1 的 GameStateView

   - 自己手牌:完整内容               - 自己手牌:完整内容

   - 对手手牌:只有数量 HandCount=3   - 对手手牌:只有数量 HandCount=4

   - 对手合成区:只有数量             - 对手合成区:只有数量

   - 对手角色:"???"(未公开)        - 对手角色:已公开则显示名称

规则:服务端永远不直接发 GameSnapshot,只发 GameStateView。

*int 而不是 -1 表示隐藏

CardView.Points*int(指针类型):

go

// 点数可见:

card := CardView{Points: protocol.IntPtr(3)} // Points = 指向 3 的指针

  

// 点数隐藏(虚幻之境·虚效果下):

card := CardView{Points: nil}                // Points = nil

为什么不用 -1 表示隐藏?

  • -1 时,调用方拿到值必须先判断 if points == -1,很容易忘

  • nil 时,Go 类型系统强制调用方在使用前检查 if card.Points != nil

(否则对 nil 指针解引用会直接 panic)

  • nil 在语义上更清晰:不是"点数是负一",而是"点数不存在/不可见"

核心概念 5:增量更新 vs 全量同步

游戏里有两种状态同步策略:

| 策略 | 触发时机 | 数据量 | 使用场景 |

|------|----------|--------|---------|

| MsgGameStateEv 全量快照 | 阶段开始时 | 大 | 确保双方状态一致 |

| MsgPlayerStatusEv 增量更新 | HP/能量变化时 | 小 | 频繁的小变化 |

为什么需要两种?

  • 全量同步可以修正因网络丢包导致的状态偏差(每个阶段开始重对一次)

  • 增量推送让客户端能实时展示动画(噬渊者受伤时能量增加,实时显示)

  • 如果每次 HP 变化都发全量快照,数据量是增量的 10-20 倍


当前数据流:登录流程


Client                          Server

  |                               |

  |-- LoginReq (msgID=1001) ----> |

  |   {"player_name": "Alice"}    |

  |                               | [生成 PlayerID]

  |                               | [绑定到 Session]

  |<-- LoginResp (msgID=1002) --- |

  |   {"success":true,            |

  |    "player_id":"Alice#..."}   |

  |                               |

面试会问什么

Q:游戏里怎么保证客户端看不到对手的手牌?

A:服务端维护完整的 GameSnapshot,永远不直接序列化发给客户端。

发送前通过 BuildView 函数生成视图:为玩家 A 生成的视图中,对手手牌只有 HandCount(数量),没有 Hand(内容)。

这是"服务端权威"(Server Authority)原则的体现:客户端只能看到服务端决定让它看到的信息。

Q:用 JSON 还是 Protobuf?

A:JSON 开发阶段更方便(可读、易调试)。生产环境建议 Protobuf:

  • 体积约为 JSON 的 1/3 到 1/5(移动端省流量)

  • 序列化/反序列化速度快 5-10 倍

  • 有 .proto 文件作为强类型契约,客户端和服务端不容易产生字段不一致的问题

迁移成本:替换 Encode/Decode 实现,调用方代码不变(这就是为什么要有协议层)。

Q:消息 ID 用 uint16,最多 65535 个,够用吗?

A:对于单个游戏项目绰绰有余。如果是超大型平台(多游戏共用一套连接层),

可以在帧头加一个 serviceID 字段区分业务,每个业务内部再有自己的 msgID 命名空间。


下一步:Phase 3 匹配与房间系统

有了协议定义,Phase 3 要实现:

  1. 玩家管理器(PlayerID 和 Session 的绑定)

  2. 匹配队列(两个玩家配对,创建房间)

  3. 房间生命周期(创建→游戏中→结束→销毁)

  4. 断线重连 token(玩家掉线后凭 token 恢复到同一局游戏)

这一阶段会引入"有状态服务"最核心的挑战:并发安全的共享状态

Contributors

The avatar of contributor named as root root

Changelog

撰写