CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

怪物的刀要等动画点头:怪物攻击节奏与Y排序

2026-10-03 08:53 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

怪物是游戏里戏份最重的群演,一天要挥几百万次刀。996 引擎给怪物配了一套和玩家不同的攻击节奏逻辑,核心一句话:玩家的刀跟着攻速表走,怪物的刀要等动画点头。这套逻辑在 actor/gameActorStateMonsterAttack.lua,外加 gameActorMonster.lua 里一段教科书级的 setPosition。今天把这两块拆开,看完你会重新理解"动作即时间"。

怪物状态族和玩家状态族是两套并行的家谱:玩家那边有走跑跳砍施法几十个状态,怪物这边精简到 Idle、Walk、Attack、Die 加几个特殊款(出生、传送、变身)。精简的原因很直白:玩家的每个动作都是意志的表达,怪物的动作是服务端数据包的翻译——服务端说"这怪开始攻击了",客户端演一遍就完,自己不用拿主意。所以怪物状态里的代码普遍比玩家状态短一半,MonsterIdle 的 Tick 干脆是空函数:站着不动需要什么逻辑?但别小看这个空函数,它继承了状态机的全部骨架(ChangeState、OnEnter、OnExit、GetStateID),随时可以填肉。空壳也是架构的一部分。

MonsterIdle 的 ChangeState 里还藏着一个小小的礼貌:if newState:GetStateID() == self:GetStateID() then return false end——已经是待机就拒绝再次进入待机。没有这道闸,服务端每发一次冗余的待机包,客户端就 OnExit 加 OnEnter 折腾一轮,待机动画反复从头播,怪看起来像在抽搐。状态机的第一道防线是拒绝无意义的自我切换,一行判断挡掉的是满屏小怪集体抽风的直播事故。你做任何状态机,先把这句写上。

一、怪物攻速:动画时长是唯一基准

先看 OnEnter,怪物的攻击入场:

lua
function gameActorStateMonsterAttack:OnEnter(monster)
    -- 攻击时长直接问动画管理器要:这套刀的动画一共多久
    local animTime = global.FrameAnimManager:GetAnimTotalDuration(
        monster:GetAnimationID(), global.MMO.SFANIM_TYPE_MONSTER, global.MMO.ANIM_ATTACK)

    -- 怪物攻速 默认用帧动画速度 要是快于帧速度 就加速
    if monster:GetAttackSpeed() > 1 then
        monster.mCurrentActT = animTime / monster:GetAttackSpeed()
    else
        monster.mCurrentActT = animTime
    end
    monster:SetIsArrived(false)
    monster.mActionHandler:handleActionBegin(monster, global.MMO.ACTION_ATTACK, monster.mOrient, 0)
end

三个参数也顺手解读一遍:GetAnimationID 是这只怪的动画资源号,SFANIM_TYPE_MONSTER 告诉动画管理器去怪物的图集目录找,ANIM_ATTACK 指定动作。同一套 FrameAnimManager 服务玩家、怪物、NPC、技能特效四大图集体系,靠第二个参数分库——分库的键早于一切查询存在,这就是动画缓存那篇讲过的"档案按类型分家"在查询入口的体现。

和玩家攻击状态对比一下,差异立刻浮出来。玩家的攻击时长是"配置步长÷攻速",动画只是陪衬;怪物的攻击时长直接取这套动画本身的总时长——挥几帧就演几帧,时间轴和画面对得严丝合缝。为什么区别对待?怪物的动作是整套设计的展示品(抬手、蓄力、劈砍每一帧都在讲"这是什么怪"),压时间会毁掉怪物的性格;玩家的动作被千百次重复,玩家要的是利索。

那句"快于帧速度就加速"的注释值得咂摸:攻速大于 1 才除,等于 1 或小于 1 原样走。攻速只允许怪物比动画快,不允许比动画慢——慢放的动作会卡出鬼畜感,引擎干脆不接受。你调怪物难度想"让 Boss 出手更慢",别在这套攻速里想办法,去改动画帧间隔,那才是给慢刀留的正门。

攻速上限这头也有讲究。动画总时长 0.8 秒、攻速 2.5,压到 0.32 秒,一帧只剩 40 毫秒,玩家的眼睛开始跟不上;攻速 4 就是纯残影。我们给怪物的经验上限是 2.5,超过这个数与其继续压时间,不如直接换一套出手更利索的攻击动画——引擎里怪物的动画资源是按动作风格归档的,同一只怪挂不同 AnimationID 就能换整套节奏。改表比压数快,效果还好,这笔账很多二开团队没算过。

