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

走一格为什么是半秒?拆开996引擎走路状态机

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

接手二开的兄弟几乎都撞过同一堵墙:玩家嫌走路"一格一格像机器人",老板拍板要"丝滑移动",改服务端速度?不对。改客户端动画帧?也不对。今天把 996 引擎走路这套状态机整个拆开,你看完就明白:走路不是一个动画,是一条带着"服务器签收"机制的流水线,动任何一个环节之前都得知道整条线怎么转的。

一、一切从 0.5 秒说起

actor/gameActorStateMoveBase.lua 是所有移动状态的爹,玩家走路、怪物走路都从它继承。里面有个看着不起眼的函数:

lua
-- 一格的行走时长,走路基类默认 0.5 秒
function gameActorStateMoveBase:GetMoveTime(actor)
    return 0.5
end

0.5 秒一格,这就是传奇式移动的手感基准。跑步状态继承之后覆写成 0.4,坐骑再快一档。为什么把"一格多久"做成可以覆写的函数而不是常量?因为引擎作者很清楚:速度是手感,手感必须按状态微调,但插值算法一套就够。 你做二开想要"疾跑鞋",覆写这个函数返回 0.35 就行,别去动 Tick。

再看进状态那一刻做了什么:

lua
function gameActorStateMoveBase:OnEnter(actor)
    if actor.mMoveTo and actor.mMoveTo.x then
        self:moveToNextGrid(actor)          -- 算好起点、方向向量、距离
        actor.mIsArrived   = false          -- 还没走到格心
        actor:SetConfirmed(false)           -- 还没拿到服务器签收
        actor.mCurrentActT = self:GetMoveTime(actor)   -- 倒计时 0.5 秒
        actor.mMoveTime    = self:GetMoveTime(actor)
    else
        actor:SetAction(global.MMO.ACTION_IDLE)        -- 没目标就直接待机
    end
end

两个标志位,mIsArrived 管"我这条腿迈完没有",mIsConfirmed 管"服务器认不认这一步"。记住这对搭档,后面整套机制都围着它转。

二、mir_tick 与 smooth_tick:引擎留了两条路

Tick 里有个分岔:

lua
function gameActorStateMoveBase:Tick(dt, actor)
    if G_ActorUtils.IsSmoothMove(actor) then
        self:smooth_tick(dt, actor)    -- 连续位移,丝滑
    else
        self:mir_tick(dt, actor)       -- 网格步进,经典传奇手感
    end
end

mir_tick 是老传奇的骨头:虽然一格内部也在插值,但位置会被对齐到像素网格上。插值公式本身很朴素——percent = 已走时间 / 总时长,位移 = 起点 + 方向向量 × percent × 总距离,中学数学。真正值钱的是插值完的这段"补丁":

lua
-- PC影子闪:直角方向时把坐标对齐到偶数像素
if actor:GetDirection() % 2 == 0 then
    moveT.x = mFloor(moveT.x)
    moveT.y = mFloor(moveT.y)
    moveT.x = (moveT.x % 2 == 0) and moveT.x or moveT.x - 1
    moveT.y = (moveT.y % 2 == 0) and moveT.y or moveT.y - 1
else
    moveT.x = mFloor(moveT.x)
    moveT.y = mFloor(moveT.y)
end
actor:setPosition(moveT.x, moveT.y)

这段注释写着"PC影子闪",是修一个很邪门的渲染 bug:角色上下左右直行时,影子(贴在脚底的椭圆)会一闪一闪。根因是序列帧锚点在小数像素上的采样抖动,取整到偶数像素就稳了。为什么只治直角方向?因为斜向八方向贴图本身就带半个格的偏移,取整反而会抖。这种"按方向分类打补丁"的写法看着土,但它是无数台真机试出来的,你二开动了取整逻辑,影子闪就会回来找你。真想全局丝滑,走 smooth_tick 分支,别在 mir_tick 里拆补丁。

mermaid
flowchart LR
A[点击目标格] --> B[OnEnter: moveToNextGrid 算方向]
B --> C[ACTION_WALK 0.5s 插值]
C --> D{mIsArrived 到格心?}
D -->|是| E[等服务器 mIsConfirmed]
E -->|确认| F[handleActionCompleted]
E -->|5秒超时| F
F --> G{还有下一格?}
G -->|是| C
G -->|否| H[ACTION_IDLE]

三、确认机制:为什么你的角色走两步会"顿"一下

这是本篇最值钱的一段。mir_tick 结尾有这两行:

lua
-- 走到了、也被服务器确认了,才允许上报"动作完成"
if actor.mActionHandler and actor.mIsArrived and actor.mIsConfirmed then
    actor.mActionHandler:handleActionCompleted(actor, self:GetStateID())
end

-- 5秒都没等到确认,强制放行,防卡死
if actor.mCurrentActT <= -5.0 then
    actor:SetConfirmed(true)
