技能系统详解(4)——运动表现
【不同系统的运动表现】
游戏内涉及到运动表现的常见系统为:运动系统、技能系统、打击系统
实现一个强大的运动系统是十分困难的,一个强大的运动系统包括以下方面:
- 基础移动:走:包括慢走、走、跑、疾跑、冲刺等;跳:基础跳跃、二段跳跃、冲刺跳跃等;蹲:下蹲、下蹲走、翻滚
- 地形运动:走跳蹲的地形移动、滑动、攀爬(爬树、爬墙等)、跨越、翻越、飞行、游泳等,可以通过射线检测、Trigger触发、体素等方式使角色知晓附近地形信息
打击系统的运动表现通常如下,其可以被定义成不同的表现类型:
- 击退、飞、倒、落、升
- 震(弹)退、飞、倒、落、升
- 各类其他的等
通常为了追求符合游戏打击感的运动表现,角色的运动不会全用物理控制,而是通过一系列参数控制,这些参数包括以下几类:
- 打击时的位置、朝向、力度、方向
- 打击表现类型及每个类型所需的参数,例如击退是多少米,多长时间;击飞是飞多高、多久降落
- 运动轨迹及参数
- 动画肢体控制,一般来说特定打击会提供一些被打击的动画,不同方向的打击也有不同的动画,但为了更细节的表现,动画系统需要提供一些参数供修改实现特定的动画混合
严格来说,打击系统属于交互系统的一部分,交互包括:
- 角色与物体的交互(拾取、推开、握住、抓住、攀绕、踢开、破坏等等)
- 角色与角色的交互(主要是打击、少量握手、拥抱等)
- 物体与物体的交互(子弹、机关等)
游戏中绝大部分交互都属角色与角色之间的打击,因此也简称为打击系统
技能系统的运动通常有以下情况:
- 动画控制:复杂大招的动作表现通常都是播动画,角色的运动由RootMotion控制
- 细节调整:对角色运动的距离和角度,会做一定程度上的调整控制。例如,可以在释放技能时调整角色朝向让技能朝着敌人释放;技能连招会朝着敌人运动;推着敌人运动的技能会控制调整移动距离
- 接口调用:调用已有运动系统的接口,调整位置和角度,例如向前突进的位移技能,调用运动系统提供的改变位置接口即可;向后跳跃闪避的技能,调用跳跃接口即可。
- 参数设置:技能释放可能修改了运动系统中某些参数配置,例如speed、stepover、stepoffset等
因此运动系统中需要提供一个Hook接口,用于技能和打击等系统对角色运动做细节调整
【动作简介】
在动作游戏中,一个攻击动作通常是由多个动画片段组成的,最常见的是前摇、攻击、后摇三个阶段
有些攻击动作有蓄力,需要再加上一个蓄力阶段,构成前摇、蓄力、攻击、后摇四个阶段
还可以做更复杂的阶段划分,预备、蓄力、攻击、击打、硬直、收招
通常,在不存在打断的情况下,一个攻击动作需要执行到每个动画片段
游戏内的动画系统通常是基于FSM来实现的,动画的切换实质是状态转移,而对于固定的攻击动作组成来说,为减少上层的主动切换逻辑复杂性,FSM需要实现自动转移。
因此,技能触发动作实际触发的是前摇动画,即调用动画系统的接口播前摇动画,每个技能都有自己的前摇动画。
(更准确的说,游戏内实现的动画系统是动画控制器,Unity内实现了动画播放器,两者构成较为完整的动画系统)
【表现触发】
在技能驱动动作中,释放技能时的各类表现通常是由动画Timeline触发的,类似于技能中有个Tick循环触发。
在动作驱动技能中,是播放到特定动画帧时触发的,类似于Unity自身的Animation上添加Event
(在FSM中,会对当前状态做Update,基于Unity的Playable实现动画系统时,可以自定义Event,触发各类动画表现)
我们增加一个DriveType,动作驱动技能时,从AnimationSystem中调用到Skill.Input();技能驱动动作时,在Skill做Tick执行
增加一个ActionComponent用于动作触发,该切换动作需要在Start中就开始执行。
【技能位移】
角色的技能通常带有位移,需要修改角色位置,可以通过动画控制(即apply RootMotion)和代码控制。
动画控制的优点为:
- 动作和位移完美匹配,适合翻滚等复杂动作
- 物理表现更加真实,动画中的加速度、减速、弧线运动等细节会自然体现在位移中,无需手动模拟物理效果
- 更适合复杂轨迹,例如非如蛇形走位、弧形冲锋可以通过动画直接实现
缺点为:
- 调试困难,动画位移的精度依赖美术制作的动画,如果动画位移不准确(如位移距离过长/过短),需要反复修改动画资源
- 灵活性低,位移完全由动画数据决定,无法在运行时动态调整方向或速度
- 网络同步问题,Root Motion的位移可能因动画帧率差异导致不同客户端的位置不一致
代码控制的优点为:高度灵活、调试方便、网络同步友好
缺点为:物理表现不真实、动作和位移不匹配、复杂轨迹实现难度高
通常会将两种方式结合起来
在Unity内,可以通过OnAnimatorMove()做运动控制。
如果Animator勾选使用了apply root motion,且在OnAnimatorMove()中有做代码控制,那么角色Transform会被代码控制。
如果在代码中调用了Animator.ApplyBuiltinRootMotion(),那么会重新转为动画控制。
【位移技能】
分为瞬间位移技能和持续位移技能
瞬间位移技能是位置突变,例如闪现、短距离冲刺,需要直接修改角色位置,同时要注意碰撞检测,防止卡墙。
此外,需要在角色移动时修改移动的方向,一般有如下几种:
- 摇杆移动方向,常见于MOBA类游戏
- 当前相机朝向,常见于动作类游戏
- 敌人所在方向,进攻类的冲击简化玩家操作,直接指向敌人
- 远离敌人的方向
- 角色自身朝向
持续位移技能是位置渐变,例如冲锋、滑步,其需要持续的碰撞检测,遇到墙壁需要停止,类似于角色走跑等运动,可以调用运动系统接口
位移跟随:为了使玩家更容易攻击到敌人,可以在技能位移的基础上增加朝向敌人移动的位移偏移
【代码实现】
public class MoveConfig : ScriptableObject, IData{[LabelText("移动Id")]public int moveId;[LabelText("移动类型")]public MoveType moveType;[LabelText("移动方向")]public MoveDirection moveDirection;[LabelText("移动距离")]public float moveDis;[LabelText("检测修正")]public float deltaDis;}public enum MoveDirection{Camera,//相机朝向Joystick,//摇杆移动方向FolllowEnemy,//朝着敌人的方向FleeEnemy,//远离敌人的方向Self,//当前角色朝向}public enum MoveType{Instantaneous,Continuous,Compensatory,}public class MoveConfigDataSystem : ConfigDataSystem<MoveConfigDataSystem, EffectConfigData>{public override string dataPath => "Assets/ConfigData/MoveConfigData.asset";}public class MoveComponent : LogicComponent{private ActorMove actorMove;protected override void OnInit(){base.OnInit();configData = MoveConfigDataSystem.Instance.GetData(configDataId);}protected override void OnStart(){base.OnStart();//获取ActorMovevar skillLocalData = SKillLocalDataSystem.Instance.GetOrCreateData(skilllocalDataId);var entity = ActorManager.Instance.GetActorById(skillLocalData.playerId);actorMove = entity.actorMove;actorMove.AddMoveHooks("MoveComponent", 10, OnMoveTick);go = entity.actorGo;}protected override void OnEnd(){actorMove.RemoveHook("MoveComponent", 10);}protected override void OnDestroy(){base.OnDestroy();actorMove = null;}private void OnMoveTick(bool applyRootmotion, float deltaTime, Vector3 deltaPos, Quaternion deltaRot){var config = configData as MoveConfig;Vector3 forward = GetActorForward(config.moveDirection).normalized;if (config.moveType == MoveType.Instantaneous){//计算目标距离:Vector3 targetPos = go.transform.position + forward * config.moveDis;PhysicsUtils.Ray.origin = go.transform.position + Vector3.up * 1.2f;PhysicsUtils.Ray.direction = forward;//也可用胶囊体int hitCount = Physics.RaycastNonAlloc(PhysicsUtils.Ray, PhysicsUtils.RaycastHits, config.moveDis + config.deltaDis);if (hitCount > 0)//第一次检测:找到墙体前表面,可能撞墙{// 计算进入点(稍微向内部偏移,避免重复检测同一面墙)var enterPoint = PhysicsUtils.RaycastHits[0].point + forward * 0.1f;float remainingDistance = config.moveDis + config.deltaDis - PhysicsUtils.RaycastHits[0].distance;//剩余距离PhysicsUtils.Ray.origin = enterPoint; PhysicsUtils.Ray.direction = forward;hitCount = Physics.RaycastNonAlloc(PhysicsUtils.Ray, PhysicsUtils.RaycastHits, remainingDistance);if (hitCount > 0){// 计算墙体厚度float wallThickness = Vector3.Distance(enterPoint, PhysicsUtils.RaycastHits[0].point);// 判断剩余距离是否足够穿过墙体if (wallThickness <= remainingDistance){// 允许穿过,终点设为后表面外侧targetPos += config.deltaDis * forward;}else{// 墙体过厚,终点设为进入点targetPos = enterPoint - config.deltaDis * forward - Vector3.up * 1.2f;}}else{// 墙体过厚,射线没打到后表面targetPos = enterPoint - config.deltaDis * forward - Vector3.up * 1.2f;}}//闪现位置处如果是斜坡会卡地形,检测修正下位置targetPos = PhysicsUtils.GetSafeGroundPosition(targetPos, 2.2f, 0.2f);if (PhysicsUtils.IsPositionSafe(targetPos, 2.2f, 0.2f))//检测目标点周围的空间是否足够容纳角色{go.transform.position = targetPos;//直接修改角色位置,也可以做插值}else{//闪现失败或者做位置修正}//设置角色朝向:go.transform.forward = forward;//也可以平滑一下OnEnd();//移动结束}else if (config.moveType == MoveType.Continuous){actorMove.MoveActor(deltaPos);go.transform.rotation *= deltaRot;}else if (config.moveType == MoveType.Compensatory){actorMove.MoveActor(deltaPos + deltaPos.normalized * config.deltaDis);//每次移动额外增加小段位移go.transform.rotation *= deltaRot;}}private Vector3 GetActorForward(MoveDirection dir){Vector3 res = Vector3.zero;if (dir == MoveDirection.Camera){//获取相机朝向}else if (dir == MoveDirection.Joystick){//获取当前摇杆朝向}else if (dir == MoveDirection.FolllowEnemy){//获取当前敌人}else if (dir == MoveDirection.Self){res = go.transform.forward;}return res;}}public class MoveComponentSystem : ComponentSystem<MoveComponentSystem, MoveComponent> { }public class ActorMove : MonoBehaviour{public bool applyRootmotion;private Animator animator;private CharacterController characterController;private List<MoveHook> curHooks = new List<MoveHook>();private List<MoveHook> waitHooks = new List<MoveHook>();//因为要Tick循环,这里必须有wait的private bool onTick = false;public void Init(){animator = GetComponent<Animator>();characterController = GetComponent<CharacterController>();}public void AddMoveHooks(string name, int priority, Action<bool, float, Vector3, Quaternion> action){for (int i = 0; i < curHooks.Count; i++){if (curHooks[i].priority == priority){Debug.LogError($"{name}与{curHooks[i].name}有重复的优先级:priority = {priority}");return;}}MoveHook moveHook = new MoveHook(){name = name,priority = priority,action = action,add = true,};waitHooks.Add(moveHook);}public void RemoveHook(string name, int priority){int index = -1;for (int i = 0; i < curHooks.Count; i++){if (curHooks[i].priority == priority && curHooks[i].name == name){index = i;break;}}if (index > 0){var hook = curHooks[index];hook.add = false;waitHooks.Add(hook);}}public struct MoveHook{public string name;public int priority;public bool add;public Action<bool, float, Vector3, Quaternion> action;}public Vector3 MoveActor(Vector3 deltaPos){if (deltaPos == Vector3.zero){return Vector3.zero;}float radius = characterController.radius;float height = characterController.height;Vector3 center = transform.position + characterController.center;// 计算胶囊体上下端点Vector3 point1 = center + Vector3.up * (height * 0.5f - radius);Vector3 point2 = center - Vector3.up * (height * 0.5f - radius);int hitCount = Physics.CapsuleCastNonAlloc(point1, point2, radius, deltaPos.normalized, PhysicsUtils.RaycastHits);//添加Layerif (hitCount > 0)//移动前做碰撞检测{float safeDistance = PhysicsUtils.RaycastHits[0].distance - 0.01f;deltaPos = deltaPos.normalized * Mathf.Max(safeDistance, 0);}characterController.Move(deltaPos);return deltaPos;}private void OnAnimatorMove(){var deltaTime = Time.deltaTime;var deltaPos = animator.deltaPosition;var deltaRot = animator.deltaRotation;AnimatorMoveTick(deltaTime, deltaPos, deltaRot);}private void AnimatorMoveTick(float deltaTime, Vector3 deltaPos, Quaternion deltaRot){bool newItem = false;foreach (var item in waitHooks){if (item.add){curHooks.Add(item);newItem = true;}else{curHooks.Remove(item);}}waitHooks.Clear();if (newItem){curHooks.Sort((a, b) =>{return a.priority - b.priority;});}foreach (var item in curHooks){item.action?.Invoke(applyRootmotion, deltaTime, deltaPos, deltaRot);}}}public class AnimationSystem : Singleton<AnimationSystem>{private float curClipTime;public float GetClipTime(){return curClipTime;}//动画切换接口public bool CrossFade(int targetState, float time, float clipTime){return true;}public void Tick(float deltaTime){curClipTime += deltaTime;ExecuteCurStateEvent();}private void ExecuteCurStateEvent(){//如果这是技能动画,拿到该角色的技能组件,调用技能执行接口RoleSkillComponent skill = null;if (skill.driveType == DriveType.Action)skill.SkillInput();}}public class ActionConfig : ScriptableObject, IData { }public class ActionComponent : LogicComponent{protected override void OnStart(){//configDataId即为clipIdAnimationSystem.Instance.CrossFade(configDataId, 0, 0);}}public class ActionComponentSystem : ComponentSystem<ActionComponentSystem, ActionComponent> { }public class PhysicsUtils{public static Ray Ray = new Ray();public static RaycastHit[] RaycastHits = new RaycastHit[10];public static Collider[] Colliders = new Collider[10];public static Vector3 GetSafeGroundPosition(Vector3 startPos, float height, float radius)//在目标点垂直向下检测地面高度,确保角色站立在可行走平面上,防止斜坡{// 向下检测地面int hitCount = Physics.CapsuleCastNonAlloc(startPos + Vector3.up * height, startPos, radius, Vector3.down, RaycastHits, height);//设置地形layerif (hitCount > 0){// 计算角色底部到地面的距离float bottomToGround = RaycastHits[0].point.y - startPos.y;startPos += (bottomToGround + 0.1f) * Vector3.up;}else{// 无地面:禁止闪现(或启用坠落逻辑) }return startPos;}public static bool IsPositionSafe(Vector3 startPos, float height, float radius){radius = radius * 0.95f; // 略小于实际半径,避免临界穿透// 检测周围碰撞体int count = Physics.OverlapCapsuleNonAlloc(startPos, startPos + Vector3.up * height, radius, Colliders);//添加layerreturn count == 0;}}