【不同系统的运动表现】
游戏内涉及到运动表现的常见系统为:运动系统、技能系统、打击系统
实现一个强大的运动系统是十分困难的,一个强大的运动系统包括以下方面:
- 基础移动:走:包括慢走、走、跑、疾跑、冲刺等;跳:基础跳跃、二段跳跃、冲刺跳跃等;蹲:下蹲、下蹲走、翻滚
- 地形运动:走跳蹲的地形移动、滑动、攀爬(爬树、爬墙等)、跨越、翻越、飞行、游泳等,可以通过射线检测、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();
//获取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;
}
}