使用新的输入系统在 Unity 中构建第三人称控制器

如果你随机挑选几款游戏,每款游戏可能会有不同的艺术风格和机制、不同的故事,甚至根本没有故事,但它们都有一个共同点:所有游戏都需要读取和处理输入来自键盘、鼠标、游戏手柄、操纵杆、VR 控制器等设备。

构建第三人称控制器。 在这篇文章中,我将向您展示如何在 Unity 中使用新的Input System 驱动的跟随摄像头 Cinemachine 软件包以及由 Unity Technologies 的另一个强大软件包

我们的第三人称控制器将处理来自键盘和鼠标以及标准游戏手柄的输入,并且由于 Unity 中的新输入系统非常智能,您很快就会看到,添加对另一个输入设备的支持不需要任何额外的代码.

最重要的是,您将看到如何设置空闲、奔跑、跳跃和跌倒动画,以及如何在它们之间平滑过渡。 我们将把控制器的核心实现为状态机,重点是干净的架构和可扩展性。

如果您以前从未听说过状态机或 状态 设计模式,请不要担心,我将逐步解释所有内容。 但是,我假设您对 C# 和 OOP 概念(如 继承 和 抽象 类)有基本的了解。

在本文结束时,您将能够轻松地使用您自己的状态扩展我们的控制器,并且您将掌握一种设计模式,您会发现它在许多不同的上下文中都很有用。

说到设计模式,除了状态模式,我们还会使用另一种,在游戏开发中非常常见,如果不是最常见的话: 观察者 模式。

旧版与新版 Unity 输入系统

在我们开始构建我们的播放器控制器之前,让我们简单谈谈新旧 Unity 输入系统之间的区别。 我不会重复您可以在文档中阅读的内容,而是强调主要区别。

如果您以前使用过 Unity,您可能已经知道如何使用旧的输入系统。 当您希望仅在按下给定键时每帧执行一些代码时,您可以这样做:


超过 20 万开发人员使用 LogRocket 来创造更好的数字体验 了解更多 →


void Update()
{
  if (Input.GetKeyDown(KeyCode.Space)) 
  { 
    // Code executed every frame when Space is pressed
  }
}

中将键和轴与名称绑定来使其更好一点 您可以通过在Project Settings > Input Manager ,然后像这样编写脚本:

