如果你随机挑选几个游戏,每个游戏可能都有不同的艺术风格和机制,不同的故事,甚至根本没有故事,但有一点他们都有共同点:所有的游戏都需要读取和处理来自键盘、鼠标、游戏板、操纵杆、VR控制器等设备的输入。
在这篇文章中,我将向你展示如何使用新的输入系统包在Unity中建立一个第三人称控制器,以及由Unity Technologies的另一个强大的包Cinemachine驱动的跟拍相机。
我们的第三人称控制器将处理来自键盘、鼠标和标准游戏板的输入,因为Unity的新输入系统非常聪明,你很快就会看到,增加对另一个输入设备的支持不需要任何额外的代码。
除此之外,你将看到如何设置空闲、运行、跳跃和跌倒的动画,以及如何在它们之间平滑过渡。我们将把控制器的核心实现为一个状态机,重点放在简洁的结构和可扩展性上。

如果你以前从未听说过状态机或状态设计模式,不用担心,我将逐步解释一切。然而,我将假设你对C#和OOP概念有基本的了解,如继承和抽象类。
在这篇文章的最后,你将能够轻松地用你自己的状态来扩展我们的控制器,你将拥有一个设计模式,你会发现在许多不同的情况下都很有用。
说到设计模式,除了状态模式外,我们还将使用另一种模式,在游戏开发中非常常见,甚至是最常见的:观察者模式。
新旧Unity输入系统的对比
在我们开始建立我们的玩家控制器之前,让我们简单地谈谈新的和旧的Unity输入系统的区别。我不打算重复你可以在文档中读到的内容,而是强调主要的区别。
如果你以前一直在使用Unity,你可能已经知道如何使用旧的输入系统。当你想让一些代码在每一帧只在给定的键被按下时才被执行,你可以这样做:
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
// Code executed every frame when Space is pressed
}
}
你可以通过在 "项目设置">"输入管理器"中用名字来绑定键和轴,然后像这样写你的脚本,使它变得更好一点:
{
if (Input.GetKeyDown("Jump"))
{
// Code executed every frame when key bound to "Jump" is pressed
}
}
而当你想从轴上读取数值时,你可以这样做:
void Update()
{
float verticalAxis = Input.GetAxis("Vertical");
float horizontalAxis = Input.GetAxis("Horizontal");
// Do something with the values here
}
这很简单,对吗?新的输入系统有点复杂,但带来了很多好处。我想在本教程结束时,你会充分体会到它们。现在,我只举几个例子:
- 基于事件的API取代了
Update方法中的状态轮询,这带来了更好的性能 - 增加对新输入设备的支持不需要额外的编码,这很好,特别是对于跨平台的游戏来说。
- 新的输入系统配备了一套强大的调试工具
新的输入系统的要点在于输入设备和动作以及基于事件的API之间增加了一个抽象层。你创建一个输入动作资产,通过编辑器中的UI将输入与动作绑定,并让Unity为你生成API。
然后你写一个简单的类,实现IPlayerActions ,为玩家控制器提供输入事件和值,供其使用,这正是我们在这篇博文中要做的。
创建一个新的Unity项目
如果你想跟着做,我鼓励你这样做,我建议使用Unity 2021.3.6f1。创建一个新的空的3D项目,首先,进入窗口>包管理器。从包的 下拉列表中选择Unity Registry,在搜索栏中输入Cinemachine ,选择包,然后点击安装。然后对InputSystem 包做同样的操作。
在安装InputSystem ,Unity会提示你重新启动。之后,返回到软件包管理器窗口,选择软件包。在项目中,并确认这两个包都已安装:

你也可以删除其他软件包,除了你的IDE的代码集成支持。在我的例子中,它是Visual Studio Editor包。
设置输入系统
为了设置新的Unity输入系统,我们首先需要将输入与动作绑定。为此,我们需要一个.inputactions 资产。让我们在项目标签中点击右键,选择创建>输入动作来添加一个。
把它命名为Controls.inputactions ,然后双击这个新资产,打开绑定输入的窗口。
在右上角点击所有控制方案,从弹出的菜单中选择添加控制方案...,将新方案命名为Keyboard and Mouse ,并通过空列表底部的加号,添加键盘 和鼠标输入设备。

