最近两天想做一个人形机甲的游戏,由于本人又是一个拟真军事爱好者,不太喜欢机动特别高的,所以打算参考坦克类游戏来制作一个脚!踏!实!地!的机甲游戏
这个运动系统基本实现了逻辑和动画的分离,同时也是使用状态机的方式来进行运动的控制
案例分为三大部分
Unity机甲
文章目录
- 前言
- 第一部分:状态机部分
- 一、图
- 二、基础代码
- State
- StateMachine
- PlayerState
- PlayerStateMachine
- 三、上层状态代码
- PlayerGroundState
- PlayerIdleState
- PlayerMoveState
- PlayerJumpState
- PlayerFallingState
- 四、使用状态机
- 第二部分:预制动画部分
- AnimatorController
- PlayerAnimator.cs
- 第三部分:输入驱动部分
- PlayerController
- 额外章节
前言
我的代码是在官方的HDRP3DSample里提供的PlayerMovement
和CameraController
的基础上改的,但基本就是复用了里面对移动速度的计算和光标输入的接受和隐藏这些
鉴于UE仍然是我接触代码前最常使用的游戏引擎,因此我在地图原点放了一个空物体,充当PlayerController
,我的这个PlayerController
只负责接收玩家的鼠标输入,并使用其修改自身的旋转。该PlayerController
还是一个单例类,对外提供了获取自身的Quaternion和Transform的方法
我还在PlayerController
中制作了一个列表,编辑器界面可以对其添加物体,添加的物体有以下字段
- 跟随
PlayerController
旋转的Transform - Transform的父级(null则会寻找直接父级)
- 是否同步水平/垂直旋转
- 是否限制水平/垂直旋转
- 水平/垂直的限制范围(-180 ~ 180)
- 水平/垂直的旋转速率(度每秒)
这样包括机甲的炮塔,炮管,玩家的摄像机都可以通过将其添加到这个列表来解决旋转的问题
这一块也花费了我大半天的时间才调试完成
详细信息放在第三部分进行阐述
第一部分:状态机部分
一、图
目前运动比较简单,只提供Idle,Walk,Jump,Falling四个状态,之后新的状态也可以直接扩展,比较方便
在Idle和Move上再抽象出一层GroundState,可以方便的处理二者共同的转换条件
二、基础代码
先看一下状态机的基础部分,即State和StateMachine
State
我在State中创建委托来防止在Enter和Exit中书写过多其他逻辑,实现相关代码的解耦
其他的就是普通的State的所需函数Enter,Exit,Update
值得注意的是该类不需要继承自Monobehavior
,这也意味着我们可以使用构造函数来为类赋初值
public class State
{
public StateMachine stateMachine;
public UnityAction onStateEnter;
public UnityAction onStateExit;
public UnityAction<bool> onStateChanged; //进入true,离开false
public State(StateMachine stateMachine)
{
this.stateMachine=stateMachine;
}
public virtual void Enter()
{
onStateEnter?.Invoke();
onStateChanged?.Invoke(true);
}
public virtual void Exit()
{
onStateExit?.Invoke();
onStateChanged?.Invoke(false);
}
public virtual void Update()
{
}
}
StateMachine
为了保证通用性,我在普通的StateMachine中只提供了最低限度的函数和字段,即一个记录当前状态,一个初始化函数,一个切换状态的函数和一个调用状态的Update的函数
值得注意的是该类也不需要继承自Monobehavior,因为这个类将作为其他类的字段出现,依附于其他类的生命周期函数即可,无需自身也继承。
public class StateMachine
{
public State curState;
public virtual void Init(State state)
{
curState = state;
curState.Enter();
}
public virtual void Update()
{
curState.Update();
}
public virtual void ChangeState(State newState)
{
curState.Exit();
curState = newState;
curState.Enter();
}
}
PlayerState
在PlayerState中,为了后续类继承使用的方便,我分别新增了一个玩家字段和一个更确定的PlayerStateMachine
字段
因为显而易见,PlayerState
肯定是依赖于PlayerStateMachine
,因此将原本State中的StateMachine
字段覆盖为PlayerStateMachine
是完全合理的。
public class PlayerState : State
{
public PlayerMovement player;
public new PlayerStateMachine stateMachine;
public PlayerState(PlayerMovement player, PlayerStateMachine stateMachine) : base(stateMachine)
{
this.player = player;
this.stateMachine = stateMachine;
}
}
PlayerStateMachine
因为为了外部更方便的调用,并且实现区分运动状态的目标,我在PlayerStateMachine
中存储了所有状态,并使用枚举值对其进行区分,外界可直接使用枚举进行获取。
虽然理论上可以在初始化时直接实例化所有状态,但我还是选择了提供一个AddPlayerState
来让外界去访问并传入对应的对象,原因如下:
- 状态类特殊字段: 某些派生的状态可能希望获取一些特殊的值,这些值在外部构造其时可以提供给其,在StateMachine内部构造则不一定能获取到这些值
- 多态: 外部可根据自身情况传入不同的状态子类对象,从而实现更多不同的功能,充分利用多态的特点
public class PlayerStateMachine : StateMachine
{
public enum EState
{
Idle,
Move,
Jump,
Falling
}
private Dictionary<EState, PlayerState> states = new Dictionary<EState, PlayerState>();
public virtual void Init(EState type, PlayerState state)
{
AddPlayerState(type, state);
base.Init(state);
}
public override void Update()
{
base.Update();
}
public virtual void ChangeState(EState type)
{
base.ChangeState(GetPlayerState(type));
}
public State GetPlayerState(EState type)
{
return states[type];
}
public void AddPlayerState(EState type, PlayerState state)
{
if(!states.ContainsKey(type))
{
states.Add(type, state);
}
}
}
三、上层状态代码
接下来就是实际的状态代码了!
PlayerGroundState
为了保证低耦合的设计,我在player中创建了一系列委托来提供给State进行订阅
由上面的图可以很显然的知道,Ground状态最主要就是和Jump,FallingState进行转换
- 玩家输入Jump指令就转换为Jump状态
- 玩家脚不着地就转为Falling状态
public class PlayerGroundState : PlayerState
{
public PlayerGroundState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
{
}
public override void Enter()
{
base.Enter();
base.player.onJump += OnJump;
}
public override void Exit()
{
base.Exit();
player.onJump -= OnJump;
}
public override void Update()
{
base.Update();
if (!player.isGrounded)
{
stateMachine.ChangeState(PlayerStateMachine.EState.Falling);
}
}
private void OnJump()
{
stateMachine.ChangeState(PlayerStateMachine.EState.Jump);
}
}
PlayerIdleState
继承自Ground状态,主要负责转向Move状态,绑定玩家输入,一旦输入移动就转到移动状态
public class PlayerIdleState : PlayerGroundState
{
public PlayerIdleState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
{
}
public override void Enter()
{
base.Enter();
base.player.onMove += OnMove;
}
public override void Exit()
{
base.Exit();
player.onMove -= OnMove;
}
private void OnMove(Vector2 move)
{
if(move.magnitude > 0)
{
stateMachine.ChangeState(PlayerStateMachine.EState.Move);
return;
}
}
}
PlayerMoveState
该Move状态主要负责移动和转为Idle状态
注意该移动是相对于玩家控制器的移动
public class PlayerMoveState : PlayerGroundState
{
private CharacterController controller;
private float speed;
public PlayerMoveState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
{
this.controller = player.controller;
this.speed = player.speed;
}
public override void Enter()
{
base.Enter();
player.onMove += OnMove;
}
public override void Exit()
{
base.Exit();
player.onMove -= OnMove;
}
private void OnMove(Vector2 input)
{
//规格化的水平移动方向
Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
move.y = 0;
move.Normalize();
//该速度乘数意味着玩家朝向与输入方向差异越大,则速度乘数越小
float speedMultipler = (Vector3.Dot(new Vector3(move.x, 0, move.z), player.transform.forward) + 1) / 2;
controller.Move(move * speed * speedMultipler * Time.deltaTime);
if (input.magnitude < 0.01)
stateMachine.ChangeState(PlayerStateMachine.EState.Idle);
}
}
PlayerJumpState
因为机甲蓄力一下再起跳会比较真实,因此引入了起跳准备这一概念,体现在该State中就是使用一个Timer来延迟起跳的时间
public class PlayerJumpState : PlayerState
{
private CharacterController controller;
private float speed;
private float airSpeedMultipler;
private float jumpHeight;
private float jumpReadyTime;
private float jumpReadyTimer;
public PlayerJumpState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
{
controller = player.controller;
speed = player.speed;
jumpHeight = player.jumpHeight;
jumpReadyTime = player.jumpReadyTime;
}
public override void Enter()
{
base.Enter();
jumpReadyTimer = jumpReadyTime;
}
public override void Update()
{
base.Update();
jumpReadyTimer -= Time.deltaTime;
if(jumpReadyTimer < 0 )
{
//起跳速度计算
player.velocity.y = Mathf.Sqrt(jumpHeight * -2f * player.gravity) + player.gravity * Time.deltaTime;
controller.Move(player.velocity * Time.deltaTime);
stateMachine.ChangeState(PlayerStateMachine.EState.Falling);
}
}
}
PlayerFallingState
这个状态实际上也是我决定把一个PlayerMovement类扩展为一整个状态机的原因,因为分辨不了在空中究竟是玩家掉下去的还是起跳悬空的
同时使用一个airSpeedMultipler
来修改玩家在空中的灵活度
public class PlayerFallingState : PlayerState
{
private CharacterController controller;
private float speed;
private float airSpeedMultipler;
public PlayerFallingState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
{
controller = player.controller;
speed = player.speed;
airSpeedMultipler = player.airSpeedMultipler;
}
public override void Enter()
{
base.Enter();
player.onMove += OnMove;
}
public override void Exit()
{
base.Exit();
player.onMove -= OnMove;
}
public override void Update()
{
base.Update();
if (player.isGrounded)
stateMachine.ChangeState(PlayerStateMachine.EState.Idle);
}
private void OnMove(Vector2 input)
{
Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
move.y = 0;
move.Normalize();
controller.Move(move * speed * airSpeedMultipler * Time.deltaTime);
}
}
四、使用状态机
状态机的代码书写完毕,接下来是使用状态机。将移动的代码移动到状态里后,原本的PlayerMovement就只需要去处理重力即可,因为无论什么时候都会存在重力
同时该类也提供所有外部可以自定义的角色属性
public class PlayerMovement : MonoBehaviour
{
public CharacterController controller;
[Header("Movement")]
public float speed = 12f;
public float gravity = -10f;
public float jumpHeight = 2f;
public float jumpReadyTime = 0.5f;
public float airSpeedMultipler = 0.5f;
public float turnSpeed = 4;
[Header("Collision")]
public Transform groundCheck;
public Vector3 groundCheckRange;
public LayerMask groundMask;
//self state
[HideInInspector] public Vector3 velocity;
private Vector3 lastPosition;
public bool isGrounded { get; private set; }
//public action
public UnityAction<Vector2> onMove;
public UnityAction onJump;
//input
InputAction movement;
InputAction jump;
//All State
public PlayerStateMachine stateMachine { get; private set;} = new PlayerStateMachine();
private void Awake()
{
stateMachine.Init(PlayerStateMachine.EState.Idle, new PlayerIdleState(this, stateMachine));
stateMachine.AddPlayerState(PlayerStateMachine.EState.Move, new PlayerMoveState(this, stateMachine));
stateMachine.AddPlayerState(PlayerStateMachine.EState.Jump, new PlayerJumpState(this, stateMachine));
stateMachine.AddPlayerState(PlayerStateMachine.EState.Falling, new PlayerFallingState(this, stateMachine));
lastPosition = transform.position;
}
void Start()
{
movement = new InputAction("PlayerMovement");
movement.AddCompositeBinding("Dpad")
.With("Up", "<Keyboard>/w")
.With("Up", "<Keyboard>/upArrow")
.With("Down", "<Keyboard>/s")
.With("Down", "<Keyboard>/downArrow")
.With("Left", "<Keyboard>/a")
.With("Left", "<Keyboard>/leftArrow")
.With("Right", "<Keyboard>/d")
.With("Right", "<Keyboard>/rightArrow");
jump = new InputAction("PlayerJump");
jump.AddBinding("<Keyboard>/space");
movement.Enable();
jump.Enable();
movement.performed += (InputAction.CallbackContext context) =>
jump.performed += (InputAction.CallbackContext context) => onJump?.Invoke();
}
// Update is called once per frame
void Update()
{
stateMachine.Update();
HandleGravity();
HandleMove();
}
void HandleGravity()
{
isGrounded = Physics.CheckBox(groundCheck.position, groundCheckRange, Quaternion.identity, groundMask);
if (isGrounded && velocity.y < 0)
{
velocity.y = -2f;
}
velocity.y += gravity * Time.deltaTime;
controller.Move(velocity * Time.deltaTime);
velocity.x = transform.position.x - lastPosition.x;
velocity.z = transform.position.z - lastPosition.z;
lastPosition = transform.position;
}
//这个并非处理移动的,而是处理自身旋转朝向运动方向的
void HandleMove()
{
Vector2 input = movement.ReadValue<Vector2>();
onMove?.Invoke(input);
Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
move.y = 0;
move.Normalize();
if (move.magnitude > 0.1)
{
Quaternion q = Quaternion.LookRotation(move);
transform.rotation = Quaternion.Slerp(transform.rotation, q, turnSpeed * Time.deltaTime);
}
}
}
第二部分:预制动画部分
预制动画部分也是就三个部分
- 行走动画
- 起步,行走循环
- 起跳,悬空循环,落地
AnimatorController
接下来是动画状态机,实际上可能也是最难的地方,因为太容易结成蜘蛛网了()
然一共就四个状态,但还是加了两个额外的动画作为过渡,分别是Falling_End和Walk_Start
PlayerAnimator.cs
动画Animator的参数修改是专门建一个类进行管理,这也是解耦的地方
玩家的速度用于行走动画的速度更改,写在Update中
public class PlayerAnimator : MonoBehaviour
{
private Animator animator;
private PlayerMovement player;
public float velocityToAnimationMultiper = 2.5f;
public float minAnimSpeed = 0.5f;
public float maxAnimSpeed = 10f;
private void Awake()
{
animator = GetComponent<Animator>();
player = GetComponentInParent<PlayerMovement>();
}
// Start is called before the first frame update
void Start()
{
//状态变量绑定
player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Idle).onStateChanged += (enter) => animator.SetBool("IsIdle", enter);
player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Move).onStateChanged += (enter) => animator.SetBool("IsMove", enter);
player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Jump).onStateChanged += (enter) => animator.SetBool("IsJump", enter);
player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Falling).onStateChanged += (enter) => animator.SetBool("IsFalling", enter);
}
private void Update()
{
Vector2 velocity = new Vector2(player.velocity.x, player.velocity.z);
animator.SetFloat("WalkSpeedMultipler", Mathf.Clamp(velocity.magnitude * velocityToAnimationMultiper, minAnimSpeed, maxAnimSpeed));
}
}
第三部分:输入驱动部分
为了区分玩家的朝向和摄像机的朝向,并且希望能够限制跟随摄像机旋转的物体的速率和角度
我借鉴UE的做法,在地图上放置了一个空物体用于记录玩家的Controller旋转,和玩家操控的Actor旋转做个区分
鉴于当前只有一个玩家,因此使用单例方便进行获取控制器的旋转和变换
以下是控制器内部代码
PlayerController
public class PlayerController : MonoBehaviour
{
[System.Serializable]
public class Limit
{
public float min = -90; //限制最小角度
public float max = 90; //限制最大角度
}
[System.Serializable]
public class SyncObject
{
public Transform syncTransform; //需要同步旋转的组件
public Transform parentTransform; //该组件的父级(不赋值就寻找直接父级),有的可能希望计算限制角时和与自己同级或没关系的物体进行计算,因此专门使用一个字段进行赋值
//对外界提供控制暂停,该项可以忽略暂停
//比如使用自由查看,即其他物体暂停跟随移动,摄像机可以继续环绕玩家观察,就可以把摄像机勾上这个选项
public bool ignorePause = false;
[Header("Horizontal")]
public bool SyncY = true; //是否同步
public float RateY = 3600; //旋转速率
public bool LimitY = false; //是否限制
public Limit LimitYRange; //限制范围
//垂直与水平同理
[Header("Vertical")]
public bool SyncX = true;
public float RateX = 3600;
public bool LimitX = false;
public Limit LimitXRange;
}
private static PlayerController m_instance;
float RotX = 0, RotY = 0;
private bool paused = false;
public List<SyncObject> syncControllerRotationObjects;
//单例常规操作
private void Awake()
{
if(m_instance)
{
Destroy(gameObject);
return;
}
m_instance = this;
}
//从原本Demo的CameraController里粘贴过来的
private void Update()
{
bool unlockPressed = false, lockPressed = false;
float mouseX = 0, mouseY = 0;
//捕捉输入
if (Mouse.current != null)
{
var delta = Mouse.current.delta.ReadValue() / 15.0f;
mouseX += delta.x;
mouseY += delta.y;
lockPressed = Mouse.current.leftButton.wasPressedThisFrame ||
Mouse.current.rightButton.wasPressedThisFrame;
}
if (Keyboard.current!= null)
{
unlockPressed = Keyboard.current.escapeKey.wasPressedThisFrame;
}
//按照输入进行光标设置
if (unlockPressed)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
if (lockPressed)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
if (Cursor.lockState == CursorLockMode.Locked)
{
//控制器旋转
RotX -= mouseY;
RotY += mouseX;
//限制控制器的俯仰角
RotX = RotX > 180 ? RotX - 360 : RotX;
RotX = Mathf.Clamp(RotX, -89, 89);
transform.rotation = Quaternion.Euler(RotX, RotY, 0f);
//遍历需要跟踪控制器旋转的物体,对其进行旋转赋值
foreach (var obj in syncControllerRotationObjects)
{
//对跟踪的物体进行修改旋转,下面细说这一块
}
}
}
//设置控制器旋转 float float
public static void SetControllerRotation(float rotX, float rotY)
{
m_instance.RotX = rotX;
m_instance.RotY = rotY;
m_instance.transform.rotation = Quaternion.Euler(rotX, rotY, 0f);
}
//设置控制器旋转 Quaternion
public static void SetControllerRotation(Quaternion quaternion)
{
SetControllerRotation(quaternion.eulerAngles.x, quaternion.eulerAngles.y);
}
//获取旋转和Transform
public static Quaternion GetControllerRotation() => m_instance.transform.rotation;
public static Transform GetControllerTransform() => m_instance.transform;
//设置暂停
public static void SetPause(bool paused) => m_instance.paused = paused;
}
最重量级的部分就是上述遍历每一个需要跟踪的物体的部分,下面单独贴出来
//遍历需要跟踪控制器旋转的物体,对其进行旋转赋值
foreach (var obj in syncControllerRotationObjects)
{
if (paused && !obj.ignorePause)
continue;
//控制器原始角度 0 ~ 360
float x = transform.rotation.eulerAngles.x;
float y = transform.rotation.eulerAngles.y;
//旋转物体和其父级的角度(名为dir而已)
Vector3 dir = obj.syncTransform.eulerAngles;
Vector3 parentDir = obj.parentTransform ? obj.parentTransform.eulerAngles : obj.syncTransform.parent.eulerAngles;
//控制器角度(映射到了-180 ~ 180)
float conDirX = x > 180 ? x - 360 : x;
float conDirY = y > 180 ? y - 360 : y;
//当前的旋转角度(映射到了-180 ~ 180)
float dirX = dir.x > 180 ? dir.x - 360 : dir.x;
float dirY = dir.y > 180 ? dir.y - 360 : dir.y;
//父级的旋转角度(映射到了-180 ~ 180)
float parentDirX = parentDir.x > 180 ? parentDir.x - 360 : parentDir.x;
float parentDirY = parentDir.y > 180 ? parentDir.y - 360 : parentDir.y;
//当前和父级的相对角度(用于计算限制角度)
float angleX = parentDirX - dirX;
float angleY = dirY - parentDirY;
//用于线性平滑旋转
//控制器的旋转角度(偏移一下,以dir为起点,方便计算到目标所需角度)
float targetAngleX = (x - dir.x + 360) % 360;
float targetAngleY = (y - dir.y + 360) % 360;
//接着将0~360映射为 -180 ~ 180,此为在以当前朝向为原点下,控制器与原点的相对角度差
targetAngleX = targetAngleX > 180 ? targetAngleX - 360 : targetAngleX;
targetAngleY = targetAngleY > 180 ? targetAngleY - 360 : targetAngleY;
if (obj.SyncX)
{
Vector2 rotation = obj.syncTransform.rotation.eulerAngles;
//如果当前角度差绝对值小于旋转速率,直接转到目标角度
if (Mathf.Abs(targetAngleX) <= obj.RateY * Time.deltaTime)
{
rotation.x = x;
angleX = parentDirX - conDirX;
}
//目标在当前物体朝向右边,增大自身角度
else if (targetAngleX > 0)
{
rotation.x += obj.RateX * Time.deltaTime;
angleX -= obj.RateX * Time.deltaTime;
}
//目标在当前物体朝向左边,减小自身角度
else
{
rotation.x -= obj.RateX * Time.deltaTime;
angleX += obj.RateX * Time.deltaTime;
}
//角度限制判断,angleX已经是变换后的角度了
if((obj.SyncX && angleX >= obj.LimitXRange.min && angleX <= obj.LimitXRange.max) || !obj.LimitX)
{
obj.syncTransform.rotation = Quaternion.Euler(rotation);
}
}
//该轴与上面的计算同理
if(obj.SyncY)
{
Vector2 rotation = obj.syncTransform.rotation.eulerAngles;
if (Mathf.Abs(targetAngleY) <= obj.RateY * Time.deltaTime)
{
rotation.y = y;
angleY = parentDirY - conDirY;
}
else if (targetAngleY > 0)
{
rotation.y += obj.RateY * Time.deltaTime;
angleY -= obj.RateY * Time.deltaTime;
}
else
{
rotation.y -= obj.RateY * Time.deltaTime;
angleY += obj.RateY * Time.deltaTime;
}
if(!obj.LimitY || (angleY >= obj.LimitYRange.min && angleY <= obj.LimitYRange.max))
{
obj.syncTransform.rotation = Quaternion.Euler(rotation);
}
}
}
除此之外就是在编辑器中的实际应用,下面贴几个
额外章节
这一块讲一下关于相机的问题,我使用的是Cinemachine
我希望通过Cinemachine来创建一个过肩动画,有碰撞,有跟随延迟
最开始采用Freelook + CameraCollider + CameraOffset的方式,结果发现CameraCollider 会默认在摄像机原点检测碰撞,而CameraOffset会偏移出这个碰撞体,不得已只能去寻找下一个解决办法
最后我采用的是和Unity官方的第三人称一样的解决办法,使用VirtualCamera组件下的3rdPersonFollow,通过在玩家身上增加一个空物体作为跟随点,再加上3rdPersonFollow自带的参数微调即可达到较为满意的效果
并且可以方便的切换左右肩,后续似乎也可以和瞄准,第一人称进行很好的配合