Tick 里的完成阈值也有个彩蛋:monster.mCurrentActT <= 0.00001——不用 0 用十万分之一。浮点倒计时在最后一帧经常停在 0.0000012 这种尴尬值,严格判 0 会多等一帧,判小正数稳稳收尾。这种小数点后五位的讲究,是无数"偶发卡一刀"的工单喂出来的。

二、OnAnimLoadCompleted:动画迟到怎么办

怪物刚出生时图集可能还没加载完,攻击请求来了动画没就位,怎么算时间?引擎的答案是"补时差":

lua
function gameActorStateMonsterAttack:OnAnimLoadCompleted(monster, animAct, elapsed)
    if animAct ~= global.MMO.ANIM_ATTACK then
        return false
    end
    -- 动画实际开始到加载完成已过 elapsed,总时长里扣掉这段
    local animTime = global.FrameAnimManager:GetAnimTotalDuration(
        monster:GetAnimationID(), global.MMO.SFANIM_TYPE_MONSTER, global.MMO.ANIM_ATTACK) - elapsed
    if monster:GetAttackSpeed() > 1 then
        monster.mCurrentActT = animTime / monster:GetAttackSpeed()
    else
        monster.mCurrentActT = animTime
    end
end

加载完成回调带着 elapsed(动画已经播掉的时长)进来,倒计时改成"总时长减去已播部分"。逻辑层的计时和表现层的播放在这一刻对表——动画迟到,时间轴跟着折算,一刀不多一刀不少。这种"两套时钟对表"的思路在走路确认机制里也出现过(mIsArrived 加 mIsConfirmed 双标志),引擎处理一切"逻辑与表现不同步"的套路都是:让逻辑等表现,但只等差值。

OnExit 的两连招也顺手交代:StopAllAnimation 停掉所有动画再 DirtyAnimFlag 立脏牌。攻击态可能有特效动画、受击叠层在身上,退场时全部清干净,不留给下一个状态一堆悬空动画。状态是动画的主,进要带得动,退要清得光。

动画迟到补时这套机制还有一个隐性受益方:群体刷新。一波刷三十只怪,图集加载有先后,三十只怪的攻击动画各自在加载完成的瞬间对表,玩家看到的是每只怪的刀都完整挥完——没有一个"空手比划"的,也没有一个"动作加速追赶"的。如果引擎选择"等图集全好再让怪动手",刷怪波次会变成整齐划一的定格瞬间;如果选择"不等,直接空放",就是白骨精式空气刀。补时方案在两个极端之间走出第三条路:各自迟到,各自补账。

mermaid
flowchart TD
A[AI决策: 攻击] --> B[OnEnter]
B --> C[问 FrameAnimManager 要动画总时长]
C --> D{攻速 > 1?}
D -->|是| E[时长 = 总时长 ÷ 攻速]
D -->|否| F[时长 = 总时长 原样]
E --> G[倒计时 Tick]
F --> G
G --> H{动画是中途才加载好?}
H -->|是| I[OnAnimLoadCompleted: 扣掉 elapsed 重设倒计时]
I --> G
G --> J[≤0.00001 → handleActionCompleted]
J --> K[OnExit: StopAllAnimation + 立脏牌]

三、setPosition:一次搬家,四方通知

怪物每走一步,gameActorMonster:setPosition 要干的事比"改坐标"多得多:

lua
function gameActorMonster:setPosition(x, y)
    gameActorMonster.super.setPosition(self, x, y)

    -- refresh zOrder:Y 越大层级越高,死亡再垫一层防压住活人
    if self.mAction == mmo.ACTION_DIE then
        self:GetNode():setLocalZOrder(math.floor(-y) + self:GetAdditionZorder() + mmo.DieAddZorder)
    else
        self:GetNode():setLocalZOrder(math.floor(-y) + self:GetAdditionZorder())
    end

    -- update hud pos:血条跟着走
    local hudTop = self:GetHUDTop()
    global.HUDManager:setPosition(self.mID, x, y + hudTop)

    -- update buff pos / 特效 pos
    global.BuffManager:setPosition(self:GetID(), x, y)
    global.ActorEffectManager:setPosition(self:GetID(), x, y)
end

zOrder 取 floor(-y) 是 2.5D 俯视视角的遮挡铁律:屏幕上越靠下的角色离镜头越近,越近越该画在前面。取负让 y 越大层级数越大,死亡时再加 DieAddZorder 把尸体垫到活人脚底——三层排序,一个表达式,这就是满屏人物互不穿模的全部秘密。你以后做任何带俯视视角的 2D 项目,这行公式直接背走。

死亡垫底那个分支还有个隐藏用途:尸体压在活人脚下后, clicking 命中检测也从最上层往下走,玩家点地面时不会误点到尸体上。排序值同时服务渲染和点击两层,一层改动两处受益。这种"一份数据养多个消费者"的布置在引擎里比比皆是,看见一个新字段先想想"还有谁该吃这份数",往往能白捡两个功能。