重复上一段的过程,但这次将新方案命名为Gamepad,同时将Gamepad添加到输入设备列表中。同时,从两个需求选项中选择可选 。
回到 "控制(输入动作)"窗口,在最左边一栏中点击标有 "动作图"的加号,并将新添加的记录命名为Player 。中间一列是我们现在要把输入与动作绑定的地方。
有一个动作已经被添加了,它被标记为New Action 。右键单击该动作,将其重命名为Jump ,然后用小三角图标将其展开,选择绑定<No Binding> ,在右栏的绑定属性中,点击路径旁边的下拉图标。
你可以在键盘部分找到Space,或者点击Listen按钮,直接按键盘上的空格键。回到绑定属性中,在路径下面,在控制方案中使用的键盘和鼠标下打勾。注意这些是我们之前添加的方案。

空格键现在被分配给了跳跃动作,但我们还想要另一个游戏手柄的绑定。在动作栏中,点击加号。选择添加绑定,在路径中,从游戏手柄部分设置Button South。这一次,在控制方案中的使用下,勾选游戏板。
让我们添加另一个动作,这次是为了移动。用动作标签旁边的加号,添加新的动作,并将其命名为Move 。保持选择Move 动作,在右边一栏,将动作类型改为值,控制类型改为矢量2。

第一个绑定槽,同样默认为<No Binding> ,已经被添加。让我们把它用在游戏手柄上,因为对于键盘,我们要添加一个不同类型的动作。在 "路径"中**,从游戏手柄**部分找到并指定 "左摇杆"。
现在,用移动动作旁边的加号,添加一个新的绑定,但这次选择添加上/下/左/右复合。你可以把新绑定的名称保留为一个二维矢量,重要的是为每个组件指定一个键。
指定W、S、A和D分别代表上、下、左和右,如果你喜欢的话,也可以指定方向键。不要忘了在控制方案中使用 键盘和鼠标下为它们各自打勾。
我们需要添加的最后一个动作是旋转摄像机,以便观察四周。让我们把这个动作命名为Look 。对于这个动作,也将动作类型设置为值,控制类型设置为矢量。对于键盘和鼠标控制方案,从鼠标部分绑定Delta,这是鼠标的X和Y位置从上一帧到当前帧的变化量,对于游戏手柄方案,绑定右键。

现在我们有了键盘和鼠标以及游戏板设置的所有输入绑定。在控制(输入动作)窗口,我们需要做的最后一件事是点击保存资产按钮。
注意,当我们保存资产时,Unity在Assets 文件夹中为我们生成了一个Controls.cs 文件,就在Controls.inputactions 的旁边。我们将需要在这个文件中生成的代码来构建我们的InputReader 类,我们将在下一节进行。
构建一个InputReader 类
到目前为止,我们只在Unity编辑器中工作。现在是时候写一些代码了。在你的资产中创建一个新的C#脚本,并将其命名为InputReader 。InputReader 类应该继承自MonoBehavior ,因为我们要把它作为一个组件附加到我们的Player 游戏对象上,以后我们会有一个。
除此以外,InputReader 将实现Controls.IPlayerActions 接口。这个接口是Unity为我们生成的,当我们在上一节结束时保存了Controls.inputactions 资产时。
因为我们创建了查看、移动和跳跃动作,这个接口定义了OnLook 、OnMove 和OnJump 方法,并有一个context 的参数InputAction.CallbackContext 。
你是否记得我写过新的Unity输入系统是基于事件的?这就是它。我们在我们的InputReader 类中定义了一个类型为Vector2 的成员MoveComposite ,我们像这样实现OnMove:
public void OnMove(InputAction.CallbackContext context)
{
MoveComposite = context.ReadValue<Vector2>();
}
每当我们为移动动作绑定的输入(W、S、A、D键和游戏板上的右键)被注册,这个OnMove 。从该生成的代码中的一个事件,以及从传递的context 参数中,我们就会读取输入值。
例如,当W键被按下时,从分配给我们的MoveComposite 的context 的值将是X轴上的0和Y轴上的1。当我们同时按下W和A时,在x轴上将是-1,在y轴上是1,而当我们释放按键时,两个轴上的值将是0。
OnLook 将以同样的方式实现,同样也是 方法,但有一点不同的是,我们将在 本身引发一个事件,而不是赋值。OnJump InputReader:
public void OnJump(InputAction.CallbackContext context)
{
if (!context.performed)
return;
OnJumpPerformed?.Invoke();
}
请注意,如果context.performed 是false ,我们将提前从该函数中返回。如果不这样做,OnJumpPerformed 事件将被调用两次:一次是当我们按下空格键,另一次是当我们释放它时。我们不希望在释放空格键后再次跳转。
我们还使用了一个空条件操作符(?.)来跳过invoke ,当没有处理程序注册在OnJumpPerformed 事件上时。在这种情况下,对象是null 。如果你喜欢在没有处理程序注册在OnJumpPerformed 上时抛出一个异常,那么就去掉这个操作符,只留下OnJumpPerformed.Invoke();
下面是InputReader.cs 文件的全部代码:
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class InputReader : MonoBehaviour, Controls.IPlayerActions
{
public Vector2 MouseDelta;
public Vector2 MoveComposite;
public Action OnJumpPerformed;
private Controls controls;
private void OnEnable()
{
if (controls != null)
return;
controls = new Controls();
controls.Player.SetCallbacks(this);
controls.Player.Enable();
}
public void OnDisable()
{
controls.Player.Disable();
}
public void OnLook(InputAction.CallbackContext context)
{
MouseDelta = context.ReadValue<Vector2>();
}
public void OnMove(InputAction.CallbackContext context)
{
MoveComposite = context.ReadValue<Vector2>();
}
public void OnJump(InputAction.CallbackContext context)
{
if (!context.performed)
return;
OnJumpPerformed?.Invoke();
}
}
OnEnable 和 方法被调用,当脚本作为一个组件附加在一个游戏对象上时,分别被启用和禁用。当我们运行我们的游戏时, 紧接着 被调用一次。更多信息请参见Unity文档中OnDisable OnEnable Awake事件函数的执行顺序。
这里重要的是创建一个Control 类的实例,Unity根据Control.inputactions asset和Player action map生成。注意名字是如何匹配的,以及我们如何将this (InputReader) 的实例传递给SetCallbacks 方法。还要注意我们在Enable 和Disable 行动图的时候。
设置一个Player
对于我们的Player 游戏对象,我们需要一个具有空闲、运行、跳跃和下降动画的人形模型。从这里下载这个由Quaternius制作的Spacesuit.fbx模式和所有动画(.anim文件)。
将.fbx 和所有.anim 文件移到你的项目的Assets文件夹中的某个地方。然后,将Spacesuit.fbx拖入你的场景中,并将Hierarchy标签中的Spacesuit 游戏对象重命名为Player 。
保持选择Player 游戏对象,并在检查器选项卡中添加Character Controller 、Animator 和我们的InputReader 作为组件:

