技能系统详解(4)——运动表现

发布于:2025-07-23 ⋅ 阅读:(14) ⋅ 点赞:(0)

【不同系统的运动表现】

游戏内涉及到运动表现的常见系统为:运动系统、技能系统、打击系统

实现一个强大的运动系统是十分困难的,一个强大的运动系统包括以下方面:

  1. 基础移动:走:包括慢走、走、跑、疾跑、冲刺等;跳:基础跳跃、二段跳跃、冲刺跳跃等;蹲:下蹲、下蹲走、翻滚
  2. 地形运动:走跳蹲的地形移动、滑动、攀爬(爬树、爬墙等)、跨越、翻越、飞行、游泳等,可以通过射线检测、Trigger触发、体素等方式使角色知晓附近地形信息

打击系统的运动表现通常如下,其可以被定义成不同的表现类型:

  1. 击退、飞、倒、落、升
  2. 震(弹)退、飞、倒、落、升
  3. 各类其他的等

通常为了追求符合游戏打击感的运动表现,角色的运动不会全用物理控制,而是通过一系列参数控制,这些参数包括以下几类:

  1. 打击时的位置、朝向、力度、方向
  2. 打击表现类型及每个类型所需的参数,例如击退是多少米,多长时间;击飞是飞多高、多久降落
  3. 运动轨迹及参数
  4. 动画肢体控制,一般来说特定打击会提供一些被打击的动画,不同方向的打击也有不同的动画,但为了更细节的表现,动画系统需要提供一些参数供修改实现特定的动画混合

严格来说,打击系统属于交互系统的一部分,交互包括:

  • 角色与物体的交互(拾取、推开、握住、抓住、攀绕、踢开、破坏等等)
  • 角色与角色的交互(主要是打击、少量握手、拥抱等)
  • 物体与物体的交互(子弹、机关等)

游戏中绝大部分交互都属角色与角色之间的打击,因此也简称为打击系统

技能系统的运动通常有以下情况:

  1. 动画控制:复杂大招的动作表现通常都是播动画,角色的运动由RootMotion控制
  2. 细节调整:对角色运动的距离和角度,会做一定程度上的调整控制。例如,可以在释放技能时调整角色朝向让技能朝着敌人释放;技能连招会朝着敌人运动;推着敌人运动的技能会控制调整移动距离
  3. 接口调用:调用已有运动系统的接口,调整位置和角度,例如向前突进的位移技能,调用运动系统提供的改变位置接口即可;向后跳跃闪避的技能,调用跳跃接口即可。
  4. 参数设置:技能释放可能修改了运动系统中某些参数配置,例如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)和代码控制。

动画控制的优点为:

  1. 动作和位移完美匹配,适合翻滚等复杂动作
  2. 物理表现更加真实,动画中的加速度、减速、弧线运动等细节会自然体现在位移中,无需手动模拟物理效果
  3. 更适合复杂轨迹,例如非如蛇形走位、弧形冲锋可以通过动画直接实现

缺点为:

  1. 调试困难,动画位移的精度依赖美术制作的动画,如果动画位移不准确(如位移距离过长/过短),需要反复修改动画资源
  2. 灵活性低,位移完全由动画数据决定,无法在运行时动态调整方向或速度
  3. 网络同步问题,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();
            //获取ActorMove
            var 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);//添加Layer
            if (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即为clipId
            AnimationSystem.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);//设置地形layer
            if (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);//添加layer
            return count == 0;
        }
    }


网站公告

今日签到

点亮在社区的每一天
去签到