简单2D游戏角色控制器的实现

简单2D游戏角色控制器的实现

学习自 Matthew-J-Spencer 的 Ultimate 2D Controller

链接: 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了,因为他于我们的跳跃功能息息相关。这里有两个特殊情况需要我们处理一下:

  1. _colDown为true,但当前帧向下的射线检测值为false,说明这是我们起跳或者离开平台边缘的第一帧

  2. _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);
    }
}

各参数效果展示

在上文 发射射线示意图 中,_characterBoundsExtend属性的值为{x = 0.5, y = 0.65, z = 0.0}_detectorCount为3,_rayBuffer为0.1,_detectionRayLength为0.1。

_characterBoundsExtend属性的值为{x = 0.65, y = 0.25, z = 0.0}时,效果如下

_characterBounds效果
_detectorCount为10时,效果如下

每条边发射10条射线
_rayBuffer为0.3时,效果如下

_rayBuffer为0.3

_detectionRayLength为0.5时,效果如下

_detectionRayLength

水平移动

所有的移动处理(包括后面的跳跃,重力下坠),均仅对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

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

F_CIL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值