{ 
  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 之间添加了一个抽象层。 您创建一个 Input Action 资产,通过编辑器中的 UI 将输入与操作绑定,并让 Unity 为您生成 API。

然后你写一个简单的类来实现 IPlayerActions为玩家控制器提供输入事件和值来消费,这正是我们在这篇博文中要做的。

创建一个新的 Unity 项目

如果您想继续,我鼓励您这样做,我建议使用 Unity 2021.3.6f1 。 创建一个新的空 3D 项目,首先,转到 Window > Package Manager 。 选择 Unity Registry ,键入 从 Packages 下拉列表中 Cinemachine in the search field, select the package, and hit install. Then do the same for the InputSystem包裹。

安装时 InputSystem, Unity 会提示您重新启动。 之后,返回 Package Manager 窗口,选择 Packages: In Project ,并确认两个包都已安装。

除了 IDE 的代码集成支持之外,您还可以删除其他包。 就我而言,它是 Visual Studio 编辑器包。

设置输入系统

要设置新的 Unity 输入系统,我们首先需要将输入绑定到操作。 为此,我们需要一个 .inputactions资产。 让我们通过右键单击 Project 选项卡并选择 Create > Input Actions 添加一个。

给它命名 Controls.inputactions并通过双击这个新资产打开绑定输入的窗口。

在右上角单击 All Control Schemes ,然后从弹出的菜单中选择 Add Control Scheme... ,将新方案命名为 Keyboard and Mouse,然后通过空列表底部的加号,添加 键盘 和 鼠标 输入设备。


来自 LogRocket 的更多精彩文章:

  • 不要错过 The Replay 来自 LogRocket 的精选时事通讯

  • 了解 LogRocket 的 Galileo 如何消除噪音以主动解决应用程序中的问题

  • 使用 React 的 useEffect 优化应用程序的性能

  • 之间切换 在多个 Node 版本

  • 了解如何 使用 AnimXYZ 为您的 React 应用程序制作动画

  • 探索 Tauri ,一个用于构建二进制文件的新框架

  • 比较 NestJS 与 Express.js


重复上一段的过程,但这次将新方案命名为 Gamepad ,并将 Gamepad 添加到输入设备列表中。 此外, 选择可选。 从两个要求选项中

返回 Controls (Input Action) 窗口,单击最左侧标记为 Action Maps to a plus symbol 的列,并将新添加的记录命名为 Player. 中间一列是我们现在要将输入与动作绑定的地方。

已添加一项操作,它被标记为 New Action. 右键单击该操作并将其重命名为 Jump,然后用小三角图标展开它,选择绑定 ,然后在右列的 Binding Properties 旁边的下拉图标 中,单击Path 。

您可以 部分找到空格 在 键盘 ,也可以单击 收听 按钮,然后只需按键盘上的空格键。 回到绑定属性,在 Path 下面,勾选 的Keyboard and Mouse 下 Use in control scheme 。 请注意,这些是我们之前添加的方案。

空间现在分配给跳跃动作,但我们还需要另一个游戏手柄绑定。 在“ 操作 ”列中,单击加号。 选择 Add binding ,然后在 Path 设置 Button South 中,从 Gamepad 部分 。 这次,在 Use in control scheme 下,勾选 Gamepad 。

让我们添加另一个动作,这次是移动。 标签旁边的加号 使用动作 ,添加新动作并为其命名 Move. 保持 Move选择动作并在右栏中,将 动作类型 更改为 值 , 将控制类型 更改为 向量 2 。

第一个绑定槽,再次默认标记为 , 已经添加了。 让我们将它用于游戏手柄,因为对于键盘,我们将添加不同类型的动作。 在 Path 中, 找到并分配 左摇杆。 从 Gamepad 部分

操作旁边的加号 现在,使用Move 添加新绑定,但这次选择 Add Up/Down/Left/Right Composite 。 您可以将新绑定的名称保留为 2D Vector ,重要的是为每个组件分配一个键。

将 W 、 S 、 A 和 D 分别分配给 Up、Down、Left 和 Right 或箭头键,如果您愿意的话。 不要忘记 使用中勾选键盘 和 鼠标 的 在每个控制方案 。

我们需要添加的最后一个动作是旋转相机以环顾四周。 让我们为这个动作命名 Look. 对于此操作,还将 Action Type 设置为 Value 并将 Control Type 设置为 Vector 。 对于 Keyboard and Mouse 控制方案, 部分绑定Delta 从 Mouse ,这是鼠标从上一帧到当前帧的 X 和 Y 位置的变化量,对于 Gamepad 方案,绑定 Right Stick 。

我们现在拥有键盘和鼠标以及游戏手柄设置的所有输入绑定。 我们需要在 Controls (Input Action) 窗口中做的最后一件事是单击 Save Asset 按钮。

请注意,当我们保存资产时,Unity 生成了一个 Controls.cs为我们归档 Assets文件夹,就在旁边 Controls.inputactions. 在构建我们的文件时,我们将需要在此文件中生成的代码 InputReader类,我们将在下一节中进行。

建立一个 InputReader班级

到目前为止,我们只在 Unity 编辑器中工作。 现在是时候编写一些代码了。 在您的资产中创建一个新的 C# 脚本并将其命名 InputReader. 这 InputReader类应该继承自 MonoBehavior因为我们要把它作为一个组件附加到我们的 Player游戏对象,稍后我们会有一个。

除此之外, InputReader将实施 Controls.IPlayerActions界面。 这个界面是Unity为我们生成的,我们保存的时候 Controls.inputactions上一节末尾的资产。

因为我们创建了 Look 、 Move 和 Jump 动作,所以界面定义了 OnLook, OnMove, 和 OnJump方法与 context类型参数 InputAction.CallbackContext.

你还记得我写的新的 Unity 输入系统是基于事件的吗? 就是这个。 我们在我们的定义 InputReaderA级成员 MoveComposite类型 Vector2我们实施 OnMove像这样:

public void OnMove(InputAction.CallbackContext context)
{
  MoveComposite = context.ReadValue();
}

每当我们为移动动作绑定的输入(W、S、A、D 键和游戏手柄上的右摇杆)被注册时,这个 OnMove叫做。 从生成的代码中的一个事件,以及从 context传递的参数,然后我们读取输入值。

例如,当按下 W 键时,来自 context分配给我们的 MoveComposite将在 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();
    }
