简单2D游戏角色控制器的实现
学习自 Matthew-J-Spencer 的 Ultimate 2D Controller
需求
-
碰撞检测 ✔
-
左右横向移动 ✔
-
跳跃
-
一次跳跃 ✔
-
中断跳跃 ✔
-
二段/多段跳
-
蹬墙跳
-
-
下落/重力 ✔
-
容错机制
-
可以在离开边缘短时间内起跳 ✔
-
在差一点点就可跳上平台时,帮用户上平台 ✔
-
起跳时碰到了一点点上平台的边缘,让用户不会被平台阻挡跳跃 ✔
-
在还没完全落地时,就可以按跳跃键连续跳跃了 ✔
-
-
Dash 冲刺
在Matthew-J-Spencer的最初版本代码中,仅完成了✔的部分功能。我会在后续尝试将其他功能补齐。
流程
代码
private void Update()
{
GatherInput(); // 获取输入
RunCollisionChecks(); // 碰撞检测
CalculateWalk(); // 水平移动(计算水平速度)
// 下坠/重力(计算垂直速度)
CalculateJumpApex();
CalculateGravity();
CalculateJump(); // 跳跃(设置垂直速度)
MoveCharacter(); // 移动角色
}
获取输入
在Player中包含有一个FrameInput类型的属性Input
,用于记录当前帧接收到的输入情况。
代码
public struct FrameInput
{
public float X; // 水平方向的输入值
public float Y; // 垂直方向的输入值
public bool JumpDown; // 跳跃键按下为true
public bool JumpUp; // 跳跃键抬起为true
}
这里并没用到Y,在后续添加向各个方向Dash的功能时,就要用到Y啦,不过目前我们还没做Dash功能。
private void GatherInput()
{
Input = new FrameInput
{
JumpDown = UnityEngine.Input.GetButtonDown("Jump"),
JumpUp = UnityEngine.Input.GetButtonUp("Jump"),
X = UnityEngine.Input.GetAxisRaw("Horizontal")
};
// 这里记录了松开跳跃键的时间,
// 目的是实现容错机制中的第三条,还没完全落地时,即可再次跳跃,
if (Input.JumpDown)
{
_lastJumpPressed = Time.time;
}
}
碰撞检测
碰撞检测依靠向上下左右四个方向分别发射若干条射线,进行射线检测实现的。
Player自身无需Rigidbody2D或者Collider2D, 地板、墙、平台等,须包含Collider2D组件,同时设定好Layer。
射线检测
如下图,我们在Player的四个方向,分别发射了3条射线(图中蓝色短线),并且我们维护四个bool类型的变量_colUp
,_colRight
,_colDown
,_colLeft
用来表示在四个方向上是否有发生碰撞。
在这个过程中,最重要的当属更新_colDown
了,因为他于我们的跳跃功能息息相关。这里有两个特殊情况需要我们处理一下:
-
_colDown
为true,但当前帧向下的射线检测值为false,说明这是我们起跳或者离开平台边缘的第一帧 -
_colDown
为false,但是当前帧向下的射线检测值为true,说明这是我们落到地上的第一帧
对于情况2,我们要将Player的LandingThisFrame
属性设为true,以表示已经落地,从而使得Player可以再次起跳。
对于情况1,我们要用_timeLeftGrounded
记录当前的时间,这是为了实现“可以在离开边缘短时间内起跳”这一容错机制,具体实现细节在跳跃/下降与重力小结讲。
代码
[Header("COLLISION")]
[SerializeField, Tooltip("角色的碰撞检测盒")]
private Bounds _characterBounds;
[SerializeField, Tooltip("射线检测的Layer")]
private LayerMask _groundLayer;
[SerializeField, Tooltip("每个方向发射的射线数量")]
private int _detectorCount = 3;
[SerializeField, Tooltip("射线检测距离")]
private float _detectionRayLength = 0.1f;
[SerializeField, Tooltip("发射线区域与边缘的缓冲区大小")]
[Range(0.1f, 0.3f)]
private float _rayBuffer = 0.1f; //增大数值可以尽量避免侧向的射线碰撞到地板
private RayRange _raysUp, _raysRight, _raysDown, _raysLeft; // 四个方向的RayRange参数
private bool _colUp, _colRight, _colDown, _colLeft; // 分别表示四个方向是否发生碰撞
private float _timeLeftGrounded; // 记录离开地面时的时间
/// <summary>
/// 通过在四个方向上发射若干射线,进行四个方向上的碰撞检测
/// </summary>
private void RunCollisionChecks()
{
// 初始化四个方向上的RagRange参数
CalculateRayRanged();
// Ground
LandingThisFrame = false;
var groundedCheck = RunDetection(_raysDown);
if (_colDown && !groundedCheck) _timeLeftGrounded = Time.time; // 对应情况1
else if (!_colDown && groundedCheck)
{
_coyoteUsable = true; // 这个参数后面再讲
// 对应情况2
LandingThisFrame = true;
}
_colDown = groundedCheck;
_colUp = RunDetection(_raysUp);
_colLeft = RunDetection(_raysLeft);
_colRight = RunDetection(_raysRight);
bool RunDetection(RayRange range)
{
return EvaluateRayPositions(range).Any(point => Physics2D.Raycast(point, range.Dir, _detectionRayLength, _groundLayer));
}
}
/// <summary>
/// 计算四个方向上发射射线的范围;
/// 即确定射线的起点线,终点线,方向。
/// 影响参数:_raysUp, _raysRight, _raysDown, _raysLeft
/// </summary>
private void CalculateRayRanged()
{
// 根据当前位置和参数中设定的检测盒大小,生成一个检测盒,以检测盒四个边界为准,修改RayRange
var b = new Bounds(transform.position, _characterBounds.size);
_raysDown = new RayRange(b.min.x + _rayBuffer, b.min.y, b.max.x - _rayBuffer, b.min.y, Vector2.down);
_raysUp = new RayRange(b.min.x + _rayBuffer, b.max.y, b.max.x - _rayBuffer, b.max.y, Vector2.up);
_raysLeft = new RayRange(b.min.x, b.min.y + _rayBuffer, b.min.x, b.max.y - _rayBuffer, Vector2.left);
_raysRight = new RayRange(b.max.x, b.min.y + _rayBuffer, b.max.x, b.max.y - _rayBuffer, Vector2.right);
}
private IEnumerable<Vector2> EvaluateRayPositions(RayRange range)
{
for (var i = 0; i < _detectorCount; i++)
{
var t = (float)i / (_detectorCount - 1);
yield return Vector2.Lerp(range.Start, range.End, t);
}
}
各参数效果展示
在上文 发射射线示意图 中,_characterBounds
的Extend
属性的值为{x = 0.5, y = 0.65, z = 0.0}
,_detectorCount
为3,_rayBuffer
为0.1,_detectionRayLength
为0.1。
当_characterBounds
的Extend
属性的值为{x = 0.65, y = 0.25, z = 0.0}
时,效果如下
当_detectorCount
为10时,效果如下
当_rayBuffer
为0.3时,效果如下
当_detectionRayLength
为0.5时,效果如下
水平移动
所有的移动处理(包括后面的跳跃,重力下坠),均仅对Player的_currentHorizontalSpeed
,_currentVerticalSpeed
属性进行修改,在每帧的最后一步去根据Player的水平/垂直速度,修改transform.position
。
为了优化手感,我们在跳跃过程中,会小幅增加水平移动的最大速度,并且越接近跳跃最高点,增幅越大。具体思路在跳跃/下降与重力中详细讲。
代码
[Header("WALKING")]
[SerializeField, Tooltip("加速度")]
private float _acceleration = 90;
[SerializeField, Tooltip("最大移动速度")]
private float _moveClamp = 13;
[SerializeField, Tooltip("减速度")]
private float _deAcceleration = 60f;
[SerializeField, Tooltip("在跳跃中对移速的加成系数")]
private float _apexBonus = 2;
/// <summary>
/// 根据按键和碰撞情况,修改Player水平速度,实现左右移动
/// 影响参数:_currentHorizontalSpeed
/// </summary>
private void CalculateWalk()
{
// 有“Horizontal”按键按下
if (Input.X != 0)
{
// 设置水平速度,根据加速度加速
_currentHorizontalSpeed += Input.X * _acceleration * Time.deltaTime;
// 将速度限制在最大移动速度范围内
_currentHorizontalSpeed = Mathf.Clamp(_currentHorizontalSpeed, -_moveClamp, _moveClamp);
// 根据跳跃高度对速度给予加成
var apexBonus = Mathf.Sign(Input.X) * _apexBonus * _apexPoint;
_currentHorizontalSpeed += apexBonus * Time.deltaTime;
}
else
{
// 松开按键后,逐渐减速
_currentHorizontalSpeed = Mathf.MoveTowards(_currentHorizontalSpeed, 0, _deAcceleration * Time.deltaTime);
}
// 如果左右两侧撞到墙壁,则将速度强制设成 0,不允许穿墙
if (_currentHorizontalSpeed > 0 && _colRight || _currentHorizontalSpeed < 0 && _colLeft)
{
_currentHorizontalSpeed = 0;
}
}
跳跃/下降与重力
首先在这里我们手动模拟了一个重力系统。为了让游戏操作“手感更好”,我们并没有按照真实的物理规律去实现重力,而是做了一些改动:
-
限制了下落的最大速度
这使得我们从较高的平台向下跳时,在跳跃过程中,不会由于重力一直加速导致速度过快,难以操控。 -
在跳到最高处附近时给予水平移动一些速度补偿
这使得我们在跳跃过程中可以更流畅的调整人物的横向移动。
同时在跳跃时,我们做了以下几点:
-
中断跳跃(即在跳跃上升中如果松开空格,则会即刻开始下落)
这个是通过临时将Player的向下加速度变大很多来实现。 -
可以在离开边缘短时间内起跳
这个是通过在前文中提到的_timeLeftGrounded
来实现的,只要当前时间减去_timeLeftGrounded
小于我们设定的阈值_coyoteTimeThreshold
,即使现在我们已经走出了平台边缘(悬空了),我们仍可以跳跃。 -
在还没完全落地时,就可以按跳跃键连续跳跃了
由于人眼并不能很精确的分辨Player是否已经落下了(当Player就快要落到地面但是还没完全接触地面时),如果每次必须在落地那一帧之后才可以再次起跳的话,会导致有时候我们以为Player已经落下了,便按下了跳跃键企图让Player连续的跳跃。当然结果就是Player只会呆呆地站在原地,这样会使得我们的操作手感不佳。故而我们在前文获取输入中,只要跳跃键被按下,就用_lastJumpPressed
参数不断记录当前时间,如果在落地前_jumpBuffer
时间内按下过跳跃键(即_lastJumpPressed + _jumpBuffer > Time.time
时),则会在落地后自动起跳,实现流畅的连续跳跃。 -
跳跃中给予水平移速加成
在跳跃中,我们的水平移动速度可以突破最大值_moveClamp
,且越接近跳跃最高点,加成越高。这通过_jumpApexThreshold
、_apexPoint
和_apexBonus
三个参数实现,其中_apexPoint = Mathf.InverseLerp(_jumpApexThreshold, 0, Mathf.Abs(Velocity.y));
,速度加成为Mathf.Sign(Input.X) * _apexBonus * _apexPoint
。
代码
[Header("GRAVITY")]
[SerializeField, Tooltip("最大下落速度")]
private float _fallClamp = -40f;
[SerializeField, Tooltip("最小下落加速度")]
private float _minFallSpeed = 80f;
[SerializeField, Tooltip("最大下落加速度")]
private float _maxFallSpeed = 120f;
private float _fallSpeed; // 当前下落加速度
/// <summary>
/// 实现重力
/// </summary>
private void CalculateGravity()
{
if (_colDown) // 说明落地了
{
// 落地后将垂直速度归零
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
}
else
{
// 当松开跳跃键并且此时还在上升,调大下落加速度使Player快速减速到下落状态
float fallSpeed = _endedJumpEarly && _currentVerticalSpeed > 0 ? _fallSpeed * _jumpEndEarlyGravityModifier : _fallSpeed;
// 依据当前下落加速度,修改当前垂直速度
_currentVerticalSpeed -= fallSpeed * Time.deltaTime;
// 因为有最大下落速度的限制,向下时不能快过最大下落速度
if (_currentVerticalSpeed < _fallClamp) _currentVerticalSpeed = _fallClamp;
}
}
[Header("JUMPING")]
[SerializeField, Tooltip("跳跃初速度")]
private float _jumpHeight = 30;
[SerializeField, Tooltip("当上升时速度小于该值时认为接近跳跃最高点了")]
private float _jumpApexThreshold = 10f;
[SerializeField, Tooltip("离开平台边缘仍可起跳的时间")]
private float _coyoteTimeThreshold = 0.1f;
[SerializeField, Tooltip("在离落地前多少时间内就可以响应跳跃按键")]
private float _jumpBuffer = 0.1f;
[SerializeField, Tooltip("中断跳跃时附加的乡下加速度倍数")]
private float _jumpEndEarlyGravityModifier = 3;
private bool _coyoteUsable; // 并没在跳跃中
private bool _endedJumpEarly = true; // 是否中断了跳跃
private float _apexPoint; // 起跳时为0,跳到最高点时为
private float _lastJumpPressed; // 上次按下跳跃键的时间
// 是否脱离平台边缘并且可以跳起
private bool CanUseCoyote => _coyoteUsable && !_colDown && _timeLeftGrounded + _coyoteTimeThreshold > Time.time;
// 是否在落地后自动跳起
private bool HasBufferedJump => _colDown && _lastJumpPressed + _jumpBuffer > Time.time;
/// <summary>
/// 根据跳跃的程度,调整向下的加速度
/// 越接近跳跃的最高点(即Velocity.y -> 0)时,_apexPoint -> 1
/// 向下的加速度也受_apexPoint影响,_apexPoint -> 1,_fallSpeed -> max
/// </summary>
private void CalculateJumpApex()
{
if (!_colDown)
{
// 越接近跳跃最高点(即垂直速度接近0)时,向下加速度越大
_apexPoint = Mathf.InverseLerp(_jumpApexThreshold, 0, Mathf.Abs(Velocity.y));
_fallSpeed = Mathf.Lerp(_minFallSpeed, _maxFallSpeed, _apexPoint);
}
else
{
_apexPoint = 0;
}
}
/// <summary>
/// 处理跳跃
/// </summary>
private void CalculateJump()
{
// 如果 按下跳跃键且处于CanUseCoyote时,或者 处于HasBufferedJump时,跳跃
if ((Input.JumpDown && CanUseCoyote) || HasBufferedJump)
{
_currentVerticalSpeed = _jumpHeight; // 设置初始速度
_endedJumpEarly = false; // 并未中断跳跃
_coyoteUsable = false; // 已经在跳跃中,使CanUseCoyote一定为false
_timeLeftGrounded = float.MinValue; // -3.40282347E+38,使CanUseCoyote一定为false
JumpingThisFrame = true; // 在当前帧跳跃了
}
else
{
JumpingThisFrame = false; // 在当前帧没有跳跃
}
// 如果当前帧松开了跳跃键,并且此时Player还在上升,则说明是中断跳跃
if (!_colDown && Input.JumpUp && !_endedJumpEarly && Velocity.y > 0)
{
// 这里可以粗暴的将垂直速度设为0,但是这样手感不好,我们不这样做
// _currentVerticalSpeed = 0;
_endedJumpEarly = true;
}
// 如果向上撞到了障碍物,得强制速度为零,
if (_colUp)
{
if (_currentVerticalSpeed > 0) _currentVerticalSpeed = 0;
}
}
移动角色
移动Player时,我们根据Player当前的水平、垂直速度,计算出下一帧Player应处的位置,并依据_characterBounds
的大小,在对应位置进行碰撞检测,如果此时并没有碰到任何物体,则直接把Player移动到对应位置即可。否则要根据_freeColliderIterations
参数,一小步一小步试探。
例如_freeColliderIterations = 3
,当前Player处于{x = 0, y = 0.5, z = 0}
的位置,下一帧理论位置为{x = 1.2, y = 0.5, z = 0}
,而在这个位置的碰撞检测检测到了障碍物,则我们需要在{x = 0.4, y = 0.5, z = 0}
位置进行碰撞检测,如果无障碍物,则将Player移动到该位置,并在{x = 0.8, y = 0.5, z = 0}
位置再检测,以此类推。故而_freeColliderIterations
越大,移动则会更精细,但是计算量也会同时提升很多。
同时,在移动过程中,我们要完成一点容错处理:
- 在差一点点就可跳上平台时,帮用户上平台
- 起跳时碰到了一点点上平台的边缘,让用户不会被平台阻挡跳跃`
这两点其实处理方法是一样的,首先在下一帧理论位置存在障碍物,且进行小步移动时,第一步就有障碍物,则触发这两种容错。我们仅需在此时将Player往碰撞点的反方向轻推一下即可。
效果:
这样处理大部分情况都没问题,不过还是有bug的,这一轻推可能会把Player推到墙里卡住,所以后续可以思考更好的解决方案。
代码
[Header("MOVE")]
[SerializeField, Tooltip("碰撞检测精度")]
private int _freeColliderIterations = 10;
/// <summary>
/// 移动角色
/// </summary>
private void MoveCharacter()
{
Vector3 pos = transform.position;
// 根据Player当前帧的水平、垂直移动速度计算出下一帧应处于的位置
RawMovement = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed);
Vector3 move = RawMovement * Time.deltaTime;
Vector3 furthestPoint = pos + move;
// 如果没有发生碰撞,则可以直接移动Player
var hit = Physics2D.OverlapBox(furthestPoint, _characterBounds.size, 0, _groundLayer);
if (!hit)
{
transform.position += move;
return;
}
// 否则我们要根据_freeColliderIterations,将原本一帧的移动拆分成若干更小的几步,逐步移动。
Vector3 positionToMoveTo = transform.position;
for (int i = 1; i < _freeColliderIterations; i++)
{
// 由近到远,一步一步试探
float t = (float)i / _freeColliderIterations;
Vector2 posToTry = Vector2.Lerp(pos, furthestPoint, t);
if (Physics2D.OverlapBox(posToTry, _characterBounds.size, 0, _groundLayer))
{
transform.position = positionToMoveTo;
// 这说明我们差一点就跳上一个平台,可以轻推一下Player,让其可以跳上平台
// 或者起跳时头顶碰到了平台的角,可以轻轻让Player再靠外一点,不会被平台挡住跳跃
if (i == 1)
{
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
Vector3 dir = transform.position - hit.transform.position;
transform.position += dir.normalized * move.magnitude;
}
return;
}
positionToMoveTo = posToTry;
}
}
完整代码
添加了中文注释的完整代码已上传至Github