摘要
一把枪看起来只是"按左键开火、按 R 换弹",实际要处理的情况远比这多:换弹时能不能开火?换弹换到一半切枪,子弹算不算装进去了?冲刺时能不能开镜?60 帧和 144 帧下射速一样吗?客户端预测开了一枪,服务器拒绝了怎么办?
用一堆bool isReloading、bool isSwitching拼出来的武器逻辑,在这些问题面前很快会失控。状态机的作用是把武器在任意时刻"能做什么、不能做什么"写清楚,让每一个转换都有明确的条件和后果。
本文依次讨论:状态划分、如何避免状态爆炸、开火与射速、换弹与打断、切枪、逻辑与动画的关系,以及武器状态机在网络预测和回滚下需要满足的要求。
1. 为什么需要状态机
1.1 用布尔变量拼的武器
// ✗ 常见的初版写法boolisReloading;boolisSwitching;boolisAiming;boolisSprinting;floatreloadTimer;voidUpdate(floatdt){if(input.fire&&!isReloading&&!isSwitching&&!isSprinting&&ammo>0)Fire();if(input.reload&&!isSwitching&&ammo<magSize){isReloading=true;reloadTimer=reloadTime;}if(isReloading){reloadTimer-=dt;if(reloadTimer<=0){ammo=magSize;isReloading=false;}}...}每加一个功能,每一处if都要多判断一个变量。很快就会出现:
| 现象 | 原因 |
|---|---|
| 换弹时切枪,切回来子弹满了 | 切枪没有清除isReloading,计时器在后台继续走 |
| 切枪途中能开一枪 | 某个if漏判了isSwitching |
| 同时处于"换弹"和"切枪" | 两个布尔值都为真,没有任何代码阻止 |
| 改一个条件,另一个功能坏了 | 条件分散在各处,没人知道完整规则 |
问题的根源:布尔变量的组合允许出现不合法的状态。4 个布尔值有 16 种组合,其中很多根本不应该存在。
1.2 状态机的约束
状态机规定:武器在任意时刻只处于一个主状态,只能通过定义好的转换进入另一个状态。
布尔拼凑: isReloading=true, isSwitching=true ← 允许,但无意义 状态机: state = Reloading 或 Unequipping ← 只能是其中一个2. 主状态划分
2.1 核心状态
| 状态 | 含义 | 能开火 | 典型时长 |
|---|---|---|---|
| Holstered收起 | 武器不在手上 | 否 | — |
| Equipping拔出 | 正在掏枪 | 否 | 0.3 ~ 1.0 秒 |
| Ready就绪 | 可以开火(含开火冷却) | 是 | — |
| Reloading换弹 | 正在换弹 | 视武器而定 | 1.5 ~ 4 秒 |
| Unequipping收枪 | 正在把枪收起来 | 否 | 0.2 ~ 0.5 秒 |
2.2 状态图
切到这把枪 ┌──────────┐ ─────────→ ┌───────────┐ │Holstered │ │ Equipping │ │ 收起 │ │ 拔出 │ └──────────┘ └─────┬─────┘ ↑ │ 时间到 │ 时间到 ▼ ┌─────┴──────┐ 切枪 ┌───────────┐ │Unequipping │ ←─────── │ Ready │ ←─┐ │ 收枪 │ │ 就绪 │ │ 开火(冷却在内部处理) └────────────┘ └─┬───────┬─┘ ──┘ ↑ │ ↑ │ 切枪(打断换弹) │ 换弹 │ 时间到 / 被打断 │ ▼ │ │ ┌─────────┴─┐ └──────────────── │ Reloading │ │ 换弹 │ └───────────┘2.3 为什么"开火"不单独做成状态
很多教程会画一个Firing状态。但开火之后的冷却期间,玩家仍然可以按换弹、切枪、开镜,规则和Ready几乎完全相同。如果单独建状态,每条从Ready出发的转换都要在Firing再写一遍。
更简单的做法:开火是Ready状态内部的行为,冷却用"下次可开火时刻"这一个数据表示(见第 4 节)。
Ready 状态内部: 现在 >= 下次可开火时刻 且 按着开火 → 开一枪,推后"下次可开火时刻" 否则 → 什么都不做只有当开火后有一段真正禁止其他操作的动作时(例如栓动狙击枪的拉栓),才需要独立状态,见 4.4 节。
3. 避免状态爆炸:正交维度分开处理
3.1 爆炸是怎么发生的
武器行为还和"是否开镜""是否冲刺"有关。如果把它们都塞进主状态:
Ready × {腰射, 开镜} × {走, 冲刺} = 4 个状态 Reloading × {腰射, 开镜} × {走, 冲刺} = 4 个状态 Equipping × ... = 4 个状态 ... 再加上"蹲下""趴下""滑铲""攀爬"…… → 几十上百个状态3.2 分层处理
这些维度彼此基本独立,应该分开管理:
┌──────────────────────────────────────────────┐ │ 主状态机: Holstered / Equipping / Ready / │ 一次只能一个 │ Reloading / Unequipping │ ├──────────────────────────────────────────────┤ │ 瞄准层: 腰射 ⇄ 开镜(含过渡进度 0~1) │ 独立推进 ├──────────────────────────────────────────────┤ │ 移动姿态: 由角色移动系统提供,只读 │ 作为条件使用 │ (冲刺、空中、滑铲……) │ └──────────────────────────────────────────────┘层与层之间只通过条件交互,不互相嵌套:
| 规则 | 写法 |
|---|---|
| 冲刺时不能开火 | Ready 内部的开火条件加!sprinting |
| 冲刺时不能开镜 | 瞄准层的进入条件加!sprinting |
| 换弹时不能开镜 | 瞄准层的进入条件加state != Reloading |
| 开镜后散布更小 | 散布计算读取瞄准层的过渡进度 |
3.3 开镜为什么用"进度"而不是开关
开镜有过渡时间。如果只有开/关两个值,开镜动画播到一半时,散布和视场角该用哪个?用进度 0~1 表示,散布、移动速度、视场角都可以按进度插值:
进度: 0.0 ──────────── 0.5 ──────────── 1.0 散布: 腰射值 两者之间 开镜值 视场角: 90° 75° 60°很多游戏还会规定"进度达到某个值之前开火按腰射算",这也只需要读进度,不需要新状态。
4. 开火与射速
4.1 射速换算
射速通常用"每分钟发数"(RPM)描述:
射速 600 RPM → 每秒 10 发 → 间隔 100 毫秒 射速 900 RPM → 每秒 15 发 → 间隔 66.7 毫秒4.2 常见错误:用"现在 + 间隔"计算下一发
// ✗ 每次开火后:下次可开火时刻 = 现在 + 间隔if(now>=nextFireTime&&wantFire){FireOneShot();nextFireTime=now+interval;}问题在于now只能是"某一帧的时刻"。以 60 帧(每帧 16.7 毫秒)、射速 900 RPM(间隔 66.7 毫秒)为例:
理想开火时刻: 0 66.7 133.3 200.0 ... 帧时刻: 0 16.7 33.3 50 66.7 83.3 100 116.7 133.3 ... 第 1 发: 0ms 开火,下次 = 66.7 第 2 发: 66.7ms 那一帧刚好赶上 ✓ 但只要有一点误差落到 83.3ms 那一帧,下次 = 83.3+66.7 = 150 第 3 发: 150ms → 实际在 150 那一帧开火 …… 每一发都可能晚一帧,误差一直累积 → 实际射速低于设定值 而且帧率不同,实际射速也不同:144 帧的玩家射速比 60 帧的玩家高4.3 正确做法:时间累加器
// ✓ 下次可开火时刻按"理想时刻"累加,不依赖帧时刻voidTickFire(WeaponState&s,constInputCmd&in,Time now){if(!in.fireHeld||!CanFire(s,in)){// 没在开火时,不允许"攒"射击机会,// 否则停火一秒后再按,会瞬间打出好几发if(s.nextFireTime<now)s.nextFireTime=now;return;}// 一帧内可能需要打出多发(高射速 + 低帧率/低 tick)while(s.nextFireTime<=now&&s.ammo>0){FireOneShot(s,/*shotTime=*/s.nextFireTime);s.ammo-=1;s.shotIndex+=1;// 散布种子使用的计数(见散布同步一文)s.nextFireTime+=s.def->fireInterval;}}理想开火时刻: 0 66.7 133.3 200.0 累加器结果: 0 66.7 133.3 200.0 ← 不会漂移 实际执行帧: 0 66.7 133.3 200.0 (或紧随其后的那一帧)两个要点:
- 按理想时刻累加,射速与帧率无关。
- 停火时把累加器拉回当前时刻,防止积攒射击机会。
- 每发子弹带上自己的
shotTime,而不是统一用帧时刻。一帧内打出两发时,两发的时刻和弹道起点都不同,这也是 sub-tick 开火能做得更精确的基础。
时间建议用整数(微秒或 tick)表示,避免浮点累加误差,也便于客户端和服务器得到完全相同的结果。
4.4 开火模式
开火模式不应该做成新的主状态,而是Ready状态内部的不同策略:
| 模式 | 行为 | 需要的额外数据 |
|---|---|---|
| 全自动 | 按住就按射速连续开火 | 无 |
| 半自动 | 每次按下开一发,按住不连发 | 上一帧是否按着(检测按下边沿) |
| 点射 | 按一下连发 N 发,打完才能再触发 | 剩余点射发数 |
| 栓动 | 开一发后必须拉栓 | 需要一个"拉栓"子状态 |
| 蓄力 | 按住蓄力,松开发射 | 蓄力开始时刻 |
boolWantsShot(constWeaponState&s,constInputCmd&in){switch(s.def->fireMode){caseFireMode::Auto:returnin.fireHeld;caseFireMode::Semi:// 只在"这一帧按着、上一帧没按"时触发returnin.fireHeld&&!s.fireHeldLastTick;caseFireMode::Burst:// 点射进行中自动继续;否则需要一次新的按下returns.burstRemaining>0||(in.fireHeld&&!s.fireHeldLastTick);}}栓动武器的"拉栓"期间不能开火,但通常可以开镜、可以被切枪打断,这时可以给Ready加一个子状态,或者单独加一个Cycling主状态,取决于它和其他操作的打断关系有多特殊。
4.5 输入缓冲
半自动武器有一个手感问题:玩家在冷却结束前几十毫秒按下开火,按照严格规则,这次按下会被丢弃,玩家感觉"按了没反应"。
冷却中 ████████████████░░░░ 冷却结束 ↑ 玩家这时按下 → 被丢弃 → "吞枪"常见做法是缓冲:冷却结束前一小段时间内的按下被记住,冷却一结束就开火。
if(in.firePressedThisTick)s.bufferedFireUntil=now+kFireBufferWindow;// 例如 100msbooltriggered=(now<=s.bufferedFireUntil);if(triggered&&now>=s.nextFireTime){FireOneShot(...);s.bufferedFireUntil=0;}缓冲窗口不能太长,否则玩家会感觉"我没想开枪,它自己开了"。服务器同样按这套规则执行,所以缓冲不会让半自动武器超过设定射速,宏连点也不会。
5. 换弹
5.1 弹药模型
弹匣内: ammo 当前能打的子弹 备弹: reserve 背包里剩的子弹 弹匣容量: magSize 枪膛: 有些游戏区分"枪膛里的一发"(战术换弹后显示 30+1)两种换弹:
| 战术换弹 | 空仓换弹 | |
|---|---|---|
| 条件 | 弹匣里还有子弹 | 弹匣打空 |
| 动作 | 换弹匣 | 换弹匣 + 拉枪机 |
| 时长 | 较短(例如 2.2 秒) | 较长(例如 2.8 秒) |
| 有枪膛设计时 | 换完 = 容量 + 1 | 换完 = 容量 |
进入换弹状态时就决定是哪一种,并记录下来,不要在换弹过程中根据弹药数重新判断。
5.2 关键设计:提交点
换弹最常见的 bug 是"换弹被打断,子弹却已经满了"或者"动画都快播完了被打断,子弹一发没进"。解决方法是为每种换弹定义一个提交点:
战术换弹 (2.2 秒): 0.0s 0.6s 1.5s 2.2s │─ 拔出旧弹匣 ─│─── 插入新弹匣 ───│●── 收尾动作 ──│ ↑ 提交点:此刻子弹进入弹匣 在提交点之前被打断: 不加子弹,下次需要重新完整换弹 在提交点之后被打断: 子弹已经加好,只是收尾动作没做完voidTickReload(WeaponState&s,Time now){Time elapsed=now-s.stateStartTime;if(!s.reloadCommitted&&elapsed>=s.reloadDef->commitTime){intneed=s.reloadDef->targetAmmo-s.ammo;inttaken=std::min(need,s.reserve);s.ammo+=taken;s.reserve-=taken;s.reloadCommitted=true;}if(elapsed>=s.reloadDef->totalTime)EnterState(s,WeaponPhase::Ready,now);}提交点之后,很多游戏允许提前取消收尾(比如直接开火或冲刺),这能让熟练玩家通过"换弹取消"节省时间。这是设计选择,但它必须由状态机明确允许,而不是靠 bug 实现。
5.3 逐发装填
霰弹枪、部分狙击枪是一发一发装的。它是一个循环:
┌───────────┐ ┌──────────────────┐ ┌───────────┐ │ 开始装填 │ ──→ │ 装一发(循环) │ ──→ │ 结束装填 │ ──→ Ready │ 0.4s │ │ 每发 0.5s │ │ 0.3s │ └───────────┘ │ 每发末尾提交 +1 │ └───────────┘ └──────────────────┘ ↑ │ └─────────┘ 还没满、还有备弹 → 继续 按开火: 当前这发装完(或立刻)停止 → 跳到结束装填 → 开火每一发都有自己的提交点,打断只会损失"正在装的那一发"。
5.4 打断规则
把"谁能打断谁"写成一张表,比散落在代码里的条件清楚得多:
| 当前状态 \ 请求 | 开火 | 换弹 | 切枪 | 冲刺 | 开镜 |
|---|---|---|---|---|---|
| Equipping | ✗ | 排队 | 立即(反向收枪) | 允许,不打断 | ✗ |
| Ready | ✓ | ✓ | ✓ | ✓(禁止开火) | ✓ |
| Reloading(弹匣式) | 提交点后可取消收尾 | ✗ | ✓ 打断 | 设计选择 | ✗ |
| Reloading(逐发) | ✓ 打断 | ✗ | ✓ 打断 | 设计选择 | ✗ |
| Unequipping | ✗ | ✗ | 改目标武器 | 允许 | ✗ |
"冲刺是否打断换弹"不同游戏差别很大:有的冲刺会中断换弹,有的允许边跑边换。这类规则放进表里,策划可以直接看、直接改。
6. 切枪
6.1 切枪是两段
当前武器 目标武器 Ready ──→ Unequipping(0.3s) ──→ Holstered Holstered ──→ Equipping(0.6s) ──→ Ready切枪不是一个状态,而是两把武器各自的状态转换,由上层的武器管理器协调:当前武器收枪完成后,通知目标武器开始拔出。
6.2 需要处理的情况
| 情况 | 处理 |
|---|---|
| 切枪时正在换弹 | 换弹被打断,按提交点规则决定子弹是否已加 |
| 收枪途中又按了另一把枪 | 只改目标武器,不重新开始收枪 |
| 拔出途中又切回原来的枪 | 当前拔出的武器开始收枪,或者直接反向播放(设计选择) |
| 连续按了三次切枪键 | 只保留最后一次请求 |
| 快速切换 | 有些游戏切到副武器更快,用单独的时长配置 |
6.3 切回来之后
武器收起后,它的状态应该被完整地冻结或重置,不能有计时器在后台继续走:
✗ 换弹到一半切走 → 后台计时器走完 → 切回来子弹满了 ✓ 换弹到一半切走 → 换弹状态被打断并清除 → 切回来需要重新换状态机天然避免了这个问题:武器处于Holstered时,TickReload根本不会被调用。
7. 逻辑与动画的关系
7.1 谁驱动谁
一种常见做法是让动画驱动逻辑:动画播到某一帧时发出事件"弹匣插入了",逻辑收到后才加子弹。
这在单机游戏里可以用,但在联网 FPS 里有几个问题:
| 问题 | 说明 |
|---|---|
| 服务器不播放动画 | 专用服务器通常不跑完整的动画系统,收不到动画事件 |
| 动画速度不稳定 | 动画混合、帧率、LOD 降级都可能让事件提前或推后 |
| 美术修改影响平衡 | 美术把换弹动画改长了 0.2 秒,武器强度也跟着变了 |
| 第一人称与第三人称不同 | 两套动画时长不一样,该以哪个为准? |
所以联网 FPS 一般是逻辑驱动动画:
┌──────────────────────┐ ┌──────────────────────┐ │ 武器状态机(逻辑) │ 通知 │ 动画系统(表现) │ │ · 时长来自数据表 │ ───────→ │ · 播放对应动画 │ │ · 提交点来自数据表 │ │ · 按逻辑时长缩放速度 │ │ · 客户端服务器一致 │ │ · 只在客户端 │ └──────────────────────┘ └──────────────────────┘ 决定"发生了什么" 决定"看起来怎么样"7.2 让动画去对齐逻辑时长
逻辑时长来自数据表,动画按这个时长缩放播放速度:
voidOnEnterReload(AnimController&anim,constReloadDef&def){floatclipLength=anim.GetClipLength(def.animClip);// 美术做的动画长度,例如 2.4 秒floatplayRate=clipLength/def.totalTime;// 逻辑规定 2.2 秒 → 播放速度 1.09anim.Play(def.animClip,playRate);}逻辑时长 2.2s: │─────────────────────●──────────│ 提交点 1.5s 动画原长 2.4s: │──────────────────────●───────────│ 插入弹匣 1.64s 缩放后: │─────────────────────●──────────│ 两者对齐缩放只能解决总时长。动画里"弹匣插进去"的那一帧,也要和逻辑提交点大致吻合,否则玩家会看到弹匣还没插进去,弹药数就已经变了。常见做法:
- 美术在动画里标记关键帧(“弹匣插入”),工具在导入时检查它与数据表里的提交点比例是否一致,偏差过大就报错
- 或者反过来,由工具从动画标记中读出提交点比例写进数据表,但运行时仍然只读数据表
两种方式的共同点是:运行时逻辑只认数据表,动画的问题在制作阶段发现,而不是在对局中影响平衡。
7.3 第一人称与第三人称
| 第一人称 | 第三人称 | |
|---|---|---|
| 谁看到 | 只有自己 | 其他玩家 |
| 动画 | 精细,手和枪的特写 | 全身,较粗糙 |
| 时长 | 按逻辑时长缩放 | 按逻辑时长缩放 |
| 是否预测 | 是,本地立即播放 | 否,根据同步来的状态播放 |
两套动画原始时长可以不同,但都缩放到同一个逻辑时长。这样敌人看到你换弹的节奏和你真实换弹的节奏一致,"看到对面在换弹就冲"这种判断才是可靠的。
7.4 表现事件
声音、枪口火焰、弹壳、屏幕震动由状态转换触发,而不是由动画触发:
状态机事件 表现 ────────── ──── OnShotFired(shotIndex) → 枪声、枪口火焰、弹壳、后坐动画 OnReloadStart(kind) → 换弹动画、换弹音效 OnReloadCommitted → HUD 弹药数刷新 OnEquipStart → 拔枪动画、拔枪音效 OnDryFire → 空仓"咔哒"声这里有一个联网特有的陷阱:客户端在回滚重模拟时会再次经过同样的状态转换,如果每次都触发表现,同一发子弹的枪声会响好几次。处理方式见 8.4 节。
8. 网络预测与回滚
8.1 预测流程回顾
武器的预测和角色移动的预测是同一套机制:
客户端 服务器 ────── ────── 第 100 帧: 输入=开火 → 本地执行 → 弹药 30→29 发送 cmd#100 ─────────────────────→ 第 101 帧: 输入=开火 → 本地执行 → 弹药 29→28 发送 cmd#101 ─────────────────────→ 第 102 帧: 输入=开火 → 本地执行 → 弹药 28→27 执行 cmd#100 → 弹药 29 ←───────────────────────── 回传:已处理到 cmd#100, 武器状态 = {…弹药 29…} 收到回传: 1. 把武器状态重置为服务器给的状态(弹药 29) 2. 重新执行 cmd#101、cmd#102 → 弹药 27 3. 与当前预测一致 → 画面不变只要客户端和服务器执行同样的逻辑,重新执行的结果就和预测一致,玩家察觉不到任何纠正。要做到这一点,武器状态机必须满足下面几条要求。
8.2 要求一:状态完全由一份数据描述
回滚需要"把状态重置成某个快照"。如果武器的状态散落在动画系统、协程、定时器、其他组件里,就没法完整地重置。
// ✓ 武器的全部可变状态集中在一个可直接复制的结构里structWeaponState{WeaponPhase phase;// Holstered / Equipping / Ready / Reloading / UnequippingTick stateStartTick;// 进入当前状态的时刻Tick nextFireTick;// 下次可开火时刻(累加器)Tick bufferedFireUntil;uint16_tammo;uint16_treserve;uint32_tshotIndex;// 散布种子计数uint8_tburstRemaining;uint8_treloadKind;// 战术 / 空仓 / 逐发boolreloadCommitted;boolfireHeldLastTick;uint8_taimProgress;// 开镜进度 0~255floatrecoilAccum;// 连射累积(影响散布大小)};检验标准很简单:把这个结构复制一份存起来,以后拷回去,武器的一切行为都和当时一模一样。只要有一样东西做不到(例如某个计时器在WeaponState之外),回滚就会出 bug。
8.3 要求二:Tick 是纯函数
新状态 = Tick(旧状态, 这一帧的输入, 这一帧的时刻, 只读的外部条件)| 禁止 | 原因 | 替代 |
|---|---|---|
| 读取系统时钟 | 重模拟时时钟已经变了 | 使用传入的 tick |
| 使用渲染帧 dt | 客户端和服务器帧率不同 | 固定 tick 步长 |
调用rand() | 重模拟时结果不同 | 用shotIndex生成种子 |
| 读取动画进度 | 服务器没有动画,客户端回滚时动画不会回退 | 用stateStartTick计算进度 |
| 在 Tick 里直接播放声音、生成特效 | 重模拟时重复触发 | 输出事件,见 8.4 |
“只读的外部条件"指冲刺、空中、存活等由其他系统决定的信息。它们也必须来自同一帧的预测状态,而不是来自"当前画面”,否则重模拟第 101 帧时读到的却是第 103 帧的冲刺状态。
WeaponStateTick(constWeaponState&prev,constInputCmd&in,Tick now,constMoveFlags&move,EventSink&events);8.4 要求三:副作用与状态分离
Tick 不直接产生表现,而是输出带唯一标识的事件。表现层负责去重:
structWeaponEvent{EventType type;// ShotFired / ReloadStart / ...uint32_tkey;// 唯一标识:开火用 shotIndex,换弹用 stateStartTickTick tick;};voidPresentationLayer::Consume(constWeaponEvent&e){// 同一个事件已经表现过,说明这是回滚重模拟产生的重复事件if(alreadyPlayed.contains({e.type,e.key}))return;alreadyPlayed.insert({e.type,e.key});Play(e);}首次预测 cmd#101: ShotFired(key=58) → 播放枪声 ✓ 回滚重模拟 cmd#101: ShotFired(key=58) → 已播放过,跳过 回滚后结果变了: ShotFired 没产生 → 枪声已经响了,无法撤回(可接受)已表现记录只需要保留最近一两秒,超过回滚窗口的就可以清除。
8.5 要求四:时间用整数 tick
开火累加器、换弹提交点、拔枪时长,全部用整数 tick(或整数微秒)表示:
浮点: nextFire = 0.1 + 0.1 + 0.1 … 客户端和服务器可能在第 N 发出现不同舍入 整数: nextFire = 6400 + 6400 + … 任意平台结果相同数据表里配置的秒数,在加载时统一转换成 tick,运行时不再接触浮点时间。
8.6 预测失败的几种情况
| 情况 | 原因 | 纠正后的表现 |
|---|---|---|
| 服务器拒绝了一次开火 | 服务器上此时已经死亡、被眩晕、弹药不同 | 弹药数回跳一发;shotIndex以服务器为准 |
| 换弹没有被服务器接受 | 服务器上备弹为 0(被其他事件消耗) | 换弹动画中止,回到 Ready |
| 切枪时机不同 | 服务器上切枪请求晚到一帧 | 拔出动画进度微调 |
| 外部条件不同 | 服务器判定你在空中 / 被击退 | 散布、能否开火随之改变 |
纠正时,表现层应该尽量平滑:
voidOnWeaponStateCorrected(constWeaponState&predicted,constWeaponState&corrected){if(predicted.phase==corrected.phase&&predicted.stateStartTick==corrected.stateStartTick){return;// 同一状态、同一开始时刻:只是数值差异,动画不用动}if(predicted.phase==corrected.phase){// 同一状态但开始时刻不同:调整动画播放位置,不重新播放anim.SeekTo(ProgressOf(corrected));return;}// 状态都不一样:切换动画,短混合过渡anim.CrossFadeTo(AnimFor(corrected),kCorrectionBlend);}弹药 HUD 一般直接显示纠正后的值。弹药数跳动一发,玩家几乎不会注意到;但如果换弹动画在中途突然重播,玩家一定会注意到。
8.7 完整的回滚循环
voidClientOnServerState(constServerWeaponSnapshot&snap){// 1. 丢弃已被服务器处理过的输入pendingCmds.DropUpTo(snap.lastProcessedCmd);// 2. 从服务器状态出发,重放尚未确认的输入WeaponState s=snap.state;for(constInputCmd&cmd:pendingCmds)s=Tick(s,cmd,cmd.tick,moveHistory.At(cmd.tick),eventSink);// 3. 替换当前预测状态,交给表现层平滑处理OnWeaponStateCorrected(currentPredicted,s);currentPredicted=s;}9. 其他玩家看到的武器
其他玩家的武器不需要预测,只需要显示。下发的数据可以比自己的少得多:
| 字段 | 自己的武器 | 他人的武器 |
|---|---|---|
| phase、stateStartTick | ✓ | ✓(决定第三人称动画) |
| ammo、reserve | ✓ | ✗(防止透露信息;部分游戏观战时才下发) |
| nextFireTick、缓冲 | ✓ | ✗ |
| shotIndex | ✓ | ✓(用于复现散布、弹道曳光) |
| aimProgress | ✓ | ✓(低精度,决定举枪姿势) |
| recoilAccum | ✓ | ✗ |
开火不靠状态同步,而是靠开火事件:
{ 玩家 ID, shotIndex, 开火 tick, 枪口位置, 方向 } → 播放枪声、枪口火焰、曳光弹第三人称动画进度由now - stateStartTick算出,所以即使中间丢了几个包,收到下一份状态时动画也能直接定位到正确进度,不会从头播放。
不给他人下发弹药数是一个容易忽视的细节:如果对方客户端知道你弹匣里只剩 2 发,外挂就能在你换弹前精准冲脸。
10. 数据驱动
武器之间的差异应该全部是数据,而不是代码分支:
{"id":"rifle_ak","fireMode":"auto","rpm":600,"magSize":30,"chamber":true,"equipTime":0.65,"unequipTime":0.35,"reload":{"tactical":{"total":2.20,"commit":1.50,"cancelAfterCommit":true},"empty":{"total":2.80,"commit":2.05,"cancelAfterCommit":true}},"interrupt":{"sprintCancelsReload":false,"fireCancelsReload":"afterCommit"},"aim":{"time":0.25,"fireAsAdsAbove":0.7},"fireBuffer":0.10}{"id":"shotgun_pump","fireMode":"semi","rpm":70,"magSize":8,"reload":{"type":"perShell","start":0.40,"perShell":0.50,"shellCommit":0.42,"end":0.30},"interrupt":{"fireCancelsReload":"afterCurrentShell"}}加载时做一次校验,把配置错误挡在运行之前:
| 校验 | 例子 |
|---|---|
| 提交点必须在总时长之内 | commit > total报错 |
| 射速间隔不能小于 tick 长度过多 | 2000 RPM 在 64 tick 下每帧要打 0.5 发以上,提示确认 |
| 动画标记与提交点比例吻合 | 偏差超过 10% 报警告 |
| 打断规则完整 | 每种状态 × 每种请求都要有定义 |
数据驱动还有一个好处:策划调平衡不需要改代码、不会引入逻辑 bug,而客户端和服务器只要加载同一份数据,行为就一致。配置文件的版本必须随协议一起校验,客户端和服务器的数据不一致是"莫名其妙预测失败"最常见的原因之一。
11. 常见 bug 速查
| 现象 | 根因 | 修复 |
|---|---|---|
| 切枪回来子弹满了 | 换弹计时器在状态机外继续运行 | 换弹进度只存于WeaponState,离开状态即清除 |
| 换弹快结束被打断,一发没进 | 提交点设在动画最末尾 | 提交点设在"弹匣插入"时刻 |
| 高帧率射速更快 | nextFire = now + interval | 时间累加器 |
| 停火后再开,瞬间打出多发 | 累加器积攒了停火时间 | 停火时把累加器拉回当前时刻 |
| 半自动"吞枪" | 冷却结束前的按下被丢弃 | 输入缓冲 |
| 枪声偶尔响两次 | 回滚重模拟重复触发表现 | 事件带唯一 key,表现层去重 |
| 换弹动画中途重播 | 纠正时无条件重新播放动画 | 比较 phase 与 stateStartTick,只做 Seek |
| 服务器和客户端换弹时长不同 | 逻辑依赖动画事件 | 逻辑只读数据表 |
| 偶发预测失败无法复现 | 浮点时间、读取渲染 dt 或系统时钟 | 整数 tick、纯函数 Tick |
| 敌人知道你弹药快空 | 他人武器同步了弹药数 | 按可见性裁剪同步字段 |
12. 总结
玩家输入 │ ▼ ┌────────────────────────────────────────────────┐ │ 武器状态机(客户端预测 + 服务器权威,同一份代码)│ │ │ │ 主状态: Holstered → Equipping → Ready │ │ ↕ ↕ │ │ Unequipping Reloading │ │ │ │ 正交层: 开镜进度 / 读取移动姿态 │ │ Ready 内: 开火模式策略 + 时间累加器 + 缓冲 │ │ 换弹: 提交点决定弹药何时生效 │ │ │ │ 要求: 状态 = 一份可复制的数据 │ │ Tick = 纯函数,整数时间 │ │ 副作用 = 带 key 的事件 │ └──────────────┬─────────────────────┬───────────┘ │ 状态 │ 事件 ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ 动画:按逻辑时长缩放 │ │ 音效 / 特效:去重播放 │ │ 纠正时 Seek 而非重播 │ │ 开火事件广播给他人 │ └──────────────────────┘ └──────────────────────┘核心结论:
- 用单一主状态代替布尔组合,从结构上排除"同时换弹和切枪"这类非法状态;开镜、移动姿态作为正交层,通过条件交互,避免状态爆炸。
- 开火不是状态,而是 Ready 内部的行为。射速用按理想时刻累加的时间累加器实现,与帧率无关;停火时拉回累加器,防止积攒射击。
- 换弹的关键是提交点。提交点之前打断不加子弹,之后打断子弹已生效,所有打断规则写成一张表并由数据驱动。
- 逻辑驱动动画,而不是动画驱动逻辑。服务器没有动画,动画时长也不应影响平衡;动画按逻辑时长缩放,在制作阶段校验关键帧。
- 联网 FPS 的武器状态机必须可回滚:状态集中在一份可复制的数据里,Tick 是只依赖输入和整数时间的纯函数,表现通过带唯一 key 的事件输出并去重。满足这几条,客户端预测和服务器判定才能在绝大多数情况下完全一致,不一致时也能平滑纠正。