Packets · 文档

数据包与事件参考

消息长什么样、有哪些类别、玩法扩展包怎么接进来、版本怎么协商。

约 6 分钟 · 最后更新 2026-10-25

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新增一种消息的完整步骤

  1. 定义类型名与负载字段,并确认旧版本收到它会安全忽略。
  2. 在上行派发里加分支;没需要的方向也要显式忽略,别掉进兜底日志里刷屏。
  3. 在下行派发里加分支。
  4. 确认兜底出口仍然存在,并且新类型不会被它截走。
  5. 如果是玩法消息,在玩法包里注册处理函数,并确认注册早于绑定。
  6. 如果是阶段相关的状态,把它加进阶段清零清单。
  7. 同步更新审计门禁脚本的覆盖范围,并用一份故意改坏的副本确认门禁会失败。
第 7 步经常被跳过

门禁脚本覆盖不到的新消息,等于在监管之外。等它出问题的时候,门禁会告诉你「一切正常」。

11排查:消息丢了怎么查

按这个顺序走,每一步都能砍掉一半可能性:

  1. 确认发送端真的发了在发送点加一行日志。这一步经常直接命中 —— 有时候是条件分支根本没进。
  2. 确认本地桥收到了在桥的收包点加日志。桥通、消息没到,说明上半段有问题。
  3. 确认派发器分到了正确的分支打印收到的 type。如果打出来是「未知类型」,说明是类型名对不上。
  4. 确认兜底出口没有把消息吃掉临时在兜底分支里把消息也打出来。
  5. 确认处理函数真的被注册了打印注册表。空的,就是注册时机太晚。
  6. 确认结果被写进了状态打印处理后的状态。处理跑了但状态没变,说明落点字段写错了。
别猜,加日志

「我觉得应该是 XX 的问题」是这类 bug 的唯一错误解法。每一步都要有一个可观测的证据,否则你只是在原地打转。