战士玩家最常投诉的一句话是"我按了没反应",十份工单里七份指向攻击状态机。996 引擎的攻击状态就六十来行,摆在 actor/gameActorStatePlayerAttack.lua,但把这六十行读懂,你就懂了整部引擎的"动作节奏学"——为什么这一刀挥出去要等多久、为什么有时候连点没反应、为什么脚本检测总盯攻击间隔。今天全拆开。
攻击状态在整套玩家状态族里的地位很特殊:它是玩家触发频率最高的主动状态(走路是被动的跟随输入),也是和外挂斗法的主战场——加速外挂最直接的表现就是攻击间隔异常。所以读这六十行代码时你会有种奇怪的割裂感:代码本身干净得像教科书,但它周围站满了岗哨,属性入口有攻速封顶,服务端有频率检测,行为树有裁决门槛。这份代码的所有设计,都建立在一个前提上:它必须快,但必须诚实地快。
先看 OnEnter,攻击状态一生中最重要的时刻:
function gameActorStatePlayerAttack:OnEnter(player)
if player.mActionHandler then
-- 第一件事:广播"我开始攻击了",携带方向
player.mActionHandler:handleActionBegin(player, global.MMO.ACTION_ATTACK, player.mOrient, 0)
end
-- 第二件事:掐表,总时长 = 攻击步长 ÷ 攻速
local animTime = player:GetAttackStepTime() / player:GetAttackSpeed()
player.mCurrentActT = animTime
player.mIsConfirmed = true
-- 第三件事:清预备帧标记 + 标记动画脏,逼渲染层换帧
player:SetAnimPrepare(0.0)
player:DirtyAnimFlag()
end
animTime = GetAttackStepTime / GetAttackSpeed 这行是全篇的题眼。步长是这个动作的"标准时长"(配置表给,比如 0.6 秒),攻速是全体加速系数(狂暴、加速装备往上堆)。两个数一除,这一刀实际挥多久就定了。引擎把"动作多快"收拢成一张除法表,所有花里胡哨的攻速道具最后都只改 GetAttackSpeed 的返回值,状态机本体十年没动过。你做"疾风戒指",别去碰状态机,去改攻速系数的来源,这是规矩。
mIsConfirmed = true 这行要划重点:攻击状态和走路不一样,进状态时就声明"我已被确认"。走路要等服务端逐格签收(防加速外挂),攻击为什么不等?因为攻击的权威判定在服务端的命中公式里,客户端这一刀只是演——演得再快也改不了伤害结算。与其让玩家感觉"攻击发滞",不如本地直接放行,代价只是外挂能演得更花,但伤害骗不了人。这是表现优先级的典型取舍,跟飞行道具那篇"表演与判定分离"是同一个思想的两面。
再看 Tick,短到可以全文抄完:
function gameActorStatePlayerAttack:Tick(dt, player)
player.mCurrentActT = player.mCurrentActT - dt
-- 时间走完且已确认:上报"动作完成"
if 0.0 >= player.mCurrentActT and player.mIsConfirmed then
if player.mActionHandler then
player.mActionHandler:handleActionCompleted(player, global.MMO.ACTION_ATTACK)
end
end
-- 网络兜底:5秒都没正常收尾,强制放行防卡死
if player.mCurrentActT <= -5.0 then
player:SetConfirmed(true)
end
end
没有命中判定,没有伤害数字,没有目标查找——那些全在别的层。攻击状态只管一件事:把这段时间守完。handleActionCompleted 上报之后,上层(行为树、动作处理器)才会决定接下来是接下一刀还是切回待机。这种"状态只管状态"的洁癖,让六十行代码十年不用返工:改手感改配置表,改判定去服务端,改表现去动画层,谁也不用动这六十行。
那行 <= -5.0 的兜底和走路状态机是同款:倒计时归零后理论上立刻能完成,万一动作处理器抽风没人接卷,倒计时走到负五秒强制放行,角色不会卡成雕像。引擎对"最坏情况"的态度高度一致:宁可多放行,绝不卡死。 你二开写自己的状态,把这条兜底一起抄走,能少一批"角色定住"的深夜工单。
flowchart TD
A[行为树裁决: 攻击行为接管] --> B[OnEnter]
B --> C[handleActionBegin 广播开打+方向]
C --> D[animTime = 步长 ÷ 攻速, 倒计时启动]
D --> E[每帧 Tick: mCurrentActT 递减]
E --> F{归零 且 mIsConfirmed?}
F -->|是| G[handleActionCompleted 上报]
G --> H[上层裁决: 连击/待机/被打断]
F -->|倒计时< -5s| I[强制 SetConfirmed 放行]
I --> G
状态退场比进场容易被忽视,但攻击的 OnExit 藏着手感密码:
function gameActorStatePlayerAttack:OnExit(player)
-- 恢复预备帧时间(3.0 表示"离开预备阶段")
player:SetAnimPrepare(3.0)
-- 动作切回 READY 待机,标记脏帧
player:SetAnimAct(global.MMO.ANIM_READY)
player:DirtyAnimFlag()
end
SetAnimPrepare 这对进出场的配合是序列帧系统的"起手前摇"开关:进攻击状态时清零(准备播起手帧),出场时设回 3.0(回归常态)。前摇帧的长短直接决定打击感——起手帧停 80 毫秒是"蓄力",停 200 毫秒就变"卡顿"。数值在动画表里调,状态机只负责开关。
DirtyAnimFlag 这个脏标记也是老朋友了(走路状态机里见过它):状态切换不直接换图,只立一个"动画过期"的牌子,渲染层下一帧巡检到牌子再统一换装。一百个同屏角色每秒几十次状态切换,全靠这块牌子把换图操作合并到渲染节拍里,避免一帧内反复换装。你在二开里给角色加新动作层(比如披风特效层),也要遵守"立牌不换图"的规矩,直接在状态机里调渲染接口,迟早和渲染节拍打架。
把三个阶段串起来看,攻击状态机其实是一场三次握手:handleActionBegin 是"我要开打了"的广播,倒计时是"我正在打"的执行,handleActionCompleted 是"我打完了"的交卷。上层所有系统——行为树、Buff、特效——都是听这三个节拍做自己的事:Buff 听 begin 判断"攻击时触发",特效听 begin 挂刀光听 completed 播命中,统计听 completed 计输出。状态机提供节拍,各方踩点跳舞,这就是为什么它的代码可以六十行写完:所有复杂性都被推给了节拍的订阅者,它自己只管数拍子。
顺带交代一下和走路状态机的分工边界,这问题群里的朋友问过不下二十次。走路每一步都要服务端确认(mIsConfirmed 进场是 false),攻击进场就是 true;走路被中断后从当前位置继续,攻击被中断这刀作废;走路的状态时长固定 0.5 秒不随属性变,攻击时长是步长和攻速的除法结果。三个差别,每一个都对应一条设计原则:位置要权威、表演可牺牲、节奏归配置。你以后分不清某个新动作该学谁,拿这三条原则去量,量完就有答案。
下面这个演示把攻击时序摆成了可见的进度条:拖攻速滑块看同一把刀的节奏变化,勾自动连击感受"完成即刻接下一刀"的流畅;底部的倒计时读数就是 mCurrentActT 本尊:
fx-attack-timing-1003c
工单经典款:"战士连点攻击,十下只有五下出手, 手感稀碎。"排查思路给你示范一遍,结论可能和直觉相反。
第一反应别怀疑状态机——把 state 日志打开(行为树那篇教的 print 三板斧),会发现十次点击全部进了 BehaviorKLaunch 的日志,但只有五次走到 OnEnter。凶手在行为树的裁决层:另五次点击时角色还在 ATTACK 状态里,actCompleted 不是 IDLE,输入被当成"排队指令"丢弃了。攻击没有输入缓冲是引擎的原始设计:点早了就是白点,手速再快也快不过 0.6 秒的挥砍。
修法有两档。温和档:在 KLaunch 行为里加一个 0.2 秒的输入缓冲,ATTACK 状态结束前 0.2 秒内收到的点击,状态一完成立刻补刀,体感顺滑度立竿见影。激进档:直接允许打断后摇(挥砍的最后 30% 时间可被新攻击打断),输出频率上去了,但动作会显得急躁,PVP 里变相加强先手职业。我们给两个服分别上过两档,都让玩家满意——手感没有标准答案,只有和你服务定位的匹配度。改动的具体数字记好:温和档缓冲 200 毫秒,激进档打断窗口 30%,这两组数在论坛里被抄烂了,出处就是这两次改造。
攻速堆到极限会怎样?算笔账。步长 0.6 秒,攻速 3.0,单刀 0.2 秒——每秒五刀,序列帧攻击动画总共 6 帧,每帧只显示 33 毫秒,人眼看就是一团残影。这还只是观感问题,真正的问题是三连击的帧事件:第三刀的命中帧依赖前两刀的播放进度,播放被压缩后帧事件在极短时间内连发,服务端的命中包挤在同一秒,反作弊系统盯着"单秒攻击次数上限",超了直接判脚本——玩家花钱买的攻速装备把他自己送进了小黑屋。
所以成熟的服都会给攻速封顶:系数超过 2.5 后收益减半,或者硬上限 3.0。这个封顶不在状态机里做,在 GetAttackSpeed 的返回处钳制。任何"无限堆叠"的属性都要在来源处设闸,状态机和动画只是忠实的执行者,闸门立在属性入口才不会漏。这个教训来自一次真实事故:某服无限攻速活动当晚,反作弊误封 47 人,客服加班到凌晨三点,活动第二天紧急下架。
那次事故还有一个余波值得记录:封禁的 47 个号里,有 3 个是真实玩家用机械键盘宏连点触发的,另有 5 个是攻速装备配饰叠加正好踩线。解封公告发出去后,运营顺手把"攻速生效规则"写进了游戏内的属性面板悬浮提示——玩家看得懂的规则才是好规则,藏在服务端的神秘判定只会制造阴谋论。这行干久了你会认同一个朴素的道理:技术问题和信任问题往往是一体两面,解封三次不如公示一次。
问:攻击时被打断(受击、位移)怎么处理?
受击/位移状态通过 ChangeState 直接改 mCurrentState,攻击 Tick 自然停止,OnExit 擦屁股。被打断的这刀没有命中帧事件就不结算——所以高攻速职业的"打断抵抗"(霸体)其实是让受击状态跳过 ChangeState,从源头保住挥砍节奏。
问:为什么有时候攻击动作播完了,刀却没伤害?
先查服务端:命中率、闪避、无敌帧都在那边判定,客户端挥空是正常的表演。如果服务端说打了但客户端没飘字,查的是 handleActionBegin 的帧事件注册,和本篇的状态机无关,别在状态机里绕圈。
问:连击武器(三段连击)怎么在这套状态机上实现?
三段是三个动作配置,服务端下发连击段数,客户端在 handleActionCompleted 时若还有剩余段数就立刻重新 OnEnter 换下一段动作帧。段与段之间不回待机状态,观感才连贯——这也是为什么连击武器的打断窗口要单独配置,一刀被断全段作废的手感会逼疯玩家。
问:SetAnimPrepare(3.0) 里的 3.0 是什么魔法数字?
按引擎的约定,预备时间大于任何一个真实动作时长就等于"关闭预备阶段",3.0 秒远超所有动作上限,是"归位"的惯用写法。这属于约定俗成的哨兵值,改之前先全局搜引用点,有些动画表判断的是 prepare > 某阈值 而不是等于零,乱改会出现"待机时反复抽帧"的怪病。
问:能不能给攻击加本地预判定,挥刀瞬间就飘伤害?
能演,但两套数字会打架:本地先飘的数字被服务端结果覆盖时,玩家会看到伤害数字"变脸",比晚半秒飘字糟糕十倍。命中提示的延迟阈值在 300 毫秒内大多数玩家无感,真要做打击感优化,把预算花在刀光提前量和受击音效上,收益比抢跑数字高得多。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…