Phase 2 学习笔记:协议层设计
这一阶段做了什么
建立了独立的 internal/protocol 包,定义了游戏所有消息的结构和编解码工具。
与 Phase 1 的网络层分工:
internal/network/ — 管"字节怎么传"(帧的边界、连接的生命周期)
internal/protocol/ — 管"传的是什么"(消息的语义、结构、可见性)核心概念 1:为什么协议层要和网络层分开?
如果把消息定义全塞进 network 包,会产生一个问题:
业务逻辑(游戏引擎)想用消息结构体时,必须 import 网络层,
而网络层本来应该对业务完全透明。
分层的好处:
game包(Phase 4-5)importprotocol,不需要知道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 泛型实现类型安全的解码
传统做法(没有泛型):
var req LoginReq
json.Unmarshal(data, &req) // 每次都要声明变量,容易忘记检查错误使用泛型的 Decode[T]:
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(指针类型):
// 点数可见:
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 要实现:
玩家管理器(PlayerID 和 Session 的绑定)
匹配队列(两个玩家配对,创建房间)
房间生命周期(创建→游戏中→结束→销毁)
断线重连 token(玩家掉线后凭 token 恢复到同一局游戏)
这一阶段会引入"有状态服务"最核心的挑战:并发安全的共享状态。