end

翻译成人话:客户端走完一格,不许立刻迈下一格,必须等服务端说"这步我记上了"。为什么要这么设计?防加速。如果客户端自顾自连走,改内存的加速外挂就能一秒走八格,服务器事后追也追不上;有了确认机制,外挂本地跑得再快,确认包没回来就是干等,速度锁得死死的。

那行 <= -5.0 是保底:倒计时已经归零还欠 5 秒还没等到确认,说明网络出问题了,强制放行别把玩家卡成一尊雕像。注意代价——这 5 秒里外挂理论上又能白嫖几格,引擎权衡后宁可放行也不卡死正常玩家。你以后做"卡顿优化",别一上来就怀疑这段,先把网络层的重传摸清楚。

很多玩家抱怨的"走路顿一下",其实就是确认包晚到了几十毫秒。测延迟的办法很简单:把 GetMoveTime 临时改成 2.0 走两步,顿挫感会被放大到肉眼可见,你就能确认问题出在确认链路而不是渲染。

下面这个演示把整套状态机搬到了浏览器:点地图任意格,角色逐格走过去,状态栏实时显示 WALK 百分比、到达后"等服务端确认"的 0.25 秒模拟延迟;勾选平滑模式对比两种 tick 的手感差别,再拖 GetMoveTime 感受 0.2 秒和 1 秒的世界——顺带一提,0.2 秒时确认等待的占比会高得吓人,这就是为什么提速不能只改一个数字:

demo
fx-actor-walk-grid-1003a

四、方向从哪来:moveToNextGrid 的隐藏工作量

每迈一格前,moveToNextGrid 要干五件事:算世界坐标起点终点、归一化方向向量、换算贴图八方向、登记格子坐标、通知其他系统。挑重点说:

lua
-- 为动画算朝向:把地图坐标差交给工具函数折算成 0~7
local mapX, mapY = global.sceneManager:WorldPos2MapPos(actor:getPositionX(), actor:getPositionY())
local lastOri    = actor.mOrient
actor.mOrient    = G_ActorUtils.CalcActorDirByPos(mapX, mapY, gridPoint.x, gridPoint.y, lastOri)

-- 朝向变了才标记动画脏、刷新Buff特效朝向
if lastOri ~= actor.mOrient then
    actor:DirtyAnimFlag()
    global.BuffManager:UpdateBuffSfxDir(actorID)
    global.ActorEffectManager:UpdateEffectDir(actor, actorID)
end

最后一行是精髓:朝向没变,一切免谈;朝向变了,动画帧缓存作废、Buff 特效转向、挂点特效转向,三个系统一起动。 引擎用 lastOri ~= mOrient 这一个判断把 90% 的无效刷新挡在门外。你想想满屏一百个怪同时巡逻,如果每帧都无脑刷新特效朝向,那是两百多次无意义的节点操作。二开时往这套链路里加自己的系统(比如 footwear 印脚印),也要挂在 lastOri ~= mOrient 的判断里,别独立开每帧逻辑。

五、实战案例:一次"跑步提速"引发的连锁事故

去年有个服要做"轻功"卖点,把跑步的 GetMoveTime 从 0.4 直接改成 0.2,上线三天出了三个 bug,逐个说。

第一个,穿人。格子 A 到 B 的 0.4 秒里碰撞检测按格算,0.2 秒时服务端的移动包频率没跟上,客户端本地预走,两个人在格心重叠的瞬间才被服务器拽回来,表现为"闪现穿模"。改法是同步把服务端的移动校验窗口从 0.45 秒收到 0.25 秒——客户端提速必须跟服务端的确认窗口成对改,单边改就是埋雷。

第二个,动画跟不上。序列帧行走动画总共 4 帧循环,0.4 秒一格时每帧 100 毫秒,看着正好;0.2 秒时每帧只剩 50 毫秒,腿都快糊成风火轮了,玩家说"像在溜冰"。最后美术补画了 2 帧,帧数撑起来速度感才对。

第三个最阴,影子闪复发。有人顺手把 mir_tick 的偶数对齐删了,说"都 2026 年了还要这个"。结果玩家群里三天内七个人发截图,全是上下直走时脚下影子抽搐。老补丁治的是序列帧采样这种渲染层的物理事实,跟引擎版本无关,删之前先想想它当年为什么存在。

这一单最后赔进去九个工作日,账我给大家算明白:改速度本身十分钟,改服务端校验窗口半天,美术补帧三天,修影子闪复发连带回归测试四天半。所谓"改个数字的事",在状态机这种全服共用的核心链路上,永远是牵一发动全身。正确的做法是一开始就把轻功设计成独立状态继承跑步基类,只覆写时长,主链路一行不动——那单生意的教训我记到现在:热点路径上的改动,工时永远按十倍报。

五点五、位移曲线里藏着的合批智慧