​
    public void OnMove(InputAction.CallbackContext context)
    {
        MoveComposite = context.ReadValue();
    }
​
    public void OnJump(InputAction.CallbackContext context)
    {
        if (!context.performed)
            return;
​
        OnJumpPerformed?.Invoke();
    }
}

OnEnable和 OnDisable当脚本作为组件附加到其上的游戏对象分别启用和禁用时,将调用方法。 当我们运行我们的游戏时, OnEnable被调用一次 Awake. 有关更多信息,请参阅 中事件函数的执行顺序。 Unity 文档

这里重要的是创建一个 Control类,Unity基于该类生成 Control.inputactions资产和 Player行动图。 注意名称如何匹配以及我们如何传递 this ( InputReader) 到 SetCallbacks方法。 还要注意当我们 Enable和 Disable行动图。

设置一个 Player

对于我们的 Player游戏对象,我们需要一个带有空闲、奔跑、跳跃和跌倒动画的人形模型。 从这里下载 制作的Spacesuit.fbx 模式 Quaternius 和所有 动画(.anim 文件) 。

移动 .fbx和所有 .anim某处的 项目中Assets 文件夹中 拖放 文件。 然后,将Spacesuit.fbx 到您的场景中并重命名 Spacesuit游戏对象 层次结构 选项卡中的 Player.

保持 Player选择游戏对象并在 Inspector 选项卡中添加 Character Controller, Animator, 和我们的 InputReader作为组件。

在我们继续下一部分之前,右键单击 Hierarchy 选项卡,然后从上下文菜单中添加 3D Object > Cube 。 将此立方体移动到玩家下方并更改其比例以创建一些地面。 如果您愿意,您还可以添加更多的多维数据集并从中构建一些平台。

配置 Character Controller

一个 Character Controller中所述,组件允许我们进行受碰撞约束的运动,而无需处理刚体 如Unity 文档 。 这意味着我们需要设置它的碰撞器,它在“ 场景” 选项卡中显示为绿色线框胶囊。

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

现在右键单击 Player在 层次结构 中并选择 Create Empty ; 这将创建一个空的游戏对象,只有一个 Transform组件作为子对象 Player. 将其重命名为 CameraLookAtPoint并在 Inspector 中设置其 Y-position至 1.5. 您将在下一节中了解我们为什么这样做。

Setting up Cinemachine

Cinemachine 是一个非常强大的 Unity 包。 它允许您在编辑器 UI 中创建具有高级功能(如避障)的自由外观跟随相机,无需任何编码。 这正是我们现在要做的!

右键单击 Hierarchy 选项卡并添加 Cinemachine > FreeLook Camera 。 这 CMFreeLook添加到我们场景中的对象不是相机本身,而是 Main Camera. 在运行时,它设置主摄像机的位置和旋转。

虽然 CMFreeLook拖放 对象被选中,从Hierarchy 选项卡 到Inspector 选项卡,在 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对象并赋值 Player/Look (Input Action Reference)从我们的 Control.inputactions资产 XY Axis财产。

要完成 Cinemachine 设置,请将最后一个组件添加到 CMFreeLook: 这 Cinemachine Collider. 这将使相机避开障碍物而不是穿过。 您可以保持其所有值不变。

这就是我们使用 Cinemachine 设置的播放器相机,完全无需编码。 如果您现在玩游戏,您应该能够使用鼠标或游戏手柄上的左摇杆围绕玩家旋转相机。

然而,这真的只是冰山一角。 Unity Technologies 的聪明人在这个软件包上投入了大量时间,您可以 在 Unity 网站上的 Cinemachine 部分 阅读更多相关信息。

设置动画师

在下一节从头开始构建自定义状态机之前,我们需要做的最后一件事是设置动画师。

在 Animator 中,我们将创建一个 Blend Tree 用于在 Idle 和 Move 动画之间进行平滑过渡,并为 Jump 和 Fall 添加两个独立的动画。

如果您之前在 Unity 中使用过 Animator,您可能知道这个 Animator 也是一个状态机,但您可能会惊讶我们不会在 Blend Tree 和动画之间添加任何转换。 这是一种有效的方法,因为稍后我们将在我们自己的状态机中启动这些动画之间的转换。