在我们进行下一节之前,在层次结构选项卡中点击右键,并从上下文菜单中添加3D对象>立方体。将这个立方体移到玩家的下面,并改变它的比例来创造一些地面。如果你愿意,你也可以添加更多的立方体,用它们来建造一些平台。
设置Character Controller
Character Controller 组件允许我们做受碰撞约束的运动,而不需要处理刚体,正如Unity文档中所述。这意味着我们需要设置它的碰撞器,在 "场景"选项卡中,它被可视化为一个绿色的线框胶囊:

对于这个特定的角色模型,碰撞器的位置和形状的最佳值是Center X: 0, Y: 1, Z: 0.12,Radius 0.5, 和Height 1.87 。你可以保持其他属性的默认值。

现在右击层次结构中的Player ,选择创建空对象;这将创建一个空的游戏对象,只有一个Transform 组件作为Player 的子对象。将其重命名为CameraLookAtPoint ,并在检查器中设置其Y-position 为1.5 。你会在下一节看到我们为什么要这样做:

设置Cinemachine
Cinemachine是一个非常强大的Unity包。它允许你创建,除其他事项外,一个具有高级功能(如避开障碍物)的自由观察跟拍摄像机,完全在编辑器的UI中,不需要任何编码。而这正是我们现在要做的事情!
在 "层次结构"选项卡中点击右键,添加Cinemachine>FreeLook Camera。已经添加到我们场景中的CMFreeLook 对象本身不是摄像机,而是Main Camera 的驱动。在运行时,它设置主摄像机的位置和旋转。
当CMFreeLook 对象被选中时,从层次结构选项卡拖到检查器选项卡,在CinemachineFreeLook 组件下,我们的Player 到Follow 属性,其子对象CameraLookAtPoint 到Look At:

现在向下滚动,为顶部、中部和底部的摄像机支架设置数值。将TopRig Height 设为4.5 ,Radius 设为5 ,MiddleRig 设为2.5 和6 ,BottomRig 设为0.5 和5:

注意在Scene 视图中,当CMFreeLook 被选中时,在我们的播放器周围有三个红色的圆圈,用花线垂直连接。这些圆圈是顶部、中部和底部的钻机。
垂直花键是摄像机的虚拟轨道,当我们垂直移动鼠标时,它将上下滑动,而水平移动鼠标时,整个花键将围绕这些圆圈旋转。
而有了新的输入系统,将这些鼠标输入与摄像机连接起来就超级容易了。你只需要将Cinamechine Input Provider 组件添加到CMFreeLook 对象中,并将我们的Control.inputactions 资产中的Player/Look (Input Action Reference) 分配到XY Axis 属性中:

