
1. 为什么我要在Godot里死磕ALS复刻第一次在虚幻引擎里跑起ALSAdvanced Locomotion System的时候我盯着那个角色从走路切到小跑、再切到冲刺脚掌落地的节奏跟速度完全对得上转身的时候上半身和下半身还能分开转当时就觉得这套东西做得是真讲究。后来因为项目原因转到Godot第一件事就是想找一套对标的 locomotion 方案翻了一圈发现要么是简单的八向移动要么是状态机写得比较糙能做到ALS那种“动画驱动位移”的几乎没有。这就是这个开源项目的起点把ALS那套运动逻辑在Godot里从头实现一遍做到行为层面的100%复刻。注意我说的是行为复刻不是把虚幻的蓝图一行行翻译成GDScript——引擎底层不一样硬翻译只会得到一堆跑不起来的代码。我要的是同样的输入角色做出同样的反应动画切换的时机、根运动的应用方式、速度分级的阈值全部对齐。这套东西适合谁如果你正在用Godot做第三人称动作游戏、射击游戏或者任何需要角色“跑起来像个人”的项目那这套逻辑你直接拿去改就能用。如果你只是想学Godot的动画状态机和根运动怎么配合这里面的拆解也够你啃一阵子。前置要求不高会写GDScript、看得懂AnimationTree的基本节点就行但想完全吃透最好对状态机和向量运算有点概念。项目已经开源代码结构我尽量做得扁平注释写得比较密就是为了方便你对着改。下面我按“为什么这么设计—核心细节怎么落地—完整实操流程—踩过的坑”这个顺序把整套东西掰开讲。2. 整体架构设计与方案选型思路2.1 为什么不用现成的CharacterBody3D模板硬改Godot自带的CharacterBody3D模板给的是一个最基础的移动框架读输入、算方向、move_and_slide。很多人做locomotion就是在这个基础上加个AnimationTree然后根据速度切动画。这么做能跑但做不出ALS的味道问题出在驱动方向反了。传统做法是“位移驱动动画”我先算出角色这一帧移动了多少然后根据速度大小去选一个动画播放。ALS的思路是反过来的叫“动画驱动位移”动画本身携带根运动root motion角色的实际位移由动画的根运动决定速度分级只是决定“当前该播哪一段动画”。这么做的最大好处是脚底不打滑——因为位移和脚步是同一套数据出来的天然同步。所以架构上我做了两个关键决策。第一位移完全交给根运动代码里不再手动给velocity赋值做水平移动只保留重力。第二速度分级用动画曲线驱动而不是用if-else硬切。具体来说我用一个BlendSpace1D把idle、walk、run、sprint四段动画按速度混合混合的参数不是直接的速度值而是经过归一化处理的“速度比例”。2.2 状态机的分层设计ALS原版的状态机分了好几层我这边简化成三层够用且好维护。最底层是Locomotion状态层管的是地面移动idle、walk、run、sprint这几个基础状态以及它们之间的过渡。这一层用AnimationNodeStateMachine实现每个状态是一个BlendSpace1D。中间层是Action状态层管的是跳跃、落地、翻滚这类会打断locomotion的动作。这一层我用AnimationNodeStateMachine嵌套在Locomotion层之上通过travel()切换。关键是过渡条件要写清楚比如跳跃只能在grounded状态下触发落地动画播完必须回到locomotion。最上层是Overlay层管的是上半身和下半身的分离。比如角色在跑动的时候上半身转向瞄准方向下半身还在往前跑。这一层用AnimationNodeBlend做骨骼遮罩把上半身和下半身的动画分开控制。三层之间通过信号通信不直接互相引用这样改一层不会把另外两层搞崩。我试过把三层揉成一个巨大的状态机改到后面自己都看不懂连线了分层是血泪教训。2.3 根运动与代码位移的取舍这里有个坑必须提前说Godot的根运动默认是关的你得在AnimationTree上把root_motion_track指向根骨骼的位移轨道然后在AnimationPlayer里确保动画本身带了位移数据。很多免费动画资源是不带根运动的你得自己用Blender或者动捕数据补上或者退而求其次用代码模拟根运动。我的方案是混合制优先用根运动如果检测到当前动画没有根运动数据自动降级到代码位移。降级的判断逻辑写在AnimationTree的animation_finished信号回调里检查根运动轨道是否为空。这样即使你手头的动画资源不齐项目也能跑起来只是脚底可能会有点滑等补齐根运动就好了。3. 核心细节拆解与实操要点3.1 速度分级与BlendSpace1D的参数设置ALS的速度分级大概是idle0、walk1.5 m/s、run3.5 m/s、sprint6 m/s。这个数值不是拍脑袋定的是根据角色模型的实际步幅和动画播放速度反推出来的。具体怎么算假设你的walk动画一个循环是1秒角色在这个循环里根运动前进了1.5米那walk对应的速度就是1.5 m/s。run和sprint同理。在BlendSpace1D里我把这四个动画按上面的速度值摆在数轴上。但混合参数不能直接用速度因为速度是无上限的而BlendSpace1D的混合范围是0到1。所以我加了一个归一化步骤func _get_blend_position(speed: float) - float: var max_speed : sprint_speed # 6.0 return clamp(speed / max_speed, 0.0, 1.0)这样速度6 m/s对应混合位置1.0速度3 m/s对应0.5落在run和walk之间BlendSpace1D会自动做插值混合。实测下来这样混合出来的过渡比硬切自然得多尤其是从run减速到walk的时候不会出现动画突然跳一下的情况。注意BlendSpace1D的混合模式要选Interpolated不要选Discrete否则就是硬切了。另外每个动画的循环模式要设成Loop不然播完会停住。3.2 根运动的提取与重定向根运动的数据在动画里是存在根骨骼的位移轨道上的。Godot的AnimationTree有个root_motion_track属性你把它指向根骨骼的position轨道引擎就会在每帧把根运动的位移和旋转提取出来你可以通过get_root_motion_position()和get_root_motion_rotation()拿到。但这里有个细节根运动提取出来的位移是相对于角色本地坐标系的你得把它转换到世界坐标系才能用。转换的代码大概是这样var root_motion : animation_tree.get_root_motion_position() var world_motion : global_transform.basis * root_motion velocity.x world_motion.x / delta velocity.z world_motion.z / delta注意除以delta因为根运动给的是这一帧的位移量要转成速度才能喂给move_and_slide。我一开始忘了除delta角色跑得跟蜗牛一样排查了半天才发现是单位问题。还有一个坑根运动的旋转。如果你的动画里角色有转身根运动也会带旋转。这时候你不能直接把旋转应用到角色上否则会和你的输入转向打架。我的做法是只取根运动的位移旋转交给代码处理这样转向手感更可控。3.3 上下半身分离的骨骼遮罩配置上下半身分离是ALS的一个标志性特性。实现方式是在AnimationTree里加一个AnimationNodeBlend节点把两个动画源混合混合权重由骨骼遮罩控制。具体配置步骤在AnimationTree里新建一个AnimationNodeBlend给它两个输入端口。第一个端口接下半身的locomotion动画第二个端口接上半身的动作动画比如瞄准、射击。然后给这个Blend节点设置blend_amount0是全下半身1是全上半身。关键是骨骼遮罩。你需要在Skeleton3D节点上创建一个Skin把上半身的骨骼脊椎以上标记出来然后在AnimationNodeBlend的blend_point里引用这个Skin。这样混合的时候只有上半身的骨骼会受第二个端口影响下半身保持第一个端口的动画。我试过用AnimationNodeBlend2做这个效果不如AnimationNodeBlend精细因为Blend2是整个动画混合没法按骨骼遮罩。所以如果你要做上下半身分离老老实实用AnimationNodeBlend。提示骨骼遮罩的创建在Skeleton3D的Inspector里点“Create Skin”然后勾选上半身骨骼。别忘了给下半身也创建一个反向的Skin有些混合场景需要用到。3.4 状态过渡的条件与优先级状态过渡最容易出问题的地方是过渡条件冲突。比如角色正在从run减速到walk同时玩家按了跳跃这时候是该先播减速过渡还是直接切跳跃我的处理原则是Action层优先级高于Locomotion层但Action层不能无条件打断Locomotion得看当前Locomotion状态是否允许被打断。具体来说我给每个Locomotion状态加了一个can_be_interrupted标志。idle和walk可以随时被打断run和sprint在过渡过程中比如run到walk的减速阶段不允许被打断得等过渡完成。这个标志通过状态机的transition_request信号来检查代码大概是这样func _on_transition_request(from_state: String, to_state: String) - void: if from_state in [run, sprint] and is_transitioning: return # 拒绝打断 state_machine.travel(to_state)这样处理之后跳跃的手感明显好了很多不会出现按了跳跃角色还在滑行的情况。4. 完整实操流程与关键环节实现4.1 项目结构与节点树搭建先把节点树搭起来这是后面所有逻辑的骨架。我的节点树是这样的Player (CharacterBody3D) ├── CollisionShape3D ├── Skeleton3D │ └── (骨骼层级) ├── AnimationPlayer ├── AnimationTree └── CameraRig (Node3D) └── SpringArm3D └── Camera3DAnimationTree的tree_root是一个AnimationNodeStateMachine里面嵌套了前面说的三层结构。AnimationPlayer里放所有动画资源每个动画的根骨骼位移轨道要确保存在。搭建顺序建议先放CharacterBody3D和CollisionShape3D确保角色能站在地面上再加Skeleton3D和AnimationPlayer导入动画最后加AnimationTree配置状态机。这个顺序别乱不然调试的时候你会分不清是碰撞问题还是动画问题。4.2 输入映射与移动向量计算输入映射在项目设置里配我用了四个动作move_forward、move_back、move_left、move_right分别绑到WASD。另外加了jump绑空格sprint绑Shift。移动向量的计算要考虑相机方向因为第三人称游戏里“前”是相对于相机的。代码func _get_movement_input() - Vector2: var input : Vector2.ZERO input.x Input.get_axis(move_left, move_right) input.y Input.get_axis(move_forward, move_back) return input.limit_length(1.0) func _get_movement_direction(input: Vector2) - Vector3: var camera_basis : camera_rig.global_transform.basis var forward : -camera_basis.z var right : camera_basis.x forward.y 0 right.y 0 forward forward.normalized() right right.normalized() return (forward * input.y right * input.x).normalized()注意limit_length(1.0)这是为了防止斜向移动比直线移动快。很多新手会漏掉这一步导致角色斜着走速度是直线的1.4倍。4.3 速度状态更新与动画参数同步每帧的更新逻辑分三步读输入算目标速度、根据当前速度决定状态、把速度喂给AnimationTree。func _physics_process(delta: float) - void: var input : _get_movement_input() var direction : _get_movement_direction(input) # 目标速度 var target_speed : 0.0 if input.length() 0.1: target_speed sprint_speed if Input.is_action_pressed(sprint) else run_speed # 平滑加速 current_speed move_toward(current_speed, target_speed, acceleration * delta) # 更新动画参数 animation_tree.set(parameters/locomotion/blend_position, _get_blend_position(current_speed)) animation_tree.set(parameters/locomotion/direction, direction) # 应用根运动 _apply_root_motion(delta) # 重力 if not is_on_floor(): velocity.y - gravity * delta else: velocity.y 0 move_and_slide()这里current_speed是标量只表示速度大小方向由direction单独控制。这样做的原因是BlendSpace1D只需要速度大小来混合动画方向用来控制角色的朝向。4.4 根运动的应用与位移同步根运动的应用是整套逻辑里最需要小心的地方。前面说了根运动给的是本地坐标系的位移要转到世界坐标系。但还有一个问题根运动的位移是动画播放出来的而动画的播放速度受AnimationTree的process_callback影响。如果process_callback设成Physics根运动就和物理帧同步如果设成Idle就和渲染帧同步。我建议设成Physics这样位移和碰撞检测在同一帧里处理不会出现穿墙。func _apply_root_motion(delta: float) - void: var root_motion : animation_tree.get_root_motion_position() if root_motion.length_squared() 0.0001: # 没有根运动数据降级到代码位移 var fallback : _get_movement_direction(_get_movement_input()) * current_speed velocity.x fallback.x velocity.z fallback.z return var world_motion : global_transform.basis * root_motion velocity.x world_motion.x / delta velocity.z world_motion.z / delta降级逻辑很重要因为不是所有动画都有根运动。我手头有几段免费动画就没有降级之后至少角色能动只是脚底会滑等换了带根运动的动画就好了。4.5 跳跃与落地的状态切换跳跃的实现分三段起跳、空中、落地。起跳时切到jump_start动画空中切到jump_loop落地切到land。关键是过渡时机。func _on_jump() - void: if not is_on_floor(): return velocity.y jump_velocity state_machine.travel(jump_start) jump_started.emit() func _on_land() - void: state_machine.travel(land) land_timer.start(land_duration)land动画播完之后要回到locomotion这个用AnimationTree的animation_finished信号处理。注意land动画的时长要和land_timer对齐不然会出现动画还没播完就切回locomotion的情况看起来会很突兀。注意跳跃的jump_velocity要根据重力算公式是v sqrt(2 * g * h)h是你想要的跳跃高度。比如重力20想跳1.5米高v就是sqrt(60)≈7.75。这个数值我调了好几次才找到手感合适的。5. 常见问题与排查技巧实录5.1 角色脚底打滑怎么排查脚底打滑是根运动项目最常见的问题原因通常有三个。第一动画本身没有根运动数据降级到代码位移之后速度对不上。排查方法在_apply_root_motion里打印root_motion.length()如果是0就是没数据。第二根运动的速度和动画播放速度不匹配。比如动画是1倍速播放但你代码里把current_speed设成了动画速度的1.5倍那肯定滑。排查方法对比current_speed和根运动算出来的速度两者应该接近。第三AnimationTree的process_callback设成了Idle导致根运动和物理帧不同步。改成Physics试试。我遇到过一次特别隐蔽的打滑动画的根骨骼位移轨道上有关键帧但关键帧的插值模式是Constant导致根运动是阶梯状的角色一卡一卡的。把插值改成Linear就好了。这个坑排查了一下午因为从数据上看根运动是有的但就是不平滑。5.2 动画过渡生硬的调整方法过渡生硬通常是BlendSpace1D的混合曲线没调好。Godot的BlendSpace1D默认是线性混合但实际动画的过渡往往需要非线性。你可以在BlendSpace1D的每个动画点上右键调整blend_point的min和max值控制混合的起止范围。另一个原因是过渡时间太短。AnimationNodeStateMachine的每个过渡都有一个xfade_time参数默认是0也就是硬切。把它设成0.2到0.3秒过渡会自然很多。但也不能太长超过0.5秒会感觉角色反应迟钝。我个人的经验值idle到walk用0.3秒walk到run用0.25秒run到sprint用0.2秒。速度越快过渡时间越短因为高速运动下玩家对延迟更敏感。5.3 上下半身分离时骨骼错位骨骼错位一般是遮罩没配对。检查两个地方第一AnimationNodeBlend的blend_point引用的Skin是否正确上半身的Skin要包含脊椎以上的所有骨骼包括手臂和头。第二两个输入动画的骨骼命名要一致如果一个是Mixamo的命名一个是自己绑的骨骼对不上就会错位。还有一个容易忽略的点AnimationNodeBlend的blend_amount在0和1之间切换时中间状态是混合的如果两个动画的骨骼姿势差异太大中间状态会很奇怪。解决办法是加一个过渡动画或者把blend_amount的变化速度调快让混合过程尽量短。5.4 常见问题速查表问题现象可能原因排查方法解决方案角色脚底打滑根运动缺失或速度不匹配打印根运动长度和当前速度补齐根运动或调整速度参数动画过渡生硬过渡时间太短或混合曲线不对检查xfade_time和blend_point调整过渡时间到0.2-0.3秒上下半身错位骨骼遮罩配置错误检查Skin的骨骼包含范围重新创建Skin并核对骨骼命名跳跃后不落地落地检测条件不对检查is_on_floor()的调用时机在physics_process里检测斜向移动过快输入向量没归一化检查limit_length(1.0)加上归一化处理根运动方向反了坐标系转换错误打印world_motion的值检查global_transform.basis的乘法顺序5.5 性能优化的几个实操技巧AnimationTree的更新是有开销的尤其是骨骼多、动画多的时候。我做了三个优化。第一把AnimationTree的active属性在角色远离相机时设成false近处再打开。第二BlendSpace1D里的动画数量控制在4个以内多了混合计算量会上去。第三骨骼遮罩的Skin不要包含不必要的骨骼只勾选真正需要分离的部分。还有一个技巧把AnimationPlayer的playback_process_mode设成Physics和AnimationTree的process_callback保持一致这样动画更新和物理更新在同一帧不会出现一帧的延迟。这个延迟在高速运动下很明显角色会看起来像在“追”自己的碰撞体。6. 开源代码结构与二次开发建议6.1 代码目录说明项目开源在GitHub上目录结构是这样的/addons/als_locomotion/ ├── scripts/ │ ├── locomotion_state_machine.gd │ ├── root_motion_applier.gd │ └── input_handler.gd ├── resources/ │ ├── blend_spaces/ │ └── state_machines/ └── scenes/ └── player.tscnlocomotion_state_machine.gd是核心管状态切换和过渡条件。root_motion_applier.gd管根运动的提取和应用。input_handler.gd管输入映射和向量计算。三个脚本通过信号通信耦合度很低你可以单独替换其中一个而不影响另外两个。6.2 二次开发的扩展点如果你想在这套基础上加东西我预留了几个扩展点。第一locomotion_state_machine.gd里有一个custom_transition_check虚函数你可以重写它来加自己的过渡条件比如体力值不够时不能sprint。第二root_motion_applier.gd里有一个motion_modifier回调你可以在根运动应用之前修改位移比如加一个击退效果。第三input_handler.gd里的输入映射是数据驱动的你可以在资源文件里加新的输入动作不用改代码。我试过用这套框架做一个带攀爬的角色扩展点在custom_transition_check里加了“靠近墙壁且按攀爬键”的条件然后在状态机里加了一个climb状态半天就搞定了。框架的扩展性还是可以的。6.3 动画资源的替换与适配替换动画资源的时候最关键的是根运动轨道的命名。不同来源的动画根骨骼的名字可能不一样有的是Root有的是Hips有的是Armature。你需要在AnimationTree的root_motion_track里指向正确的骨骼。如果动画没有根运动就用前面说的降级方案。另外动画的帧率也要注意。如果原动画是30帧你的项目是60帧根运动的速度会不对。解决办法是在导入动画的时候把帧率统一或者在代码里根据帧率做补偿。我一般是在Blender里导出的时候就统一成60帧省得后面麻烦。7. 我在实际复刻过程中踩过的坑第一个坑是根运动和代码位移的冲突。一开始我没做降级判断结果有些动画有根运动、有些没有有根运动的时候角色正常跑没根运动的时候角色原地踏步。后来加了降级判断才解决。这个坑的教训是不要假设所有动画资源都是完整的一定要做容错。第二个坑是状态机的过渡条件写得太死。我一开始把过渡条件写成“速度大于3.5就切run”结果角色在3.5附近反复横跳动画一直在run和walk之间闪。后来加了迟滞区间速度大于3.5切run小于3.2才切回walk问题就没了。迟滞区间的大小大概是0.3 m/s太小了还是会闪太大了反应迟钝。第三个坑是骨骼遮罩的坐标系问题。我一开始以为骨骼遮罩是按世界坐标系的Y轴来分的结果发现是按骨骼层级的。上半身的骨骼要从脊椎开始往上选不能只看Y坐标。这个坑让我调了一整天的遮罩最后是看了Godot的文档才搞明白。第四个坑是AnimationTree的process_callback。默认是Idle我一开始没改结果根运动和物理帧不同步角色在斜坡上会抖。改成Physics之后就好了。这个坑比较隐蔽因为从表现上看只是“有点抖”不容易联想到是帧同步问题。第五个坑是输入向量的归一化。前面提过斜向移动会快1.4倍。我一开始没注意直到测试的时候发现角色斜着跑比直着跑快才回头加上limit_length(1.0)。这个坑很基础但确实容易忘。这套东西我断断续续做了大概三周中间推翻重来了两次。第一次是用代码位移驱动动画做出来脚底滑得没法看。第二次是状态机揉成一团改到后面自己都理不清。第三次才定下现在这个架构根运动驱动、三层状态机、骨骼遮罩分离。现在跑起来的效果跟ALS原版对比行为层面基本一致动画过渡的时机和速度分级的阈值都对齐了。代码已经开源你可以直接clone下来跑。如果你的动画资源跟我的不一样可能需要调一下速度阈值和过渡时间但整体逻辑不用动。后面我打算把攀爬和游泳也加进去扩展点已经留好了有兴趣的话可以一起搞。