首先,我们需要创建一个 Animator Controller 资源。 右键单击项目选项卡并选择 Cinemachine > Animator Controller 。 将此新资产命名 PlayerAnimator.

现在在 层次结构 选项卡中选择我们的 Player游戏对象并在 Inspector 中,将资源拖到 的Controller 插槽中。 Animator 组件

现在,在菜单栏中转到 Window > Animation > Animator 。 如果您尚未下载动画文件 ( .anim),现在就 下载 它们。

让我们首先为空闲和移动动画之间的过渡创建一个混合树。 的网格空间内右键单击, 在Animator 窗口 然后选择Create State → From New Blend Tree 。

这是我们的第一个状态,Unity 自动将其标记为橙色的默认状态。 当我们运行游戏时,Animator 立即将当前状态设置为此。 它还在 UI 中显示,橙色箭头从 Entry 指向我们的 Blend Tree 状态。

选择 Blend Tree 状态并在 Inspector 选项卡中,将其重命名为 MoveBlendTree. 小心不要打错字,并按照您在此处看到的准确命名,一个好主意是从此处复制并粘贴名称,因为稍后我们将通过此名称在代码中引用它。

在 Animator 窗口的左侧面板中,从 Layers 切换到 Parameters 选项卡,单击加号,选择 float 作为参数类型并命名新参数 MoveSpeed. 再次确保名称正确,原因与混合树的名称相同。

现在双击 MoveBlendTree,这会打开另一层。 然后选择标记为 Blend Tree 的灰色框(它还应该有一个标记为 MoveSpeed和一个输入框 0在其中)并在 Inspector 中单击空的动作列表下的小加号,然后单击 Add Motion Field 。

再做一次以获得另一个插槽,然后从下载的动画中拖放( .anim文件) 空闲 进入第一个, 运行 进入第二个。

混合树就是将更多动画组合成最终动画。 这里我们有两个动画和一个参数 MoveSpeed. 当。。。的时候 MoveSpeed是 0, the final animation is all Idle and no Run. When MoveSpeed is 1, then it will be the exact opposite. And with the value of 0.5,最终动画将是 50% 的 Idle 和 50% 的 Run 的组合。

想象一下你站着不动,然后你需要跑到某个地方。 你需要做一个特定的动作才能从静止过渡到跑步,对吧? 这正是我们在设置 MoveSpeed来自我们下一节的代码。

目前,我们几乎完成了 Animator。 我们只需要添加独立的跳跃和下降动画。 这要容易得多——从 MoveBlendTree 返回 到 Base Layer 动画拖放 ,只需将Jump 和 Fall 到 Animator 窗口中即可。

我故意让 Jump 动画太慢,因此您可以了解如何在 Unity 中调整任何动画的速度。 单击 Animator 窗口中的 Jump 动画,然后在 Inspector 中更改 Speed财产来自 1至 2. 动画将以两倍的速度播放。

构建状态机

到目前为止,我们大部分时间都在 Unity 编辑器中度过。 本教程的最后一部分将是关于编码的。 正如我在开头所写的,我们将使用一种称为状态模式的东西,它与状态机密切相关。

首先,我们要写一个纯粹的摘要 State类(只有抽象方法而没有实现和数据的类)。 从这个类中,我们继承了一个抽象 PlayerBaseState类并提供具有逻辑的具体方法,这些逻辑在将从此类继承的具体状态中有用。

这些将是我们的状态: PlayerMoveState, PlayerJumpState, 和 PlayerFallState. 他们每个人都会实施 Enter, Tick, 和 Exit方法不同。

所有类、它们之间的关系、它们的成员和方法都在下面的 UML 类图中进行了说明。 具体国家的成员喜欢 CrossFadeDuration, JumpHash, 和其他用于动画之间的过渡。

状态机本身将包含两个类。 第一个将是 StateMachine. 这个类将继承自 MonoBehavior并且将具有用于切换状态和执行它们的核心逻辑 Enter, Exist, 和 Tick方法。

第二节课将是 PlayerStateMachine; 这个将继承自 StateMachine因此也间接地来自 MonoBehavior. 我们要附上 PlayerStateMachine作为我们的 Player 游戏对象的另一个组件。 这就是为什么我们需要它成为一个 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();
    }
}

