最近一次在GameJam里做了一款平台跳跃游戏,从头开始开发了一个平台跳跃的移动系统,在这里记录一下。
输入的映射
这个游戏的移动输入非常简单,只有左右两个方向和跳跃,甚至没有冲刺的设定。所以只需要在inputSystem中映射一个float代表移动方向,和一个button代表跳跃操作。

基础的位移代码
为了保证移动的手感和操作的精度,我们选择在FixedUpdate中进行所有的移动操作,而具体的移动代码则采用了RigidBody2D.MovePosition的方法,这个方法会给予刚体一个能在下一次物理检测时精确到达目标位置的速度,同时也方便碰撞系统的检测。
于是,最基础的几个属性就出现了。
public Rigidbody2D rb;
public CapsuleCollider2D capsule;
[Tooltip("当前速度")]public Vector2 currentVelocity = Vector2.zero;
[Tooltip("水平移动方向,由playerStatus传入")]public float HDirection;
[Tooltip("垂直移动方向,由playerStatus传入")]public float VDirection;
其中,HDirection和VDirection均是由外部的PlayerStatus 所传入的,而currentVelocity则表示我们想要的给角色的速度。于是移动的代码就是。
rb.MovePosition(rb.position + currentVelocity * Time.fixedDeltaTime);
水平位移处理
紧接着就是处理水平的位移。为了使的移动效果更加真实,我们通常会设置一个加速和减速的过程,而根据初中物理,我们都知道v = v_0 + at,也就是加速度公式。但是这里我们并不使用这个公式来控制速度。主要原因是,速度的变化并非完全线性,也就是说加速度也是动态变化的,这样就很复杂了。于是这里我选择直接操作速度,而为了能够精确的控制速度的变化,我使用了Unity的AnimationCurve。
[Header("水平移动")]
[Tooltip("最大移动速度")] public float maxHSpeed = 4;
[Tooltip("加速曲线")] public AnimationCurve accelerateCurve;
[Tooltip("减速曲线")] public AnimationCurve decelerateCurve;
加速曲线
减速曲线
使用这两个曲线,就可以精确的控制每秒的速度变化,可以看到,我控制了在0.05秒中加速到最快速断,在0.1秒中减速到0。当然为了知道当前的时间,还需要再FixedUpdate中通过逻辑统计时间。不过由于fixedDeltaTime是固定的,所以某种意义上曲线映射的也是帧数。
/// <summary>
/// 处理水平移动
/// </summary>
private void ProcessHorizontal()
{
HDirectionCumulativeTime += Time.fixedDeltaTime;
// 在地面的情况
currentVelocity.x = HDirection * accelerateCurve.Evaluate(HDirectionCumulativeTime) * maxHSpeed;
if (Mathf.Abs(currentVelocity.x) > maxHSpeed)
{
currentVelocity.x = maxHSpeed * Mathf.Sign(currentVelocity.x);
}
// 减速的情况
if (Mathf.Abs(HDirection) < 0.1f && Mathf.Abs(currentVelocity.x) > 0)
{
currentVelocity.x = maxHSpeed * decelerateCurve.Evaluate(HDirectionCumulativeTime);
}
}跳跃处理
紧接着就是跳跃系统的处理了,众所周知,跳跃是平台跳跃的灵魂所在,一个平台跳跃的上限如何很大程度的取决于跳跃的手感,而且我们这个游戏还是平台跳跃解密的类型,跳跃的精确程度就更加的重要了。考虑到这一点,在跳跃中我不打算以时间为依据来控制速度,而是用跳跃高度来控制。这样我们可以保证在达到我们的限定高度时一定会停下来,手感的部分就可以通过曲线来微调,于是跳跃的相关参数就的出来了。
[Header("垂直移动")]
[Tooltip("最大垂直速度")] public float maxVSpeed = 4;
[Tooltip("最小跳跃高度")] public float minJumpHeight = 1f;
[Tooltip("最大跳跃高度")] public float maxJumpHeight = 5f;
[Tooltip("跳跃速度变化曲线")] public AnimationCurve jumpAccelerateCurve;
[Tooltip("重力加速度")] public float gravityAccelerate = 9.8f;
[Tooltip("起始跳跃点")]public Vector2 jumpStartPos;
[Tooltip("跳跃高度")]public float jumpHeight;其中minJumpHeight代表最小的跳跃高度,只要玩家按了跳跃键就一定能到达这个高度。而jumpAccelerateCurve是一个x从0到1,y从1到0变化的曲线,我们将x缩放到maxJumpHeight的长度,将y缩放到maxVSpeed的长度,就可以使得在一开始的时候速度最快,在最高点时速度为0。(ps. 由于浮点数的误差实际上并不能)
private void ProcessVertical()
{
// 处理按住跳跃
if (currentStatus == MoveStatus.Ground && VDirection > 0)
{
// 从地面起跳
ChangeStatus(MoveStatus.Jump);
}
// 当玩家松手的时候,根据高度来判断是否需要下落
if (currentStatus == MoveStatus.Jump && VDirection <= 0)
{
if (jumpHeight >= minJumpHeight)
{
ChangeStatus(MoveStatus.Fall);
}
}
if (isCeiling || !isGrounded && currentStatus == MoveStatus.Ground)
{
ChangeStatus(MoveStatus.Fall);
}
// 向上加速
if (currentStatus == MoveStatus.Jump)
{
currentVelocity.y = jumpAccelerateCurve.Evaluate(jumpHeight / maxJumpHeight) * maxVSpeed;
// 处理浮点数精度误差
if (Mathf.Abs(currentVelocity.y) < 0.1f)
{
currentVelocity.y = 0;
}
// 到达最高点之后要开始下落了
if (jumpHeight >= maxJumpHeight - 0.1f || currentVelocity.y == 0 && jumpHeight > 0)
{
ChangeStatus(MoveStatus.Fall);
}
}
// 处理下落重力加速度
if (currentStatus == MoveStatus.Fall)
{
currentVelocity.y -= gravityAccelerate * Time.fixedDeltaTime;
if (isGrounded)
{
ChangeStatus(MoveStatus.Ground);
}
}
if (currentStatus == MoveStatus.Fall || currentStatus == MoveStatus.Jump)
{
jumpHeight = rb.position.y - jumpStartPos.y;
}
}
private void ChangeStatus(MoveStatus status)
{
switch (status)
{
case MoveStatus.Jump:
jumpStartPos = rb.position;
break;
case MoveStatus.ClimbJump:
jumpStartPos = rb.position;
break;
case MoveStatus.Fall:
currentVelocity.y = 0;
break;
case MoveStatus.Ground:
jumpHeight = 0;
currentVelocity.y = 0;
break;
default:
break;
}
OnStatusChange?.Invoke(status);
currentStatus = status;
}由于跳跃的逻辑有着相对复杂的判定机制,于是这里引入了状态机来处理跳跃的状态变换,同时也是为之后的抓墙以及登墙跳做准备。
除此之外,在空中的时候,玩家的水平移动应当也受到限制,这里我仍然使用了曲线来进行处理,按照横坐标高度,纵坐标速度的方式进行处理。但是有一点,如果玩家在地面上已经有了一个速度的话,除非在空中要移动的更加快或者说反方向,我们需要让玩家可以保持这个速度。于是我选择在起跳的时候记录起跳速度来进行判断。
[Tooltip("起跳时的水平方向达到的最大速度")] [SerializeField]private float jumpHVelocity = 0f;
// 在地面的情况
if (isGrounded)
{
currentVelocity.x = HDirection * accelerateCurve.Evaluate(HDirectionCumulativeTime) * maxHSpeed;
jumpHVelocity = currentVelocity.x;
}
else
// 在空中的情况
{
float newSpeed = HDirection * accelerateCurve.Evaluate(HDirectionCumulativeTime) * skyMoveCurve.Evaluate(jumpHeight) * maxHSpeed;
if (HDirection != 0 && !Mathf.Sign(HDirection).Equals(Mathf.Sign(jumpHVelocity)) || Mathf.Abs(newSpeed) > Mathf.Abs(jumpHVelocity))
{
currentVelocity.x = newSpeed;
}
}
if (Mathf.Abs(currentVelocity.x) > maxHSpeed)
{
currentVelocity.x = maxHSpeed * Mathf.Sign(currentVelocity.x);
}方向检测
在上面的跳跃系统中可以看到我们使用了isGround来进行判断,而这个isGround就是我们需要实时检测的部分。处理在地面之外,还需要顶头和前方的检测,这里顶头和地面为了和碰撞箱所像匹配,我都采用了OverlapCircle来进行检测,而爬墙的位置检测我则使用了OverlapBox 。同时为了能够方便的看出范围,我还使用了OnDrawGizmos 来绘制可视化线框。
[Header("检测系统")]
public LayerMask groundCheckLayer;
public LayerMask wallCheckLayer;
public Transform groundCheckPos;
public Color groundCheckCircleColor = Color.red;
public float groundCheckRadius;
public Transform ceilingCheckPos;
public float ceilingCheckRadius;
public Color ceilingCheckCircleColor = Color.green;
public Transform forwardCheckPos;
public Vector2 forwardCheckSize;
public Vector2 forwardPredictSize;
public Color forwardCheckCircleColor = Color.blue;
public Color forwardPredictCircleColor = Color.cyan;
void OnDrawGizmos()
{
Gizmos.color = ceilingCheckCircleColor;
Gizmos.DrawWireSphere(ceilingCheckPos.position, ceilingCheckRadius);
Gizmos.color = groundCheckCircleColor;
Gizmos.DrawWireSphere(groundCheckPos.position, groundCheckRadius);
Gizmos.color = forwardCheckCircleColor;
Gizmos.DrawWireCube(forwardCheckPos.position, forwardCheckSize);
Gizmos.color = forwardPredictCircleColor;
Gizmos.DrawWireCube(forwardCheckPos.position, forwardPredictSize);
}
/// <summary>
/// 玩家各个方向的检测
/// </summary>
private void StatusCheck()
{
// 地面检测
ContactFilter2D groundContactFilter = new ContactFilter2D();
groundContactFilter.SetLayerMask(groundCheckLayer);
var groundResult = new List<Collider2D>();
Physics2D.OverlapCircle(groundCheckPos.position, groundCheckRadius, groundContactFilter, groundResult);
isGrounded = groundResult.Any(it => it.gameObject != gameObject);
// 顶头检测
ContactFilter2D ceilingContactFilter = new ContactFilter2D();
ceilingContactFilter.SetLayerMask(groundCheckLayer);
var ceilingResult = new List<Collider2D>();
Physics2D.OverlapCircle(ceilingCheckPos.position, ceilingCheckRadius, ceilingContactFilter, ceilingResult);
isCeiling = ceilingResult.Any(it => it.gameObject != gameObject);
// 抓墙检测
ContactFilter2D forwardContactFilter = new ContactFilter2D();
forwardContactFilter.SetLayerMask(wallCheckLayer);
var wallResult = new List<Collider2D>();
Physics2D.OverlapBox(forwardCheckPos.position, forwardCheckSize, 0f, forwardContactFilter, wallResult);
isClimbing = wallResult.Any(it => it.gameObject != gameObject);
// 抓墙预测
wallResult.Clear();
Physics2D.OverlapBox(forwardCheckPos.position, forwardPredictSize, 0f, forwardContactFilter, wallResult);
isPredictClimbing = wallResult.Any(it => it.gameObject != gameObject);
}爬墙与登墙跳
接下来终于是到了最复杂最核心的部分了,也就是玩家的抓墙与登墙跳的逻辑。
进入爬墙
既然要做登墙跳,首先就要能够爬墙。这里我设置成了只有下落的时候才会进入爬墙状态,之所以这么做是因为玩家在跳跃的时候一般都希望能够跳到最高的地方,如果在中途就开始爬墙了就会感觉像是被拉住一样。于是状态转移的代码就是这样。
// 只有Fall的时候才要进行抓墙
else if (currentStatus == MoveStatus.Fall && isClimbing && isClimbEnable)
{
ChangeStatus(MoveStatus.Climb);
}在玩家上墙之后,我们还得让玩家以比正常下落速度更慢的速度下落,于是:
[Tooltip("最大抓墙时间")]public float maxClimbTime = 0.5f;
[Tooltip("抓墙的时候的下落速度")]public float climbDownSpeed;
[Tooltip("判定离墙的最下速度")] public float minLeaveWallTime;
// 在Climb的时候下落更加的缓慢,同时要有登墙跳的检测
else if (currentStatus == MoveStatus.Climb)
{
climbCumulativeTime += Time.fixedDeltaTime;
currentVelocity.y -= climbDownSpeed * Time.fixedDeltaTime;
if (VDirection > 0)
{
ChangeStatus(MoveStatus.ClimbJump);
}
else if (climbCumulativeTime > maxClimbTime || HDirectionCumulativeTime > minLeaveWallTime && !isClimbing )
{
ChangeStatus(MoveStatus.Fall);
}
else if (isGrounded)
{
ChangeStatus(MoveStatus.Ground);
}
}可以看到,除了下落速度向的逻辑之外,还进行了离开墙的检测,其中HDirectionCumulativeTime > minLeaveWallTime && !isClimbing 主要是给向反方向离开墙的时候留出一个最小的时间给予玩家蹬墙跳的操作余地。
除此之外,我们还要禁止玩家在同一个墙面上连续爬墙,也就是在一面墙上如果下落了,在落地之前都不能再进入爬墙状态。
if (currentStatus == MoveStatus.Climb && status == MoveStatus.Fall)
{
isClimbEnable = false;
}
else
{
isClimbEnable = true;
}处理蹬墙跳
能够自由爬墙之后,就要处理玩家的登墙跳了,而这里的蹬墙跳有两种,第一种是向反方向的灯墙跳,另一种是向同一个方向的蹬墙跳。由于我们是一个平台跳跃解密游戏,不能让玩家在一面墙上一直向上跳,所以这里就设置了让一个方向的蹬墙跳只有一次。
if (currentStatus == MoveStatus.Fall && status == MoveStatus.Climb)
{
if (climbDirection != 0 && Mathf.Sign(transform.right.x).Equals(Mathf.Sign(climbDirection)))
{
isClimbEnable = false;
return;
}
}
// 在转换状态的时候记录方向
case MoveStatus.Climb:
climbCumulativeTime = 0;
climbDirection = transform.right.x;
break;除此之外,在蹬墙跳的时候,不需要进行空中移动的限制
else if (currentStatus == MoveStatus.ClimbJump)
{
currentVelocity.x = HDirection * accelerateCurve.Evaluate(HDirectionCumulativeTime) * maxHSpeed;
}连续跳跃限制
如果玩家一直按住跳跃键的话,角色就会一直跳跃不停,看起来会很奇怪,所以要设置成只有松手之后重新按压才会处理。这里就不让MoveUnit来进行了,毕竟输入还是由PlayerStatus来进行管理的。
using System;
using System.Collections;
using System.Collections.Generic;
using Character;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(MoveUnit)), RequireComponent(typeof(RotateUnit))]
public class PlayerStatus : MonoBehaviour
{
#region PlayerUnit
[Header("PlayerUnit")]
public MoveUnit moveUnit;
public RotateUnit rotateUnit;
#endregion
/// <summary>
/// 水平输入
/// </summary>
InputAction _horizontalAction;
/// <summary>
/// 垂直输入
/// </summary>
InputAction _verticalAction;
/// <summary>
/// 用于判断垂直输入是否松手了
/// </summary>
bool _hasJump;
void Start()
{
SetInputActions(
PlayerInputController.Instance.playerInput.Player.Move,
PlayerInputController.Instance.playerInput.Player.Jump
);
moveUnit.OnStatusChange += OnMoveStatusChange;
}
void Update()
{
// 检测水平输入
float direction = _horizontalAction.ReadValue<float>();
if (direction * moveUnit.HDirection <= 0)
{
moveUnit.SetHDirection(direction);
}
// 检测垂直输入
float vDirection = _verticalAction.IsPressed()?1f:0f;
if (!_verticalAction.IsPressed())
{
_hasJump = false;
}
if (!vDirection.Equals(moveUnit.VDirection))
{
moveUnit.SetVDirection(vDirection);
}
// 调整方向
rotateUnit.SetHDirection(moveUnit.currentVelocity.normalized.x);
}
void FixedUpdate()
{
float vDirection = _verticalAction.IsPressed()?1f:0f;
if (_hasJump && vDirection.Equals(1f))
{
moveUnit.SetVDirection(0f);
}
}
void Reset()
{
moveUnit = GetComponent<MoveUnit>();
rotateUnit = GetComponent<RotateUnit>();
}
void OnDestroy()
{
moveUnit.OnStatusChange -= OnMoveStatusChange;
}
public void SetInputActions(InputAction horizontalAction, InputAction verticalAction)
{
_horizontalAction = horizontalAction;
_verticalAction = verticalAction;
}
private void OnMoveStatusChange(MoveStatus status)
{
switch (status)
{
case MoveStatus.Fall:
_hasJump = true;
break;
case MoveStatus.Climb:
_hasJump = true;
break;
default:
break;
}
}
}