任何 2D 平台游戏的关键元素之一就是主角。主角的移动和控制方式极大地塑造了游戏的氛围——无论是温馨的老式游戏还是动态杀戮游戏。因此,创建角色控制器是开发平台游戏的重要早期阶段。
在本文中,我们将彻底研究从头开始创建角色的过程,教它在遵守物理定律的同时在关卡中移动。即使您已经有创建角色控制器的经验,您也会对了解 Unity 2023 中的创新感兴趣。令我惊讶的是, Rigidbody2D
组件添加了期待已久的Slide
方法,它通过允许更有效地在运动模式下使用Rigidbody2D
,大大简化了编写角色控制器的过程。以前,所有这些功能都必须手动实现。
如果您不仅想阅读文章,还想在实践中尝试它,我建议从 GitHub 存储库Treasure Hunters下载一个关卡模板,其中已包含用于测试角色的必要资产和现成关卡。
对于我们的平台游戏,我们制定了一条规则,即游戏将仅具有垂直和水平表面,并且重力将严格向下。这大大简化了平台游戏在初始阶段的创建,特别是如果您不想深入研究矢量数学的话。
将来,我可能会在我的项目《寻宝猎人》中偏离这些规则,在那里我将探索在 Unity 上为 2D 平台游戏创建机制。但这将是另一篇文章的主题。
由于我们决定沿水平面移动,因此角色的底部将是矩形。使用倾斜表面需要开发胶囊形碰撞器和滑动等附加机制。
首先,在场景中创建一个空对象并将其命名为 Captain — 这将是我们的主角。向对象添加Rigidbody2D
和BoxCollider2D
组件。将Rigidbody2D
类型设置为 Kinematic,这样我们就可以控制角色的运动,同时仍然利用 Unity 的内置物理功能。此外,通过激活“冻结旋转 Z”选项锁定角色沿 Z 轴的旋转。
您的设置应该如下图所示。
现在让我们为我们的船长添加一个外观。在 Assets/Textures/Treasure Hunters/Captain Clown Nose/Sprites/Captain Clown Nose/Captain Clown Nose with Sword/09-Idle Sword/Idle Sword 01.png 中找到纹理,并将其 Pixel Per Unit 值设置为 32。我们将在本课程中经常使用此值,因为我们的纹理是使用此分辨率创建的。将 Sprite Mode 设置为 Single,为方便起见,将 Pivot 设置为 Bottom。不要忘记单击 Apply 按钮来应用更改。确保所有设置都正确完成。
在本文中,我们不会涉及角色动画,因此目前我们只使用一个精灵。在 Captain 对象中,创建一个名为 Appearance 的嵌套对象,并向其添加 Sprite Renderer 组件,指定先前配置的精灵。
当我放大船长的图像时,我注意到由于精灵设置不正确,图像非常模糊。要解决这个问题,请在“项目”窗口中选择“Idle Sword 01”纹理,并将“过滤模式”设置为“点”(无过滤)。现在图像看起来好多了。
目前,对撞机的位置和我们船长的图像不匹配,而且对撞机对于我们的需求来说太大了。
让我们修复这个问题,并将角色的枢轴正确定位在底部。我们将此规则应用于游戏中的所有对象,以便无论大小如何,都可以将它们放置在同一级别。
为了便于管理此过程,请使用带有枢轴设置的切换工具手柄位置,如下所示。
下一步是调整英雄的碰撞器,使其枢轴点正好位于底部中心。碰撞器的大小应与角色的尺寸完全匹配。调整碰撞器的偏移和大小参数以及嵌套外观对象的位置,以达到必要的精度。
特别注意碰撞器的 Offset.X 参数:其值必须严格为 0。这将确保碰撞器相对于物体中心的对称放置,这对于后续角色左右旋转极为重要,其中 Transform.Scale.X 的值更改为 -1 和 1。碰撞器应保持在原位,并且视觉旋转看起来很自然。
首先,必须教会角色(无论是主角、NPC 还是敌人)如何与物理世界互动。例如,角色应该能够在平坦的表面上行走、从其上跳下,并受到重力的作用,将其拉回地面。
Unity 有一个内置的物理引擎,可以控制物体的运动、处理碰撞,并增加外力对物体的影响。所有这些都是使用Rigidbody2D
组件实现的。然而,对于角色来说,拥有一个更灵活的工具来与物理世界互动,同时让开发人员更好地控制物体的行为是很有用的。
正如我之前提到的,在 Unity 的早期版本中,开发人员必须自己实现所有这些逻辑。然而,在 Unity 2023 中, Rigidbody2D
组件中添加了一种新方法Slide
,允许灵活地控制物理对象,同时提供有关所执行运动的所有必要信息。
让我们首先创建一个CharacterBody
类,它将包含在物理世界中移动角色的基本逻辑。此类将在Kinematic
模式下使用Rigidbody2D
,我们已经将其添加到我们的角色中。因此,第一件事是添加对此组件的引用。
public class CharacterBody : MonoBehaviour { [SerializeField] private Rigidbody2D _rigidbody; }
有时,为了角色运动的动态,需要重力作用比平常更强。为了实现这一点,我们会添加一个重力影响因子,初始值为 1,并且该因子不能小于 0。
[Min(0)] [field: SerializeField] public float GravityFactor { get; private set; } = 1f;
我们还需要定义哪些对象将被视为不可通行的表面。为此,我们将创建一个字段,以便我们指定必要的图层。
[SerializeField] private LayerMask _solidLayers;
我们将限制角色的速度,以防止它们的速度过快,例如由于某些外部影响,包括重力。我们将初始值设置为 30,并限制在检查器中设置小于 0 的值的能力。
[Min(0)] [SerializeField] private float _maxSpeed = 30;
当沿着表面移动时,如果角色与表面之间的距离足够小,我们希望角色始终紧贴表面。
[Min(0)] [SerializeField] private float _surfaceAnchor = 0.01f;
虽然我们决定游戏中的表面只能是水平或垂直的,但为了以防万一,我们将指定角色可以稳定站立的表面的最大倾斜角度,初始值为 45º。
[Range(0, 90)] [SerializeField] private float _maxSlop = 45f;
通过检查器,我还想查看角色的当前速度和它们的状态,所以我将添加两个具有属性SerializeField
的字段。
[SerializeField] private Vector2 _velocity; [field: SerializeField] public CharacterState State { get; private set; }
是的,这里我引入了一个新的、尚未定义的实体CharacterState
。我们将进一步讨论这一点。
为了简化我们的平台游戏的开发,我们只定义两个主要角色状态。
第一种是接地,角色稳稳地站在地面上。在此状态下,角色可以自由地沿着地面移动并跳下。
第二种是Airborne ,即自由落体状态,角色在空中。角色在此状态下的行为可能因平台游戏的具体情况而异。在接近现实的一般情况下,角色在初始动量的影响下移动,无法影响其行为。然而,在平台游戏中,物理学通常会简化,以方便使用和游戏动态:例如,在许多游戏中,即使在自由落体时,我们也可以控制角色的水平移动。在我们的例子中,这也是可能的,以及流行的双跳机制,允许在空中进行额外的跳跃。
让我们在代码中表示角色的状态:
/// <summary> /// Describes the state of <see cref="CharacterBody"/>. /// </summary> public enum CharacterState { /// <summary> /// The character stays steady on the ground and can move freely along it. /// </summary> Grounded, /// <summary> /// The character is in a state of free fall. /// </summary> Airborne }
值得注意的是,还有更多状态。例如,如果游戏包含倾斜的表面,角色可能处于滑动状态,无法自由向右或向左移动,但可以沿斜坡滑下。在这种状态下,角色也可以跳跃,从斜坡上推开,但只能沿斜坡的方向推开。另一种可能的情况是沿着垂直墙壁滑动,此时重力的影响减弱,角色可以水平推开。
我们已经定义了一个私有字段_velocity
,但我们需要能够从外部获取和设置该值,同时限制角色的最大速度。这需要将给定的速度向量与最大允许速度进行比较。
这可以通过计算速度向量的长度(或者用数学术语来说,其幅度)来实现。 Vector2
结构已经包含一个magnitude
属性,允许我们执行此操作。 因此,如果传递的向量的幅度超过允许的最大速度,我们应该保持向量的方向但限制其幅度。 为此,我们将_maxSpeed
乘以归一化速度向量(归一化向量是具有相同方向但幅度为 1 的向量)。
代码如下:
public Vector2 Velocity { get => _velocity; set => _velocity = value.magnitude > _maxSpeed ? value.normalized * _maxSpeed : value; }
现在让我们仔细看看如何计算矢量的幅值。它由以下公式定义:
计算平方根是一项资源密集型操作。尽管在大多数情况下速度不会超过最大值,但我们仍然必须每个周期至少执行一次此比较。但是,如果我们将矢量幅度的平方与最大速度的平方进行比较,我们可以大大简化此操作。
为此,我们引入一个附加字段来存储最大速度的平方,并在Awake
方法中计算一次:
private float _sqrMaxSpeed; private void Awake() { _sqrMaxSpeed = _maxSpeed * _maxSpeed; }
现在可以更优化地设置速度:
public Vector2 Velocity { get => _velocity; set => _velocity = value.sqrMagnitude > _sqrMaxSpeed ? value.normalized * _maxSpeed : value; }
这样就避免了不必要的计算,提高了处理角色移动速度的性能。
正如我前面提到的,Unity 增加了一个新的Slide()
方法,这将大大简化我们CharacterBody
的开发。但是在使用该方法之前,需要定义对象在空间中移动的规则。此行为由Rigidbody2D.SlideMovement
结构设置。
让我们引入一个新字段_slideMovement
并设置它的值。
private Rigidbody2D.SlideMovement _slideMovement; private void Awake() { _sqrMaxSpeed = _maxSpeed * _maxSpeed; _slideMovement = CreateSlideMovement(); } private Rigidbody2D.SlideMovement CreateSlideMovement() { return new Rigidbody2D.SlideMovement { maxIterations = 3, surfaceSlideAngle = 90, gravitySlipAngle = 90, surfaceUp = Vector2.up, surfaceAnchor = Vector2.down * _surfaceAnchor, gravity = Vector2.zero, layerMask = _solidLayers, useLayerMask = true, }; }
需要说明的是, maxIterations
决定了物体在碰撞后可以改变方向的次数。例如,如果角色在墙壁旁边的空中,并且玩家试图在重力作用下将其向右移动。因此,每次调用Slide()
方法时,都会设置一个向右向下的速度矢量。撞到墙壁后,会重新计算运动矢量,物体将继续向下移动。
在这种情况下,如果将maxIterations
值设置为 1,则对象会撞到墙壁、停下来并卡在那里。
maxIterations
和layerMask
的值是之前定义的。有关其他字段的详细信息,请参阅官方结构文档。
现在一切就绪,Captain 可以开始行动了。我们将在FixedUpdate
中执行此操作 — Unity 中用于处理物理的回调。在过去几年中,Unity 团队显著改进了 2D 物理的处理。目前,可以在Update
回调中处理,甚至可以自行调用所需方法。
但是,在本例中,我们将使用传统且经过验证的FixedUpdate
方法。在继续之前,值得提一下Time.fixedDeltaTime
的值。
为了确保游戏物理的可预测性,模拟以固定的时间间隔迭代进行。这保证了 FPS 或延迟的变化不会影响对象行为。
在每个周期开始时,我们将考虑重力对物体的影响。由于重力由自由落体加速度矢量给出,我们可以通过以下公式计算物体随时间Δt
Δv
其中a
是物体的恒定加速度。在我们的例子中,它是重力加速度,考虑到我们引入的系数 — Physics2D.gravity * GravityFactor
。因此, Δv
可以按如下方式计算:
Time.fixedDeltaTime * GravityFactor * Physics2D.gravity
我们改变速度的最终结果如下所示:
Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity;
现在我们可以执行角色的刚体运动:
var slideResults = _rigidbody.Slide( _velocity, Time.fixedDeltaTime, _slideMovement);
变量slideResults
是SlideResults
结构的一个值,用于存储移动的结果。对于我们来说,此结果的主要字段是slideHit
移动过程中与表面碰撞的结果)和surfaceHit
(向下投射的结果),这将有助于确定角色是否站在稳定的表面上。
当与表面发生碰撞时,限制角色朝向该表面的速度至关重要。一个简单的例子是,如果角色静止站在地面上,它们不应该在重力的影响下继续加速。在每个周期结束时,它们的速度应该为零。同样,当向上移动并撞到天花板时,角色应该失去所有垂直速度并开始向下移动。
碰撞的结果slideHit
和surfaceHit
由RaycastHit2D
结构的值表示,其中包括碰撞表面的法线。
速度限制可以通过从速度向量本身中减去原始速度向量在碰撞法线上的投影来计算。这是使用点积完成的。让我们编写一个执行此操作的方法:
private static Vector2 ClipVector(Vector2 vector, Vector2 hitNormal) { return vector - Vector2.Dot(vector, hitNormal) * hitNormal; }
现在让我们将该方法集成到FixedUpdate
中。这里,对于surfaceHit
,我们只会在向下时限制速度,因为确定物体是否在表面上的转换始终是为了检查与地面的接触。
private void FixedUpdate() { Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity; var slideResults = _rigidbody.Slide( _velocity, Time.fixedDeltaTime, _slideMovement); if (slideResults.slideHit) { _velocity = ClipVector(_velocity, slideResults.slideHit.normal); } if (_velocity.y <= 0 && slideResults.surfaceHit) { var surfaceHit = slideResults.surfaceHit; _velocity = ClipVector(_velocity, surfaceHit.normal); } }
这种实现可以正确管理角色的运动,避免与各种表面碰撞时产生不必要的加速,并保持游戏中角色的运动可预测且流畅。
在每个循环结束时,需要确定角色是处于固体表面上(接地状态)还是处于自由落体状态(或者,按照我们的定义,受控落体 - 空中状态)。
要将角色视为处于接地状态,首先,他们的垂直速度必须为零或负,我们通过_velocity.y
的值来确定。
另一个重要标准是角色脚下是否存在表面,我们可以从 Rigidbody 运动结果中识别出这一点,即通过surfaceHit
的存在。
第三个因素是表面倾斜的角度,我们根据该表面的法线,也就是surfaceHit.normal
的值来分析,需要将该角度与_maxSlop
(角色可以稳定站立的表面的最大可能角度)进行比较。
对于完全垂直的表面,法线将严格水平,即其矢量值将为 (1, 0) 或 (-1, 0)。对于水平表面,法线的值将为 (0, 1)。倾斜角度越小, y
的值越大。对于角度alpha
,该值可以计算如下:
由于我们的角度以度为单位,而函数 $\cos$ 需要弧度,因此公式转换为:
为此,我们引入一个新字段并在Awake
方法中对其进行计算。
private float _minGroundVertical; private void Awake() { _minGroundVertical = Mathf.Cos(_maxSlop * Mathf.PI / 180f); //... }
现在让我们在FixedUpdate
中更新代码,检查上述所有条件。
if (_velocity.y <= 0 && slideResults.surfaceHit) { var surfaceHit = slideResults.surfaceHit; Velocity = ClipVector(_velocity, surfaceHit.normal); State = surfaceHit.normal.y >= _minGroundVertical ? CharacterState.Grounded : CharacterState.Airborne; } else { State = CharacterState.Airborne; }
这种逻辑可以让我们准确地判断角色何时在地面上,并对其状态的变化做出正确的反应。
现在我们的 CharacterBody 组件已准备就绪,最后一步是将其添加到我们的 Captain。在场景中,选择 Captain 对象并将CharacterBody
组件添加到其中。
不要忘记如上图所示配置 Rigidbody。将重力因子设置为 3,并选择 Solid Layer 的默认选项。
现在您可以开始游戏并尝试设置不同的速度值来观察我们的角色在场景中如何移动。
当然,我们还需要添加角色控制。不过,这篇文章已经很长了,所以我将在下一篇文章中详细介绍使用新输入系统对角色的控制:“在 Unity 中创建 2D 角色控制器:第 2 部分”。
您可以在此处下载本文中描述的完整项目: Treasure Hunters ,并在实践中检查所有内容,如果遇到任何困难。开发角色控制器是创建 2D 平台游戏的关键方面,因为它决定了游戏的进一步发展。它会影响新功能添加到主要英雄或敌人行为的难易程度。因此,了解基础知识对于能够独立开发自己的游戏非常重要。