可以看到逻辑很简单。 它有一个成员, currentState类型 State,和两种方法,一种用于在调用 Exit在切换到新状态然后调用之前的当前状态方法 Enter现在在新的状态。

这 Update方法来自 MonoBehavior它由 Unity 引擎每帧调用一次,因此 Tick当前分配状态的方法也将在每一帧执行。 还要注意 null 条件运算符的用法。

我们要实现的另一个类是 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();
        Animator = GetComponent();
        Controller = GetComponent();

        SwitchState(new PlayerMoveState(this));
    }
}

使用 RequireComponent属性不是强制性的,但这是一个很好的做法。 这 PlayerStateMachine作为玩家游戏对象需要的组件 InputReader, Animator, 和 CharacterController组件也将附加到播放器。

没有它们会导致运行时错误。 随着 RequireComponent属性,我们可以在编译时更快地发现最终问题,这通常会更好。 Plus Unity 编辑器会在您添加一个像这样装饰的游戏对象时自动将所有必需的组件添加到游戏对象中,并且还可以防止您意外删除它们。

请注意我们如何使用 GetComponent中的方法 Start方法。 当 Unity 加载场景时,会调用一次 Start。 在里面 Start方法,我们还分配了一个引用 Transform主相机的组件,所以我们可以在玩家状态下访问它的位置和旋转。

从各州,我们将通过以下方式访问所有这些成员 PlayerStateMachine. 这就是为什么我们将引用传递给 this当我们创建一个新的实例时 PlayerMoveState实例,同时将其作为参数传递给 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 键或 的左摇杆设置。 游戏手柄上

但是,我们不会直接在此方法中将玩家移动到计算出的方向。 我们设置 Velocityx 和 z 值分别为计算方向的值,乘以 MovementSpeed.

在里面 FaceDirection方法,我们旋转播放器,使其始终面向移动方向,即从 Velocity, 与 y值归零,因为我们不希望我们的播放器上下倾斜。

我们设置旋转 Transform我们播放器的组件通过 stateMachine因为我们可以得到对 Transform来自游戏对象上任何其他组件的组件,以及 PlayerStateMachine将是其中之一。

旋转值本身是使用计算的 Slerp和 LookRotationUnity 提供的静态方法 Quaternion班级。 球面插值是一个需要开始和目标旋转以及插值值的函数。

我们要打电话 CalculateMoveDirection和 FaceMoveDirection来自 Tick方法在 PlayerMoveState,并且为了实现平滑和帧率独立的旋转,我们通过我们的 LookRotationDampTime乘以 Time.deltaTime.

在里面 ApplyGravity,如果 Velocityy 值大于 Physics.gravity.y,我们不断地增加它的价值乘以 Time.deltaTime. 重力的 y 值在 Unity 中默认设置为 -9.81.

这将导致玩家不断地被拉到地上。 你会看到效果 PlayerJumpState和 PlayerFallState, 但我们也将在 PlayerMoveState,以保持玩家接地。

这 Move是我们使用它实际移动玩家的方法 CharacterController零件。 我们只需将玩家移动到 Velocity乘以增量时间。

接下来,我们将添加第一个具体的状态实现, 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参数和 MoveBlendTree在我们的 Animator. 我们正在使用来自 Animator班级 StringToHash将字符串转换为“唯一”数字。

我将唯一性放在引号中,因为理论上,哈希算法可以为两个不同的输入字符串产生相同的结果。 这就是所谓的哈希冲突。 这只是一个旁注,你真的不必担心。 机会极小。

