ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPS 射击游戏的武器状态机

FPS 射击游戏的武器状态机

摘要

一把枪看起来只是"按左键开火、按 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 (或紧随其后的那一帧)

两个要点:

  1. 按理想时刻累加,射速与帧率无关。
  2. 停火时把累加器拉回当前时刻,防止积攒射击机会。
  3. 每发子弹带上自己的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 而非重播 │ │ 开火事件广播给他人 │ └──────────────────────┘ └──────────────────────┘

核心结论:

  1. 用单一主状态代替布尔组合,从结构上排除"同时换弹和切枪"这类非法状态;开镜、移动姿态作为正交层,通过条件交互,避免状态爆炸。
  2. 开火不是状态,而是 Ready 内部的行为。射速用按理想时刻累加的时间累加器实现,与帧率无关;停火时拉回累加器,防止积攒射击。
  3. 换弹的关键是提交点。提交点之前打断不加子弹,之后打断子弹已生效,所有打断规则写成一张表并由数据驱动。
  4. 逻辑驱动动画,而不是动画驱动逻辑。服务器没有动画,动画时长也不应影响平衡;动画按逻辑时长缩放,在制作阶段校验关键帧。
  5. 联网 FPS 的武器状态机必须可回滚:状态集中在一份可复制的数据里,Tick 是只依赖输入和整数时间的纯函数,表现通过带唯一 key 的事件输出并去重。满足这几条,客户端预测和服务器判定才能在绝大多数情况下完全一致,不一致时也能平滑纠正。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进