数据包与事件参考
消息长什么样、有哪些类别、玩法扩展包怎么接进来、版本怎么协商。
01消息模型
每条消息是一个 JSON 对象。传输层只负责原样转发,字段的含义由收发两端约定。
有一条规则比字段名更重要,它值得单独占一个提示框:
处理消息的派发函数必须显式处理每种已知类型,并且要有一个兜底分支。缺了兜底分支的派发,会把没识别的消息静默丢弃 —— 不报错、不日志。玩法包的消息就是这样消失的:发送端一切正常,接收端什么都没收到,两端都没有任何提示。
上行(游戏内 → 启动器 / 服务器)和下行(服务器 → 启动器 → 游戏内)是两段独立的派发逻辑。只补了其中一段,另一半照样丢消息 —— 这是实际踩过的坑。
02封套与负载
{
"type": "score", // 类型:派发靠它分流
"from": "player-7f3a", // 谁发的
"room": "4821", // 房间作用域
"ts": 1730000000000, // 发送时刻(毫秒)
"data": { "delta": 1, "reason": "hit" }
}
| 字段 | 必填 | 谁在读 | 注意 |
|---|---|---|---|
type | 是 | 派发器 | 未识别的 type 必须有兜底去处 |
from | 是 | 服务器 / 接收端 | 用它判断要不要回发给自己 |
room | 房间内消息必填 | 服务器 | 服务器按它决定转发范围 |
ts | 建议 | 接收端 | 用于超时丢弃与乱序判断 |
data | 视类型而定 | 处理函数 | 旧版本不认识的字段必须能安全忽略 |
本文档写的是结构和纪律,不是你项目里逐字的字段清单。具体有哪些类型、字段怎么命名,以代码里的派发分支为准 —— 并且新增一种消息,就要在派发里加一个分支。
03消息分类总表
| 类别 | 典型内容 | 方向 | 频率 |
|---|---|---|---|
| 会话 | 加入 / 离开 / 心跳 / 踢人 / 关房 | 客户端 ↔ 服务器 | 低 |
| 同步 | 车辆位姿 / 输入 / 车辆出生消亡 | 客户端 → 服务器 → 同房间 | 高 |
| 聊天 | 房间内文本 | 双向 | 低 |
| 玩法 | 阶段切换 / 回合 / 就位确认 / 计分 / 撞击 | 玩法模块之间 | 中 |
| 系统广播 | 公告、强制提示 | 服务器 → 全部 | 极低 |
同步类消息频率最高,它的每一点开销都会乘以房间人数。新增同步字段前先问:这个值真的需要单独发一条消息吗?能不能搭在已有的位姿消息里?
04会话类消息
| 消息 | 由谁发 | 带到什么 | 接收端要做什么 |
|---|---|---|---|
| 加入房间 | 客户端 | 房间号、自身标识 | 把该会话挂进房间作用域 |
| 离开房间 | 客户端 | 自身标识 | 摘除会话、通知同房间、播放离开提示 |
| 心跳 | 客户端 | 极轻的负载 | 刷新活跃时间(不要夹带业务数据) |
| 被踢 / 关房 | 服务器或管理端 | 目标会话 / 房间号 | 被踢方退回大厅、复位阶段 |
主动离开、心跳超时、被踢、关房 —— 如果各自写一段清理,迟早有一段漏掉某一步。把它们收敛到一个清理函数。
05同步类消息
| 消息 | 负载里有什么 | 同步策略 |
|---|---|---|
| 位姿 | 位置、朝向、速度 | 按频率上行;变化小于阈值可以跳过 |
| 输入 / 状态 | 状态位、操控意图 | 状态发生变化时才发 |
| 车辆出生 | 车辆标识、初始位姿、车型 | 一次性;顺序敏感 |
| 车辆销毁 | 车辆标识 | 一次性;必须能被重复收到而不出错 |
重连、补发、重复包都会让同一条消息到达多次。处理函数必须能承受「同一条消息来三遍」,结果与来一遍相同。
06玩法类消息
玩法消息在玩法模块之间流转,服务器只负责把它们原样转给同房间的人。
| 消息 | 含义 | 接收端要做什么 |
|---|---|---|
| 阶段切换 | 进入新阶段 | 更新阶段状态,并逐项清零上一局的临时状态 |
| 回合开始 / 结束 | 新一局开始、本局结算 | 重置本局计数,刷新 HUD |
| 就位确认 | 某玩家已到位 | 记录;等全员到位或超时再开局 |
| 计分变化 | 某人加分 / 扣分 | 按幂等键判重后再记账 |
| 撞击事件 | 发生了有效撞击 | 更新伤害归属;注意排除已被淘汰的目标 |
「某人已出局」这个状态必须同时影响输入(他不能再操作)和伤害归属(撞到他不再掉血)。只管一头,就会出现「撞残骸的人自己掉血」这种说不通的规则。
07派发与兜底
派发器是整个消息系统里唯一「不认识就别丢掉」的地方。写两段对比就清楚了:
// ✗ 错误:没有兜底出口,未识别的类型静默消失
switch (msg.Type) {
case "pose": HandlePose(msg); break;
case "chat": HandleChat(msg); break;
}
// ✓ 正确:兜底出口把未知类型暴露出来
switch (msg.Type) {
case "pose": HandlePose(msg); break;
case "chat": HandleChat(msg); break;
default:
HandleUnknown(msg); // 至少记一条日志,最好能转给玩法包
break;
}
-- Lua 侧同理
local function dispatch(kind, msg)
if kind == "phase" then return onPhase(msg) end
if kind == "round" then return onRound(msg) end
-- 兜底:转给玩法包,别让消息落地
return onGamePacket(kind, msg)
end
- 兜底分支别写成「什么都不做」—— 那样和不写没区别,还是静默。至少记一条日志。
- 更好的做法是:兜底分支把消息转交给玩法包,让扩展功能有机会消费它。
- 上行和下行两段派发都要有兜底出口。只补一段,另一半照样丢。
玩法包的消息曾经整批消失,两端都没有任何报错 —— 因为派发函数里没有兜底分支,也没人写过日志。修法是在两个派发点各加一个出口,把消息转给玩法包。
08注册与生命周期
玩法包是独立的消息消费者。它通过主模块导出的注册接口把自己的处理函数挂上去,而不是去改主模块的派发逻辑。
| 时机 | 必须完成的事 |
|---|---|
| 加载阶段(绑定之前) | 把处理函数注册到主模块 |
| 绑定那一刻 | 握手、初始状态同步 |
| 帧循环 | 处理在途消息、刷新本地状态 |
| 阶段切换 | 清零本阶段的临时状态 |
| 退出 / 换房 | 注销处理函数,释放缓存 |
绑定完成时你的处理函数还不在表里,那一刻来的消息就永远丢了。所有注册都放在绑定之前,这也是 Lua 开发 · 导出与注册顺序 反复强调同一条纪律的原因。
09阶段状态清零清单
进入新阶段(尤其是换房、重开一局)时,下面这些必须逐项清零。漏一项,它就会跨局存活:
| 要清的 | 不清的后果 |
|---|---|
| 本局分数与计数 | 新一局一开局就带着上一局的分数 |
| 就位确认记录 | 新一局的就位判断直接成立,开局时机错乱 |
| 幂等键缓存 | 同一事件在新一局被误判为「已经处理过」,分数不涨 |
| 缓存的车辆对象引用 | 引用到已销毁的车,回调里报 nil |
| 本局的临时标记(已淘汰、已出局等) | 新一局里有人一开局就是出局状态 |
「跨局静默存活」是最难复现的一类 bug:只在连续玩两局时出现,重启就没了。把清单放在清零函数旁边,以后加字段时照着补。
10新增一种消息的完整步骤
- 定义类型名与负载字段,并确认旧版本收到它会安全忽略。
- 在上行派发里加分支;没需要的方向也要显式忽略,别掉进兜底日志里刷屏。
- 在下行派发里加分支。
- 确认兜底出口仍然存在,并且新类型不会被它截走。
- 如果是玩法消息,在玩法包里注册处理函数,并确认注册早于绑定。
- 如果是阶段相关的状态,把它加进阶段清零清单。
- 同步更新审计门禁脚本的覆盖范围,并用一份故意改坏的副本确认门禁会失败。
门禁脚本覆盖不到的新消息,等于在监管之外。等它出问题的时候,门禁会告诉你「一切正常」。
11排查:消息丢了怎么查
按这个顺序走,每一步都能砍掉一半可能性:
- 确认发送端真的发了在发送点加一行日志。这一步经常直接命中 —— 有时候是条件分支根本没进。
- 确认本地桥收到了在桥的收包点加日志。桥通、消息没到,说明上半段有问题。
- 确认派发器分到了正确的分支打印收到的
type。如果打出来是「未知类型」,说明是类型名对不上。 - 确认兜底出口没有把消息吃掉临时在兜底分支里把消息也打出来。
- 确认处理函数真的被注册了打印注册表。空的,就是注册时机太晚。
- 确认结果被写进了状态打印处理后的状态。处理跑了但状态没变,说明落点字段写错了。
「我觉得应该是 XX 的问题」是这类 bug 的唯一错误解法。每一步都要有一个可观测的证据,否则你只是在原地打转。