如果你往下看我们在哪里设置我们的浮点值 MoveSpeed范围, stateMachine.Animator.SetFloat(MoveSpeedHash…,你可以看到我们如何使用这个哈希来识别 MoveSpeed范围。

不使用字符串的原因 "MoveSpeed"直接是因为比较整数比比较字符串的性能要高得多。 虽然可以将字符串传递给 Animator.SetFloat方法——在我们的小例子中,相对而言,它并没有太大的区别——我希望你展示正确的、更高效的方法。

让我们回到顶部 PlayerMoveState班级。 从公共构造函数中,我们通过 stateMachine到基础构造函数; 那是父类的构造函数, PlayerBaseState.

然后我们有 Enter方法,当状态机将状态设置为当前状态时调用一次。 在这里,我们设置 Velocity向量的 y 值 Physics.gravity矢量,因为我们希望我们的玩家不断被拉下。

然后我们将我们的 Animator 交叉淡入淡出到 MoveBlendTree固定时间的状态,在我们的例子中,这会导致下降动画和结果动画之间的平滑过渡 MoveBlendTree当我们稍后从 PlayerFallState回到 PlayerMoveState.

我们还注册了 SwitchToJumpState的方法 OnJumpPerformed我们的活动 InputReader. 当我们按下 的空格键或南按钮时, 游戏手柄上 SwitchToJumpState将被调用,正如您在最底部看到的那样,在该函数的主体中,将状态机切换到新的 PlayerJumpState.

在里面 Exit函数,在状态机将状态切换到新状态之前调用的函数,我们只需取消订阅 SwitchToJumpState方法从事件,打破输入和动作之间的联系。

这给我们留下了 Tick方法,每帧执行一次。 首先,我们检查玩家是否接地。 如果没有,玩家应该开始下落,因此我们立即将状态机中的当前状态切换到 PlayerFallState,通过 stateMachine构造函数参数。

如果播放器接地,我们调用 CalculateMoveDirection, FaceMoveDirection, 和 Move方法来自 PlayerBaseState,这个的父类,很快还有其他两个状态的父类,我们还需要实现。

我们在不久前实施这些方法时就已经知道它们是如何工作的。 在这里,我们只是调用它们,每一帧,一遍又一遍,直到状态改变。

最后,我们设置 MoveSpeed我们的参数 Animator根据平方大小 MoveComposite从我们的 InputReader.

我们使用平方幅度是因为我们对实际值不感兴趣; 我们只需要知道该值是否为 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,我们的 Player 被拉起,因为 Velocityy 值现在是正数,但我们也调用 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独立动画,我们正在设置初始 Velocityy 轴上的值为 0。 然后在 Tick方法,我们称之为 ApplyGravity和 Move,它将我们的玩家拉下来直到它撞到地面,然后我们将状态切换为 PlayerMoveState再次。

就是这样。 我们的第三人称控制器基于状态机,允许我们的玩家在移动、跳跃和跌倒状态之间进行转换。

SoftCnKiller流氓软件清理器,弹窗定位自动扫描,捆绑软件检测!

我们需要做的最后一件事是返回 Unity 编辑器并通过添加 StateMachine.cs脚本作为 Player 游戏对象的组件。

本教程中的完整 Unity 项目 可在 GitHub 上获得 。

结论

如果您是 Unity 的初学者,并且这是您第一次接触到我们所介绍的大部分或全部内容,并且您设法使其正常工作,那就太好了! 拍拍自己的后背,因为这不是一个小成就,而且你学到了很多东西。

除了状态模式,我们还看到了观察者模式,我们学习了如何使用新的 Unity 输入系统,如何使用 Cinemachine 和 Animator,以及如何使用 CharacterController零件。

最重要的是,我们已经看到了如何将所有这些组合在一起以构建一个易于扩展的第三人称玩家控制器。 说到可扩展性,最后我想给你一个小挑战。 尝试实现一个 PlayerDeadState靠自己。

挑战提示:创建一个 HealthComponent类 Health属性,将其附加到 Player 游戏对象,并将对它的引用存储在 PlayerStateMachine. 然后使用观察者模式。 当。。。的时候 Health属性达到零,调用将状态切换到 PlayerDeadState来自任何州。

LogRocket :全面了解您的网络和移动应用程序

LogRocket 是一个前端应用程序监控解决方案,可让您重现问题,就好像它们发生在您自己的浏览器中一样。 无需猜测错误发生的原因,或要求用户提供屏幕截图和日志转储,LogRocket 可让您重播会话以快速了解问题所在。 无论框架如何,它都可以完美地与任何应用程序配合使用,并且具有用于记录来自 Redux、Vuex 和 @ngrx/store 的附加上下文的插件。

除了记录 Redux 操作和状态之外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据和自定义日志。 它还检测 DOM 以记录页面上的 HTML 和 CSS,即使是最复杂的单页和移动应用程序也能重新创建像素完美的视频。

你可能感兴趣的:(unity,vr,游戏引擎)