后面三行是"搬家通知四方":血条、Buff、挂件特效各自管理器收到新坐标。漏通知任何一方的症状都很滑稽——血条飘在原地、毒圈跟着怪跑偏半格。引擎把通知集中在 setPosition 一个出口,所有子系统只认这一个源头。你加第五方(比如头顶称号),也挂进这里,别自己在移动逻辑里另插坐标更新。

下面这个演示把节奏和排序都摆上了台面:两只怪在场景里上下穿行,勾着 Y 排序看遮挡始终正确,关掉它欣赏穿模现场;点"怪物攻击"或勾自动连击,看倒计时怎么以动画总时长为底、攻速只做除法:

demo
fx-monster-anim-1003d

四、实战案例:一次"Boss 慢刀"的改造课

某服做年终 Boss"魔龙教主",需求:出手要慢、蓄力要满、压迫感要足。实习生顺手把攻速改成 0.6,期待"动作变慢"——结果刀速纹丝不动。这就是前文那条规则的活标本:攻速小于等于 1 引擎原样走帧速,"慢"这个方向根本不在攻速的服务范围。

正确路径是改动画:把 Boss 攻击动画的帧间隔从 80 毫秒拉到 160 毫秒,动画总时长从 0.8 秒变 1.6 秒,OnEnter 问到的 animTime 自然翻倍——刀真的慢了,而且每一帧的蓄力姿态都看得清。再配合蓄力帧前插 3 帧红光预警,玩家有了"该躲了"的判断窗口,Boss 战击杀率从 31% 提到 58%,论坛好评如潮。工时账:动画表调整半天,预警特效一天,总成本一天半。

改造后的数据验证也做了,给后来人一个测试范本:录屏逐帧数 Boss 出手到命中的实际秒数,和 animTime 配置对表,误差超过 50 毫秒就要查帧间隔有没有被攻速以外的逻辑再除一刀。我们当时抓到一个漏网之鱼——技能加速 Buff 把 Boss 也算了进去,改成"玩家 Buff 不影响怪物"后误差归零。改完动画时长,所有引用 animTime 的旁路逻辑都要重查一遍,这条验收动作写进了我们的 Boss 制作 checklist 第三条。

这单的通用心法:想改怪物的"性格",改动画表;想改怪物的"效率",改攻速。 两个旋钮拧对了地方,一天半能调出一个让全服讨论两周的 Boss。拧错了地方,两天工时换一屏纹丝不动的代码和一肚子疑惑。

那次改造还牵出一个 Y 排序的实战细节,一并记下。Boss 体型大,站立格的 y 和爪子的视觉前端差出两格,按站立点排序时,Boss 的爪子会"插进"身前玩家身体里。引擎的解法是 GetAdditionZorder 这个附加层级——大体型怪配置一个正偏移,整体抬一层。这就是为什么那个看起来平平无奇的 zOrder 公式里要留一个 GetAdditionZorder 的加数:公式是通用的,体型是个性,个性通过附加数接入通用。你做骑乘坐骑、变身巨人时,同样只需要调这个加数,排序公式一个字不用碰。

五、常见疑问

问:怪物攻速堆到 2.0 会不会像玩家那样有上限问题?
怪物没有玩家那套反作弊顾虑,但动画帧率有视觉下限:0.8 秒的动画压到 0.4 秒,每帧 50 毫秒还能看清,压到 0.27 秒就是残影。经验上限 2.0,再高宁可换快动作的动画资源,别硬压。

问:SetIsArrived(false) 在攻击里是什么意思?
借用了移动状态机的"到达"概念当"播完"标志。攻击倒计时走完才置 true,Tick 里据此决定是否上报完成。两套状态共用一套到达语义,代码省了,读的时候要反应一下这里的"到达"是时间上的到达。

问:SetAction 的 isTerminate 参数什么时候用?
被打断时传 true:新状态直接换,不跟旧状态商量。普通切换走 ChangeState 协商(同状态拒绝重复进入,看 MonsterIdle 的 ChangeState 就知道),打断是硬切换。协商与打断两条路,对应"自然衔接"和"受力中断"两种游戏语言。

问:怪物攻击的命中帧怎么定?
怪物的命中判定在服务端按攻击间隔结算,客户端动画只负责"对得上":命中帧大致落在挥砍中段,视觉就自洽。追求精确的服会在动画表里配帧事件,播到某帧时发攻击表现包,和玩家攻击的帧事件同机制。别用客户端帧号反推伤害时机,那是服务端的领地——这个原则从飞行道具篇贯穿到现在,没变过。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组