为了完成Cinemachine的设置,将最后一个组件添加到CMFreeLook :Cinemachine Collider 。这将使摄像机避开障碍物,而不是剪切通过。你可以保持它的所有值:

这就是我们用Cinemachine设置的玩家摄像机,完全不需要编码。如果你现在玩这个游戏,你应该能够用鼠标或游戏板上的左摇杆围绕玩家旋转摄像机。
然而,这真的只是冰山一角。来自Unity Technologies的聪明人在这个软件包中投入了大量的时间,你可以在Unity网站的Cinemachine部分阅读更多关于它的信息。
设置动画师
在下一节开始从头开始构建我们的自定义状态机之前,我们需要做的最后一件事是设置一个动画师。
在动画师中,我们将创建一个混合树,用于在空闲和移动动画之间的平滑过渡,并为跳跃和坠落添加两个独立的动画。
如果你以前在Unity中使用过Animator,你可能知道这个Animator也是一个状态机,但是你可能会很惊讶,我们不会在Blend Tree和动画之间添加任何过渡。这是一个有效的方法,因为稍后我们将在我们自己的状态机中启动这些动画之间的转换。
首先,我们需要创建一个Animator Controller资产。在项目标签中点击右键,选择Cinemachine>Animator Controller。将这个新资产命名为PlayerAnimator 。
现在在层次结构标签中选择我们的Player 游戏对象,在检查器中,将资产拖到Animator组件的Controller插槽中。

现在,在菜单栏中进入Window>Animation>Animator。如果你还没有下载动画文件(.anim),现在就下载。
让我们开始为空闲和移动动画之间的过渡创建一个混合树。在Animator窗口中的格子空间内点击右键,选择Create State → From New Blend Tree。
这是我们的第一个状态,Unity自动将其标记为默认状态,颜色为橙色。当我们运行游戏时,Animator立即将当前状态设置为这个。在用户界面中,橙色的箭头也显示了从Entry到我们的Blend Tree状态。
选择混合树状态,在检查器选项卡中,将其重命名为MoveBlendTree 。注意不要有任何错别字,要完全按照你在这里看到的命名,一个好主意是复制并粘贴这里的名字,因为以后我们将在代码中用这个名字来引用它。
在Animator窗口的左侧面板,从Layers切换到Parameters标签,点击加号,选择float作为参数类型,并将新参数命名为MoveSpeed 。再次,确保这个名字是正确的,原因与混合树的名字相同。

现在双击MoveBlendTree ,这将打开另一个图层。然后选择标记为混合树的灰色框(它也应该有一个标记为MoveSpeed 的滑块和一个输入框0 ),在检查器中点击空的运动列表下的小加号,点击添加运动场。
再做一次以获得另一个槽,然后从下载的动画(.anim )中拖放空闲到第一个槽,运行到第二个槽。

混合树是将更多的动画组合成一个最终的动画。这里我们有两个动画和一个参数MoveSpeed 。当MoveSpeed 是0 ,最终的动画都是闲置的,没有运行。当MoveSpeed 是1 ,那么它将是完全相反的。而当0.5 ,最后的动画将是两者的结合,50%的空闲和50%的运行。
想象一下,你站着不动,然后你需要跑到某个地方。你需要做一个特定的动作来从站着不动过渡到跑动,对吗?这正是我们在下一节中从我们的代码中设置MoveSpeed 的值时要使用这个混合树的原因。

目前,我们几乎已经完成了动画师的工作。我们只需要为跳跃和下落添加独立的动画。这就容易多了--从MoveBlendTree回到Base Layer,然后把跳跃和下落的动画拖到Animator窗口。

我特意把跳跃动画做得太慢了,所以你可以看到你如何在Unity中调整任何动画的速度。点击Animator窗口中的Jump动画,在检查器中,将Speed 属性的值从1 改为2 。动画的播放速度将是原来的两倍。

构建一个状态机
到目前为止,我们大部分时间都是在Unity编辑器中度过的。本教程的最后部分将是关于编码的。正如我在开始时写的,我们将使用一种叫做状态模式的东西,它与状态机密切相关。
首先,我们要写一个纯抽象的 State (一个只有抽象方法,没有实现,没有数据的类)。从这个类中,我们继承一个抽象的PlayerBaseState 类,并提供具体的方法,这些方法的逻辑在具体的状态中是有用的,这些状态将继承自这个类。
这些将是我们的状态。PlayerMoveState,PlayerJumpState, 和PlayerFallState 。它们各自将以不同的方式实现Enter,Tick, 和Exit 方法。
所有的类,它们之间的关系,它们的成员和方法都在下面的UML类图中得到说明。具体状态的成员,如CrossFadeDuration,JumpHash, 和其他的成员是用于动画之间的转换。

