摘要一把枪看起来只是按左键开火、按 R 换弹,实际要处理的情况远比这多:换弹时能不能开火?换弹换到一半切枪,子弹算不算装进去了?冲刺时能不能开镜?60 帧和 144 帧下射速一样吗?客户端预测开了一枪,服务器拒绝了怎么办?用一堆bool isReloading、bool isSwitching拼出来的武器逻辑,在这些问题面前很快会失控。状态机的作用是把武器在任意时刻能做什么、不能做什么写清楚,让每一个转换都有明确的条件和后果。本文依次讨论:状态划分、如何避免状态爆炸、开火与射速、换弹与打断、切枪、逻辑与动画的关系,以及武器状态机在网络预测和回滚下需要满足的要求。1. 为什么需要状态机1.1 用布尔变量拼的武器// ✗ 常见的初版写法boolisReloading;boolisSwitching;boolisAiming;boolisSprinting;floatreloadTimer;voidUpdate(floatdt){if(input.fire!isReloading!isSwitching!isSprintingammo0)Fire();if(input.reload!isSwitchingammomagSize){isReloadingtrue;reloadTimerreloadTime;}if(isReloading){reloadTimer-dt;if(reloadTimer0){ammomagSize;isReloadingfalse;}}...}每加一个功能,每一处if都要多判断一个变量。很快就会出现:现象原因换弹时切枪,切回来子弹满了切枪没有清除isReloading,计时器在后台继续走切枪途中能开一枪某个if漏判了isSwitching同时处于换弹和切枪两个布尔值都为真,没有任何代码阻止改一个条件,另一个功能坏了条件分散在各处,没人知道完整规则问题的根源:布尔变量的组合允许出现不合法的状态。4 个布尔值有 16 种组合,其中很多根本不应该存在。1.2 状态机的约束状态机规定:武器在任意时刻只处于一个主状态,只能通过定义好的转换进入另一个状态。布尔拼凑: isReloadingtrue, isSwitchingtrue ← 允许,但无意义 状态机: 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(nownextFireTimewantFire){FireOneShot();nextFireTimenowinterval;}问题在于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.366.7 150 第 3 发: 150ms → 实际在 150 那一帧开火 …… 每一发都可能晚一帧,误差一直累积 → 实际射速低于设定值 而且帧率不同,实际射速也不同:144 帧的玩家射速比 60 帧的玩家高4.3 正确做法:时间累加器// ✓ 下次可开火时刻按理想时刻累加,不依赖帧时刻voidTickFire(WeaponStates,constInputCmdin,Time now){if(!in.fireHeld||!CanFire(s,in)){// 没在开火时,不允许攒射击机会,// 否则停火一秒后再按,会瞬间打出好几发if(s.nextFireTimenow)s.nextFireTimenow;return;}// 一帧内可能需要打出多发(高射速 低帧率/低 tick)while(s.nextFireTimenows.ammo0){FireOneShot(s,/*shotTime*/s.nextFireTime);s.ammo-1;s.shotIndex1;// 散布种子使用的计数(见散布同步一文)s.nextFireTimes.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(constWeaponStates,constInputCmdin){switch(s.def-fireMode){caseFireMode::Auto:returnin.fireHeld;caseFireMode::Semi:// 只在这一帧按着、上一帧没按时触发returnin.fireHeld!s.fireHeldLastTick;caseFireMode::Burst:// 点射进行中自动继续;否则需要一次新的按下returns.burstRemaining0||(in.fireHeld!s.fireHeldLastTick);}}栓动武器的拉栓期间不能开火,但通常可以开镜、可以被切枪打断,这时可以给Ready加一个子状态,或者单独加一个Cycling主状态,取决于它和其他操作的打断关系有多特殊。4.5 输入缓冲半自动武器有一个手感问题:玩家在冷却结束前几十毫秒按下开火,按照严格规则,这次按下会被丢弃,玩家感觉按了没反应。冷却中 ████████████████░░░░ 冷却结束 ↑ 玩家这时按下 → 被丢弃 → 吞枪常见做法是缓冲:冷却结束前一小段时间内的按下被记住,冷却一结束就开火。if(in.firePressedThisTick)s.bufferedFireUntilnowkFireBufferWindow;// 例如 100msbooltriggered(nows.bufferedFireUntil);if(triggerednows.nextFireTime){FireOneShot(...);s.bufferedFireUntil0;}缓冲窗口不能太长,否则玩家会感觉我没想开枪,它自己开了。服务器同样按这套规则执行,所以缓冲不会让半自动武器超过设定射速,宏连点也不会。5. 换弹5.1 弹药模型弹匣内: ammo 当前能打的子弹 备弹: reserve 背包里剩的子弹 弹匣容量: magSize 枪膛: 有些游戏区分枪膛里的一发(战术换弹后显示 301)两种换弹:战术换弹空仓换弹条件弹匣里还有子弹弹匣打空动作换弹匣换弹匣 拉枪机时长较短(例如 2.2 秒)较长(例如 2.8 秒)有枪膛设计时换完 容量 1换完 容量进入换弹状态时就决定是哪一种,并记录下来,不要在换弹过程中根据弹药数重新判断。5.2 关键设计:提交点换弹最常见的 bug 是换弹被打断,子弹却已经满了或者动画都快播完了被打断,子弹一发没进。解决方法是为每种换弹定义一个提交点:战术换弹 (2.2 秒): 0.0s 0.6s 1.5s 2.2s │─ 拔出旧弹匣 ─│─── 插入新弹匣 ───│●── 收尾动作 ──│ ↑ 提交点:此刻子弹进入弹匣 在提交点之前被打断: 不加子弹,下次需要重新完整换弹 在提交点之后被打断: 子弹已经加好,只是收尾动作没做完voidTickReload(WeaponStates,Time now){Time elapsednow-s.stateStartTime;if(!s.reloadCommittedelapseds.reloadDef-commitTime){intneeds.reloadDef-targetAmmo-s.ammo;inttakenstd::min(need,s.reserve);s.ammotaken;s.reserve-taken;s.reloadCommittedtrue;}if(elapseds.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(AnimControlleranim,constReloadDefdef){floatclipLengthanim.GetClipLength(def.animClip);// 美术做的动画长度,例如 2.4 秒floatplayRateclipLength/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(constWeaponStateprev,constInputCmdin,Tick now,constMoveFlagsmove,EventSinkevents);8.4 要求三:副作用与状态分离Tick 不直接产生表现,而是输出带唯一标识的事件。表现层负责去重:structWeaponEvent{EventType type;// ShotFired / ReloadStart / ...uint32_tkey;// 唯一标识:开火用 shotIndex,换弹用 stateStartTickTick tick;};voidPresentationLayer::Consume(constWeaponEvente){// 同一个事件已经表现过,说明这是回滚重模拟产生的重复事件if(alreadyPlayed.contains({e.type,e.key}))return;alreadyPlayed.insert({e.type,e.key});Play(e);}首次预测 cmd#101: ShotFired(key58) → 播放枪声 ✓ 回滚重模拟 cmd#101: ShotFired(key58) → 已播放过,跳过 回滚后结果变了: ShotFired 没产生 → 枪声已经响了,无法撤回(可接受)已表现记录只需要保留最近一两秒,超过回滚窗口的就可以清除。8.5 要求四:时间用整数 tick开火累加器、换弹提交点、拔枪时长,全部用整数 tick(或整数微秒)表示:浮点: nextFire 0.1 0.1 0.1 … 客户端和服务器可能在第 N 发出现不同舍入 整数: nextFire 6400 6400 … 任意平台结果相同数据表里配置的秒数,在加载时统一转换成 tick,运行时不再接触浮点时间。8.6 预测失败的几种情况情况原因纠正后的表现服务器拒绝了一次开火服务器上此时已经死亡、被眩晕、弹药不同弹药数回跳一发;shotIndex以服务器为准换弹没有被服务器接受服务器上备弹为 0(被其他事件消耗)换弹动画中止,回到 Ready切枪时机不同服务器上切枪请求晚到一帧拔出动画进度微调外部条件不同服务器判定你在空中 / 被击退散布、能否开火随之改变纠正时,表现层应该尽量平滑:voidOnWeaponStateCorrected(constWeaponStatepredicted,constWeaponStatecorrected){if(predicted.phasecorrected.phasepredicted.stateStartTickcorrected.stateStartTick){return;// 同一状态、同一开始时刻:只是数值差异,动画不用动}if(predicted.phasecorrected.phase){// 同一状态但开始时刻不同:调整动画播放位置,不重新播放anim.SeekTo(ProgressOf(corrected));return;}// 状态都不一样:切换动画,短混合过渡anim.CrossFadeTo(AnimFor(corrected),kCorrectionBlend);}弹药 HUD 一般直接显示纠正后的值。弹药数跳动一发,玩家几乎不会注意到;但如果换弹动画在中途突然重播,玩家一定会注意到。8.7 完整的回滚循环voidClientOnServerState(constServerWeaponSnapshotsnap){// 1. 丢弃已被服务器处理过的输入pendingCmds.DropUpTo(snap.lastProcessedCmd);// 2. 从服务器状态出发,重放尚未确认的输入WeaponState ssnap.state;for(constInputCmdcmd:pendingCmds)sTick(s,cmd,cmd.tick,moveHistory.At(cmd.tick),eventSink);// 3. 替换当前预测状态,交给表现层平滑处理OnWeaponStateCorrected(currentPredicted,s);currentPredicteds;}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 的事件输出并去重。满足这几条,客户端预测和服务器判定才能在绝大多数情况下完全一致,不一致时也能平滑纠正。