FPS 武器状态机:设计思路和背后的原因 先说核心思路设计武器状态机,其实就是回答三个问题:1. 枪会做哪些事? → 决定有哪些状态 2. 哪些事不能同时做? → 决定哪些放进同一个状态机 3. 一件事做到一半,能不能被别的事打断?打断了算什么? → 决定转换规则下面按设计的先后顺序,一步一步讲:怎么设计、为什么这样设计、真实游戏里是什么样。第 1 步:列出枪会做的事,找出互斥的动作怎么做先把一把枪能做的事全列出来:掏枪、收枪、开火、换弹、开镜、冲刺、拉栓、检视武器 ……然后问:哪些事是不可能同时做的?掏枪 和 换弹 → 不可能同时(手还没把枪拿出来,怎么换弹) 换弹 和 收枪 → 不可能同时 开镜 和 走路 → 可以同时 开火 和 走路 → 可以同时互相排斥的动作,放进同一个状态机,做成主状态。于是得到:收起 → 掏枪中 → 就绪 ⇄ 换弹中 ↓ 收枪中 → 收起为什么主状态的本质就是一次只能是一个。把互斥的动作放进来,一边换弹一边收枪这种不可能的情况,从结构上就不会出现,不用靠程序员到处检查。游戏案例:CS 的掏枪时间在 CS 里,切到另一把枪,不能立刻开火,要等掏枪动作做完。AWP 掏出来要等一会儿才能开枪,手枪就快很多。切到 AWP: 收起 → 掏枪中(较长) → 就绪 → 才能开枪 切到手枪: 收起 → 掏枪中(较短) → 就绪 → 才能开枪这就是掏枪中作为一个独立状态的意义:在这个状态里,规则写明了不能开火,而且时长每把枪单独配置。这个设计直接影响战术:AWP 手被打了切手枪反击,就是在利用手枪掏枪更快。第 2 步:能同时发生的事,不要塞进主状态,单独分层怎么做开镜、冲刺这些事,和换弹、开火可以同时存在(你可以开着镜开火,可以边走边换弹)。如果硬塞进主状态:就绪-腰射-走路、就绪-腰射-冲刺、就绪-开镜-走路、就绪-开镜-冲刺 换弹-腰射-走路、换弹-腰射-冲刺、……状态数会成倍增加。所以把它们拆出去,单独管:┌────────────────────────────────────┐ │ 主状态: 收起/掏枪/就绪/换弹/收枪 │ 一次只能一个 ├────────────────────────────────────┤ │ 瞄准层: 腰射 ⇄ 开镜(带进度) │ 自己独立变化 ├────────────────────────────────────┤ │ 移动层: 走 / 跑 / 冲刺 / 跳 / 蹲 │ 由角色移动决定 └────────────────────────────────────┘各层之间只通过条件互相影响,比如:冲刺时 → 主状态就绪里不允许开火 换弹时 → 瞄准层不允许开镜 开镜进度越高 → 散布越小为什么防止状态爆炸。主状态 5 个、瞄准 2 个、移动 4 个,分层后需要设计的是 5 2 4 11 种;塞在一起是 5 × 2 × 4 40 种,而且每加一个维度又要翻倍。游戏案例:COD 的冲刺开火延迟在 COD 系列里,冲刺时不能开火,停下冲刺后要过一小段时间才能开第一枪,这段时间通常被称为冲刺转开火时间(sprint-to-fire),不同武器、不同配件长短不同。这在分层设计里很好实现:移动层: 冲刺 → 停止冲刺,记下停止时刻 主状态就绪判断能否开火: 现在 - 停止时刻 这把枪的冲刺转开火时间不需要新增一个冲刺后恢复中的主状态。配件改变这个时间,也只是改一个数字。开镜同理:开镜有过渡时间,用进度 0~100%表示,散布、视野、移动速度都跟着进度变化。这就是为什么很多游戏里开镜开到一半就开枪,准度介于腰射和开镜之间。第 3 步:开火不做成状态,冷却用时间表示怎么做很多人第一反应是加一个开火中状态。但开火之后的冷却期间,你照样可以换弹、切枪、开镜,和就绪完全一样。所以:开火是就绪状态里的动作,冷却只记一个下次能开枪的时刻。就绪状态里: 到了能开枪的时刻,并且按着开火 → 开一枪,把下次能开枪的时刻往后推 还没到 → 什么也不做,但换弹、切枪照常可以下一发的时间要按本来该打的时间往后推,而不是按实际打出的时间往后推,这样射速不受电脑帧率影响(前面看表出门的例子)。不同开火模式,只是就绪里的不同规则:模式规则游戏例子全自动按住就按射速连发CS 的 AK-47、M4半自动每按一下打一发CS 的沙鹰、大多数手枪点射按一下连发 3 发CS 的 FAMAS、Glock 的点射模式什么时候才需要单独的状态?当开火后有一段别的事也不能做或规则明显不同的动作时。典型就是栓动狙击枪。游戏案例:CS 的 AWPAWP 开一枪之后:开枪 → 自动退出瞄准镜 → 拉栓 → 拉完才能再开枪(要重新开镜)这个拉栓阶段和普通冷却不一样:它会强制退镜,有自己的动画。所以栓动狙击可以单独加一个拉栓中状态,规定:拉栓中: 不能开火、不能开镜、可以被切枪打断这也是 CS 里常见的开完一枪立刻切刀/切手枪再切回来操作的由来:玩家在利用切枪对拉栓的打断规则。这类操作是否成立,完全取决于状态机里写的打断规则,设计师可以有意保留,也可以有意禁止。第 4 步:每个状态都要有进入、持续、离开怎么做每个状态分三个时机:进入时: 只执行一次 → 开始播放动画、播放声音、记下开始时间、决定换弹类型 持续中: 每帧执行 → 检查时间到没到、能不能开火 离开时: 只执行一次 → 停止声音、清理这个状态的临时数据为什么开关式写法里,最常见的 bug 就是开始的事做了两次“结束的事忘了做”。比如换弹被切枪打断,换弹的声音还在响。状态机里,不管是正常结束还是被打断,只要离开换弹中,就一定会执行离开时,声音一定会停,计时一定会清掉。游戏案例:战术换弹和空仓换弹COD、战地、Apex 等很多游戏都区分两种换弹:战术换弹: 弹匣里还有子弹 → 只换弹匣 → 时间较短 空仓换弹: 弹匣打空了 → 换弹匣 拉枪机 → 时间较长有些游戏还有枪膛里多留一发的设计:30 发弹匣战术换弹后显示 31 发,空仓换弹后只有 30 发。换哪一种,在进入换弹那一刻就决定并记下来,之后不再变。如果每帧都根据子弹数重新判断,中途数据一变,动画和时间就全乱了。第 5 步:换弹要设计生效时刻怎么做换弹不是做完才给子弹,而是在中间某个时刻给:0 秒 0.6 秒 1.5 秒 2.2 秒 │── 拔旧弹匣 ──│── 插新弹匣 ──────│●── 拉栓、收尾 ──│ ↑ 生效时刻:子弹加上 在 1.5 秒之前被打断:子弹没加,下次要从头换 在 1.5 秒之后被打断:子弹已经加好了为什么两个原因:符合直觉。玩家看到弹匣插进去了,就认为子弹已经在枪里。如果收尾动作被打断就不算,玩家会觉得被坑。给熟练玩家留空间。生效之后可以提前取消收尾动作,节省时间。这是一个可以被设计、被控制的技巧。游戏案例 1:换弹取消在 COD 和 Apex 这类游戏中,玩家社区里流传换弹取消技巧:弹匣插进去之后,立刻用其他动作(如切枪、冲刺等,具体视游戏而定)打断收尾动画,子弹已经加上,还能更快恢复战斗。这个技巧之所以能存在,就是因为状态机设计了生效时刻并且允许生效后的打断。如果设计师不想要这个技巧,把生效时刻放到最后就行了——这是一个数据设计,不是程序 bug。游戏案例 2:霰弹枪逐发装填CS 的 Nova 这类泵动霰弹枪是一发一发装的:开始装填 → 装一发 → 装一发 → 装一发 …… → 结束装填 每装一发,就生效一发装到一半按开火,就停止装填,立刻开枪,已经装进去的子弹都算数。这正是每一发都有自己生效时刻的结果:打断只损失正在装的那一发。第 6 步:把谁能打断谁写成一张表怎么做不要把打断规则散落在代码各处,而是写成一张表:当前状态 \ 玩家想做开火换弹切枪开镜掏枪中不行等掏完再换可以,直接切不行就绪可以可以可以可以换弹中(弹匣)生效后可以不行可以,打断换弹不行换弹中(逐发)可以,停止装填不行可以,打断不行收枪中不行不行改成切另一把不行为什么同时按两个键时,结果是确定的。表里写了换弹中按切枪 打断换弹,就不会因为代码先后顺序而变来变去。策划能直接看懂、直接改。冲刺能不能打断换弹是游戏设计问题,不应该埋在程序代码里。一眼能看出有没有遗漏。表里有空格,就说明有情况没考虑到。游戏案例:同一个问题,不同游戏不同答案“冲刺时能不能换弹?”有些游戏里冲刺会打断换弹,玩家必须停下来换,换弹是一个冒险的决定有些游戏允许边跑边换,节奏更快“换弹时切枪会怎样?”在 CS 里,换弹时切枪会中断换弹,切回来要重新换。弹匣快空了,来不及换就切手枪是基本功这些差异体现的是游戏的设计取向:慢节奏、重决策的游戏倾向更多限制,快节奏的游戏倾向更多自由。用打断规则表,换一套规则只是改表,程序结构完全不用动。第 7 步:切枪由上层协调,不是一个状态怎么做切枪涉及两把枪:当前的枪: 就绪 → 收枪中 → 收起 ↓ 通知 目标的枪: 收起 → 掏枪中 → 就绪每把枪有自己的状态机,另外有一个武器管理器负责协调:记住要切到哪把,当前这把收完了,就让下一把开始掏。为什么每把枪的状态机只管自己,规则简单连续快速按切枪键时,管理器只需要更新目标,不会让两把枪同时在掏枪收起后,它的状态机就停了,不会有计时器在后台偷跑(回顾切回来子弹满了的 bug)游戏案例:切刀跑得快在 CS、Valorant 里,很多玩家转点时会切出刀(近战武器)跑,因为持刀移动速度更快。这个操作背后:步枪: 就绪 → 收枪中 → 收起(状态停住,子弹数等原样保存) 刀: 收起 → 掏出中 → 就绪 移动速度: 由当前手持武器决定切回步枪时,步枪从收起重新开始掏枪中,子弹数还是原来的。整个过程清清楚楚,不会出现跑着跑着步枪自己换完弹了。第 8 步:时间规则写在数据表里,动画跟着演怎么做数据表(说了算): 换弹总时长 2.2 秒,生效时刻 1.5 秒 动画(跟着演): 美术做的动画原本 2.4 秒 → 加速播放到 2.2 秒程序只读数据表,动画只负责看起来对。为什么服务器不播动画。联网游戏的判定在服务器上做,服务器只算数,没有画面。如果规则依赖动画,服务器根本没法判断。平衡由策划控制。美术改动画不应该改变武器强弱。第一人称和第三人称动画长度不同,但都对齐到同一个逻辑时长,敌人看到你换弹的节奏才和真实一致。游戏案例:COD 的快手技能COD 系列里有经典的快手(Sleight of Hand)技能,效果是换弹更快。在数据驱动的设计里,它的实现非常简单:装备快手: 换弹总时长 × 0.5,生效时刻 × 0.5 动画: 自动按新时长加快播放不用为每把枪重做一套快手换弹的逻辑,也不用让美术重新做动画。所有影响武器节奏的技能、配件(快速弹匣、轻量化枪托等),本质上都只是在改数据表里的数字。第 9 步:为联网设计——能存档读档怎么做联网 FPS 里,你的电脑会先预测开枪结果,服务器回话后如果不一致,要回到服务器的状态重新算一遍(就是前面说的读档重来)。为此,武器状态机要满足三点:1. 枪的全部信息放在一起: 当前状态、进入时间、子弹数、下次开火时刻 …… 能整个复制、整个恢复 2. 同样的操作算出同样的结果: 不读系统时间、不用随意随机数、不看动画进度 3. 声音特效不放在计算里: 只发出开了第 58 发这样的消息, 播放时检查第 58 发已经响过了就不再响为什么信息散落在各处,读档就恢复不全,会出现开了枪不算“弹药数跳动”计算结果不稳定,读档重算后和服务器又对不上声音放在计算里,每次重算都响一遍,一发子弹听到两声枪响游戏案例:你在 CS / Valorant 里按下开火你的电脑: 立刻扣一发子弹、播放枪声、显示弹孔 (预测,不等服务器) 服务器: 几十毫秒后收到,用同样的规则算一遍 结果一样 → 什么都不用改,你感觉不到任何延迟 结果不同 → 比如你其实已经被打死了 你的电脑读档:恢复服务器的状态,重新算网络好的时候,你几乎感觉不到这个过程,这正是因为两边用的是同一个、行为完全确定的武器状态机。一个完整案例:走一遍状态假设在一局类似 CS 的对局里,你做了这样一串操作:操作 步枪状态 说明 ──── ──────── ──── 开局切出步枪 收起 → 掏枪中 掏枪时不能开火 掏枪完成 就绪 按住左键扫射 就绪(内部连续开火) 按射速推下次开火时刻 打到剩 5 发,按 R 就绪 → 换弹中(战术换弹) 进入时决定类型、播放动画 换到 1.0 秒,看到敌人,切手枪 换弹中 → 收枪中 还没到生效时刻,子弹仍是 5 发 离开换弹:停止换弹声音 步枪收好 收起 状态停住,不会偷偷换完 手枪 收起 → 掏枪中 → 就绪 手枪掏得快,反击 打完敌人,切回步枪 收起 → 掏枪中 → 就绪 子弹还是 5 发 按 R 就绪 → 换弹中 这次完整换完 过了 1.5 秒 换弹中(已生效) 子弹变成 30 立刻冲刺(该游戏允许打断收尾) 换弹中 → 就绪 子弹已加,省下收尾时间每一步发生什么,都能从状态和规则表里直接看出来,没有任何碰巧。总结:每个设计决定和它的原因设计决定为什么游戏案例互斥动作做成主状态不可能的状态从结构上排除CS 切枪要等掏枪开镜、冲刺单独分层防止状态数成倍增加COD 冲刺后开火延迟开火不做状态,用时间控制冷却冷却期间其他操作照常;射速不受帧率影响AK 全自动、FAMAS 点射特殊开火动作才单独成状态规则确实不同时才需要CS 的 AWP 拉栓每个状态有进入/持续/离开开始和结束的事只做一次,打断时一定清理战术/空仓换弹在进入时决定换弹设计生效时刻符合直觉,并可控地留出操作空间换弹取消、霰弹枪逐发装填打断规则写成表结果确定、策划可改、遗漏可见各游戏冲刺/切枪打断规则不同切枪由上层协调每把枪只管自己,收起后不会偷跑切刀跑图时间写数据表,动画跟着演服务器能判定,平衡不被动画影响COD 快手技能可存档、可重算、表现去重联网预测和纠正才能稳定CS/Valorant 开枪即时反馈一句话:武器状态机的设计,是把枪能做什么、不能同时做什么、被打断了算什么这些游戏规则,从散落在代码里的判断,变成清楚的结构和数据。这样规则不会被意外破坏,策划能直接调整,联网时客户端和服务器也能算出一样的结果。