状态机本身将由两个类组成。第一个将是StateMachine 。这个类将继承自MonoBehavior ,并拥有切换状态和执行其Enter,Exist, 和Tick 方法的核心逻辑。
第二个类是PlayerStateMachine ;这个类将继承自StateMachine ,因此也间接地继承自MonoBehavior 。我们将把PlayerStateMachine 作为另一个组件附加到我们的玩家游戏对象上。这就是为什么我们需要它是一个MonoBehavior 。
PlayerStateMachine 将有对其他组件和其他成员的公共引用,我们将通过状态机实例在状态中使用,我们将从状态中传递。
如果这对你来说是新的,你感到困惑,不要担心--仔细看看下面的图和前面的图,暂停一下,想一想。如果你仍然感到困惑,就继续吧,我相信你在写代码的时候就会把你的头搞清楚了。

好了,理论够了,让我们开始写代码吧!创建一个新的C#脚本,将其命名为State 。它将是超级简单的。整个代码就是这样:
public abstract class State
{
public abstract void Enter();
public abstract void Tick();
public abstract void Exit();
}
现在添加StateMachine 类,这个类与State 类一起构成了状态模式的本质:
using UnityEngine;
public abstract class StateMachine : MonoBehaviour
{
private State currentState;
public void SwitchState(State state)
{
currentState?.Exit();
currentState = state;
currentState.Enter();
}
private void Update()
{
currentState?.Tick();
}
}
你可以看到这个逻辑非常简单。它有一个成员,即State, 类型的currentState 和两个方法,一个用于切换状态,同时在切换到一个新的状态之前,在当前状态上调用Exit 方法,然后在新的状态上现在调用Enter 。
Update 方法来自于MonoBehavior ,它被Unity引擎每帧调用一次,因此当前分配状态的Tick 方法也将每帧执行。还要注意空条件运算符的使用。
我们要实现的另一个类是PlayerStateMachine 。你可能会问为什么要创建一个PlayerStateMachine ,而不是使用StateMachine 本身作为我们播放器的一个组件。
这背后的原因,还有部分原因是PlayerBaseState 作为其他状态的直接父类而不是State 本身,在于可重用性。这个状态机通常对敌人也很有用。
敌人会使用相同的核心状态模式逻辑,但他们的状态会有不同的依赖关系和逻辑。你不想把它们和玩家的依赖关系和逻辑混在一起。
对于敌人,你会实现不同的EnemyStateMachine 和EnemyBaseState ,而不是PlayerStateMachine 和PlayerBaseState ,但作为状态机的状态,其背后的核心思想是一样的。
然而,这将超出本教程的范围,所以让我们回到我们的玩家,并添加PlayerStateMachine:
using UnityEngine;
[RequireComponent(typeof(InputReader))]
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(CharacterController))]
public class PlayerStateMachine : StateMachine
{
public Vector3 Velocity;
public float MovementSpeed { get; private set; } = 5f;
public float JumpForce { get; private set; } = 5f;
public float LookRotationDampFactor { get; private set; } = 10f;
public Transform MainCamera { get; private set; }
public InputReader InputReader { get; private set; }
public Animator Animator { get; private set; }
public CharacterController Controller { get; private set; }
private void Start()
{
MainCamera = Camera.main.transform;
InputReader = GetComponent<InputReader>();
Animator = GetComponent<Animator>();
Controller = GetComponent<CharacterController>();
SwitchState(new PlayerMoveState(this));
}
}
使用RequireComponent 属性并不是强制性的,但这是一个好的做法。PlayerStateMachine 作为播放器游戏对象上的一个组件,需要InputReader,Animator, 和CharacterController 组件也被附加到播放器上。
没有它们会导致运行时错误。有了这个 [RequireComponent](https://docs.unity3d.com/ScriptReference/RequireComponent.html)属性,我们可以更早地发现最终的问题,在编译时,这通常是更好的。另外,当你添加一个像这样装饰的游戏对象时,Unity编辑器会自动添加所有需要的组件,还可以防止你意外地删除它们。
注意我们是如何用 [GetComponent](https://docs.unity3d.com/ScriptReference/GameObject.GetComponent.html)在Start 方法中指定所需组件的引用。当Unity加载一个场景时,Start被调用一次。在Start 方法中,我们也给主摄像机的Transform 组件分配了一个引用,所以我们可以在玩家状态下访问它的位置和旋转。
从状态来看,我们将通过PlayerStateMachine 来访问所有这些成员。这就是为什么我们在创建一个新的PlayerMoveState 实例时要传递一个对this 实例的引用,同时将它作为一个参数传递给SwitchState 方法。
你可以说PlayerMoveState 是PlayerStateMachine 的默认状态,我们还需要实现它,但在实现之前,我们首先要实现它的父类,即PlayerBaseState 类:
using UnityEngine;
public abstract class PlayerBaseState : State
{
protected readonly PlayerStateMachine stateMachine;
protected PlayerBaseState(PlayerStateMachine stateMachine)
{
this.stateMachine = stateMachine;
}
protected void CalculateMoveDirection()
{
Vector3 cameraForward = new(stateMachine.MainCamera.forward.x, 0, stateMachine.MainCamera.forward.z);
Vector3 cameraRight = new(stateMachine.MainCamera.right.x, 0, stateMachine.MainCamera.right.z);
Vector3 moveDirection = cameraForward.normalized * stateMachine.InputReader.MoveComposite.y + cameraRight.normalized * stateMachine.InputReader.MoveComposite.x;
stateMachine.Velocity.x = moveDirection.x * stateMachine.MovementSpeed;
stateMachine.Velocity.z = moveDirection.z * stateMachine.MovementSpeed;
}
protected void FaceMoveDirection()
{
Vector3 faceDirection = new(stateMachine.Velocity.x, 0f, stateMachine.Velocity.z);
if (faceDirection == Vector3.zero)
return;
stateMachine.transform.rotation = Quaternion.Slerp(stateMachine.transform.rotation, Quaternion.LookRotation(faceDirection), stateMachine.LookRotationDampFactor * Time.deltaTime);
}
protected void ApplyGravity()
{
if (stateMachine.Velocity.y > Physics.gravity.y)
{
stateMachine.Velocity.y += Physics.gravity.y * Time.deltaTime;
}
}
protected void Move()
{
stateMachine.Controller.Move(stateMachine.Velocity * Time.deltaTime);
}
这个类比前面的要长一点,因为它包含了其他状态的所有通用逻辑。我将从构造函数开始,从上到下、逐个方法地解释这个逻辑。
构造函数接受PlayerStateMachine ,然后将引用分配给stateMachine 。在受保护的CalculateMoveDirection ,然后我们根据摄像机的方向和来自InputReader.MoveComposite ,由W、S、A、D键或游戏板上的左摇杆设置的输入值计算出玩家的移动方向。
然而,我们在这个方法中并不直接将玩家按计算的方向移动。我们将Velocity x和z值设置为计算方向的各自数值,再乘以MovementSpeed 。
在FaceDirection 方法中,我们旋转播放器,使其始终面向移动的方向,也就是来自Velocity 的方向,而y 的值为零,因为我们不希望我们的播放器上下倾斜。
我们通过stateMachine 来设置我们的播放器的Transform 组件的旋转,因为我们可以从游戏对象上的任何其他组件获得对Transform 组件的引用,而PlayerStateMachine 将是其中之一。
旋转值本身的计算是通过 [Slerp](https://docs.unity3d.com/ScriptReference/Quaternion.Slerp.html)和 [LookRotation](https://docs.unity3d.com/ScriptReference/Quaternion.LookRotation.html)方法计算,这些方法由Unity提供,作为Quaternion 类的静态方法。球形插值是一个函数,它需要一个开始和目标旋转,以及插值的t值。
我们要从PlayerMoveState 中的Tick 方法中调用CalculateMoveDirection 和FaceMoveDirection ,为了实现平滑的、与帧速率无关的旋转,我们将我们的LookRotationDampTime 乘以 [Time.deltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html).
在ApplyGravity ,如果Velocity 的y值大于Physics.gravity.y, ,我们就不断地将其值乘以Time.deltaTime 。重力的y值在Unity中默认设置为-9.81 。
这将导致玩家不断被拉向地面。你会在PlayerJumpState 和PlayerFallState 中看到这个效果,但是我们也要在PlayerMoveState 中调用这个方法,以保持玩家的接地。
Move 是我们使用它的CharacterController 组件实际移动播放器的方法。我们简单地将播放器向Velocity 乘以delta时间的方向移动。
接下来,我们将添加第一个具体的状态实现,PlayerMoveState:
using UnityEngine;
public class PlayerMoveState : PlayerBaseState
{
private readonly int MoveSpeedHash = Animator.StringToHash("MoveSpeed");
private readonly int MoveBlendTreeHash = Animator.StringToHash("MoveBlendTree");
private const float AnimationDampTime = 0.1f;
private const float CrossFadeDuration = 0.1f;
public PlayerMoveState(PlayerStateMachine stateMachine) : base(stateMachine) { }
public override void Enter()
{
stateMachine.Velocity.y = Physics.gravity.y;
stateMachine.Animator.CrossFadeInFixedTime(MoveBlendTreeHash, CrossFadeDuration);
stateMachine.InputReader.OnJumpPerformed += SwitchToJumpState;
}
public override void Tick()
{
if (!stateMachine.Controller.isGrounded)
{
stateMachine.SwitchState(new PlayerFallState(stateMachine));
}
CalculateMoveDirection();
FaceMoveDirection();
Move();
stateMachine.Animator.SetFloat(MoveSpeedHash, stateMachine.InputReader.MoveComposite.sqrMagnitude > 0f ? 1f : 0f, AnimationDampTime, Time.deltaTime);
}
public override void Exit()
{
stateMachine.InputReader.OnJumpPerformed -= SwitchToJumpState;
}
private void SwitchToJumpState()
{
stateMachine.SwitchState(new PlayerJumpState(stateMachine));
}
}
MoveSpeedHash 和MoveBlendTreeHash 的整数是MoveSpeed 参数的数字标识符和我们的Animator 中的MoveBlendTree 。我们使用Animator 类中的一个静态方法 [StringToHash](https://docs.unity3d.com/ScriptReference/Animator.StringToHash.html)的静态方法,将字符串转换成 "唯一 "的数字。
我把unique加了引号,因为从理论上讲,一个哈希算法可以对两个不同的输入字符串产生相同的结果。这就是所谓的哈希碰撞的东西。在这里,它只是一个附带说明,而且你真的不必担心它。这个机会是非常小的。
如果你往下看,在我们设置MoveSpeed 参数的浮动值的地方,stateMachine.Animator.SetFloat(MoveSpeedHash… ,你可以看到我们如何使用这个哈希值来识别MoveSpeed 参数的名称。
之所以不直接使用字符串"MoveSpeed" ,是因为比较整数比比较字符串的性能要好得多。尽管有可能向Animator.SetFloat 方法传递一个字符串--而且在我们的小例子中,相对而言,这并没有什么区别--但我想让你看看正确的、更有性能的方法。
让我们回到PlayerMoveState 类的顶部。在公共构造函数中,我们将stateMachine 传递给基构造函数;那是父类的构造函数,即PlayerBaseState 。
然后我们有一个Enter 方法,当状态机将状态设置为当前状态时调用一次。在这里,我们把Velocity 向量的y值设置为Physics.gravity 向量的y值,因为我们希望我们的播放器不断被拉下来。
然后我们在一个固定的时间内将我们的Animator交叉渐变到MoveBlendTree 状态,在我们的例子中,这导致了下跌动画和MoveBlendTree 的结果动画之间的平滑过渡,当我们以后从PlayerFallState 切换回PlayerMoveState 。
我们还将SwitchToJumpState 方法注册到我们的OnJumpPerformed 事件中,InputReader 。当我们在游戏板上按下空格键或South Button时,SwitchToJumpState 将被调用,正如你在最下面看到的,在该函数的主体中,将状态机切换到一个新的PlayerJumpState 。
在Exit 函数中,也就是在状态机将状态切换到一个新的状态之前被调用的那个,我们简单地将SwitchToJumpState 方法从事件中取消,以切断输入和动作之间的联系。
这就给我们留下了Tick 方法,它每一帧都被执行。首先,我们检查玩家是否已经接地。如果不是,玩家应该开始下落,因此我们立即将状态机中的当前状态切换到PlayerFallState ,并将stateMachine 作为构造参数传给它。
如果玩家是接地的,我们就调用CalculateMoveDirection 、FaceMoveDirection 和Move 方法,这些方法来自于PlayerBaseState ,这是一个父类,很快也会调用其他两个状态,我们还需要实现它们。
我们已经知道这些方法是如何工作的了,因为我们不久前还在实现它们。在这里,我们只是在每一帧调用它们,一遍又一遍,直到状态被改变。
MoveSpeed 最后,我们根据我们的InputReader 中MoveComposite 的平方幅度来设置我们的Animator 的参数。
我们使用平方幅度是因为我们对实际值不感兴趣;我们只需要知道这个值是否为0,而从平方幅度计算幅度需要一个额外的步骤,即平方根。这只是为了压榨一点性能,在执行Tick 方法时,每一帧节省几个周期。
如果值是0,我们把MoveSpeed参数也设置为0,否则,我们把它设置为1,在我们的MoveBlendTree ,从Idle ,有效地过渡到Run 的动画。其他的参数,AnimationDampTime 和Time.deltaTime ,使这个过渡平稳,不受帧速率的影响。
我们几乎完成了;我们只需要实现PlayerJumpState 和PlayerFallState 。让我们从后者开始:
using UnityEngine;
public class PlayerJumpState : PlayerBaseState
{
private readonly int JumpHash = Animator.StringToHash("Jump");
private const float CrossFadeDuration = 0.1f;
public PlayerJumpState(PlayerStateMachine stateMachine) : base(stateMachine) { }
public override void Enter()
{
stateMachine.Velocity = new Vector3(stateMachine.Velocity.x, stateMachine.JumpForce, stateMachine.Velocity.z);
stateMachine.Animator.CrossFadeInFixedTime(JumpHash, CrossFadeDuration);
}
public override void Tick()
{
ApplyGravity();
if (stateMachine.Velocity.y <= 0f)
{
stateMachine.SwitchState(new PlayerFallState(stateMachine));
}
FaceMoveDirection();
Move();
}
public override void Exit() { }
}
你第一眼就可以看到,这个人要简单得多。在顶部,我们有Jump 字符串的哈希值,在Animator 中有独立的跳跃动画的名称,我们通过构造函数传递stateMachine 实例。这对我们来说并不新鲜。
在Enter 方法中,除了将动画交叉转换到Jump ,同样的,就像我们在PlayerMoveState 中交叉转换一样,我们将Velocity 设置为一个new Vector3 ,它具有相同的x和z值以及当前速度,但y值被设置为JumpForce 。
然后,在Tick 方法中,当我们调用Move ,我们的播放器被拉高,因为Velocity 的y值现在是正的,但我们也调用ApplyGravity ,所以它的值每帧都会慢慢减少,一旦它到了0或以下,我们就切换到PlayerFallState 。
FaceMoveDirection 这不是强制性的;这只是表面现象。我个人认为,当玩家总是面对移动的方向时,即使是在跳跃的时候,也会更漂亮。
强制性的是提供抽象父类的所有抽象方法的覆盖,除非子类也是抽象的,但它不是,所以我们必须为Exit 方法提供一个实现,即使它什么都不做。
如果你想一想,这就说得通了:从这个状态退出时,SwitchState 方法会在currentState.Exit() 上调用什么?
我们现在已经非常接近完整的实现了,最后一个状态,PlayerFallState ,甚至是最简单的一个:
using UnityEngine;
public class PlayerFallState : PlayerBaseState
{
private readonly int FallHash = Animator.StringToHash("Fall");
private const float CrossFadeDuration = 0.1f;
public PlayerFallState(PlayerStateMachine stateMachine) : base(stateMachine) { }
public override void Enter()
{
stateMachine.Velocity.y = 0f;
stateMachine.Animator.CrossFadeInFixedTime(FallHash, CrossFadeDuration);
}
public override void Tick()
{
ApplyGravity();
Move();
if (stateMachine.Controller.isGrounded)
{
stateMachine.SwitchState(new PlayerMoveState(stateMachine));
}
}
public override void Exit() { }
}
这一次,在Enter 方法中,我们交叉淡入Fall 独立动画,并且我们在y轴上设置初始Velocity 值为0。然后在Tick 方法中,我们调用ApplyGravity 和Move ,它把我们的播放器拉下来,直到它落地,我们再把状态切换到PlayerMoveState 。
就这样了。我们基于状态机的第三人称控制器已经完成了,它允许我们的玩家在移动、跳跃和坠落状态中转换。

我们需要做的最后一件事是回到Unity编辑器中,通过将StateMachine.cs 脚本作为一个组件添加到Player游戏对象中来使用它。

本教程中完整的Unity项目可以在GitHub上找到。
总结
如果你是Unity的初学者,而且这是你第一次接触到我们所涉及的大部分或全部内容,并且你设法使其工作,那么做得很好!拍拍自己的背,因为这不是一个小成就,你已经学到了很多。
除了状态模式,我们还看到了观察者模式,我们学会了如何使用新的Unity输入系统,如何使用Cinemachine和Animator,以及如何使用CharacterController 组件。
最重要的是,我们看到了如何把所有这些放在一起,建立一个容易扩展的第三人称玩家控制器。说到可扩展性,我想在最后留给你一个小挑战。试着自己实现一个PlayerDeadState 。
挑战的提示:创建一个带有Health 属性的HealthComponent 类,将其附加到Player游戏对象上,并在PlayerStateMachine 中存储对它的引用。 然后使用观察者模式。当Health 属性达到0时,调用一个事件,将状态从任何状态切换到PlayerDeadState 。