再抠一个细节。mir_tick 里插值不是每帧都算的,外面套了一层 global.actorManager:IsMoveTime() 的闸门:

lua
if not actor.mIsArrived then
    if global.actorManager:IsMoveTime() then   -- 不是每帧都位移,按移动节拍算
        local percent = (actor.mMoveTime - actor.mCurrentActT) / actor.mMoveTime
        local disp = percent * actor.mDistanceToTarget
        moveT.x = actor.mOriginPositionX + actor.mMoveOrientX * disp
        moveT.y = actor.mOriginPositionY + actor.mMoveOrientY * disp
        ...

同屏一百个怪在走路,如果每个都每帧重算三角函数和除法,CPU 就在干重复劳动。引擎把"移动节拍"收拢到 actorManager 统一发放,节拍到了才允许所有在走的角色算一次位移,一次算完一批。这就是所谓的合批思维:算什么不重要,什么时候一起算才重要。 你二开加同类逻辑(比如一百个飞行道具、一百个飘字),照方抓药,先想能不能挂进现成的节拍器,别自己起炉灶每帧扫。

顺带说坐标显示。moveT 算完还要过一遍 mFloor 落进整数像素,然后才交给 setPosition。2D 序列帧引擎在整数像素上渲染是硬约定,半个像素就是一次双线性采样的开销,乘上同屏精灵数就是肉眼可见的帧率差。有些从 unity 转过来的朋友习惯把坐标全用 float 传,到这类引擎里要扭过来。

五点八、走丢的格子:mMoveTo 从哪来

整套状态机的入口是 actor.mMoveTo,这东西谁塞进来的?两层来源。玩家这边,点击地面后寻路模块把路径拆成一个一个格子,排队喂给 mMoveTo,走完一格喂下一格;怪物那边,AI 决策层直接指定相邻格,怪没有寻路大表,因为巡逻半径就三格,用不着 A 星。

这个设计给二开留了干净的插槽:你想做"点击收集沿途道具",不用碰状态机,在喂格子的那一层做文章就行——每走完一格检查脚下有没有可拾取物,有就插一个"拾取动作"再继续走。状态机只认"喂我哪格我走哪格",上层怎么编排它一概不知。分层清楚的系统,二开永远有缝可钻;分层混乱的系统,改一行崩三处。 判断一个引擎值不值得深学,就看他给你留没留这种缝。

最后提个醒:走格子喂得再欢,也得看玩家视野里有没有人看着。视野管理器把"离开屏幕的怪"冻结掉,ActorOutOfView 通知一发,状态机直接挂起,连 Tick 都省了。所以你在后台日志看到"某怪状态消失"先别慌,那不是 bug,是玩家走远了引擎在替你省性能。调试移动问题务必用小号站在怪边上测,站在地图另一头看日志,看到的全是"冻结"。

六、常见疑问

问:smooth_tick 和 mir_tick 能给玩家自己选吗?
能,IsSmoothMove 就是按角色查的开关。但建议默认经典模式:丝滑模式在低配机上位移插值和渲染帧率不同步,会有轻微"漂移感",老玩家反而觉得不跟手。

问:5 秒超时期间玩家能打断吗?
能,新输入进来会直接 ChangeState 打断等待,超时兜底只管"没有新输入也不能永久卡死"的情况。两条退出路径互不干扰。

问:怪物也走这套吗?怪物也要服务器确认?
走,怪物的 Walk 状态继承同一个基类。但怪物数量大,确认包做了合批——同一帧内同屏怪的确认打包下发,所以你看怪走路偶尔有小碎步,玩家的永远一步一顿都清晰,这是流量和手感的取舍。

问:走路途中被冰冻定住,状态机怎么退场?
Buff 系统会发通知把当前状态打断,OnExit 里 mCurrentActT 清零,位置留在插值到的那个点上。注意不是回弹到上一格格心——回弹会视觉跳变,停在半路更自然,下一格从当前位置重新算向量。引擎在"物理正确"和"看着舒服"之间,永远选后者,这是表现层的觉悟。

问:我做了个新职业,想加翻滚位移,继承这套吗?
继承 gameActorStateMoveBase,覆写 GetStateID 返回新动作 ID、GetMoveTime 返回 0.25 左右,再把翻滚的序列帧按动作码铺进图集。框架送你的是确认机制、方向换算、节拍合批三件套,你只管填自己的时长和动画。千万别另起炉灶写"平行状态机",那套坑别人 2019 年就替你踩完了:坐标不同步、朝向不刷新、确认丢失,三件套一个都跑不掉。

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

作者履历与出处

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

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

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

LATEST ARTICLES

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

进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

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

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

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

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

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

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

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

背包一卡卡三年:GUIQuickCell懒加载列表救了你

做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…

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

i2、C32、i8s都是什么鬼?996引擎网络协议类型系统速查

新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…

2026-10-03 05:40 996 技术组