My failed attempt at 2D physics

Posted On: Jan 29, 2015

So after doing extensive research on 2D side scroller physics (like mega man x and mario), I decided to try a different approach. I use Unity's Physics2D.BoxCast to detect collision. But that has a fair amount of issues, lets look into my implementation and why this method has issues.

Ok lets take a look at the Move method, this handles basic logic for movement (not collision, we'll get into that later). It take in a direction that the user wants to go, if the user wants to jump and if the user wants to sprint/run.

public void Move(float direction, bool didJump, bool isSprinting)
{
  _wasGroundedLastFrame = (_collisionFlag & CollisionFlag.Below) == CollisionFlag.Below;
  _collisionFlag = CollisionFlag.None;
  // If we are already jumping.
  if (_isJumping)
  {
    // Stop the jumping logic if the user stoped jumping.
    if (!didJump)
    {
      _isJumping = didJump;
      _currentMovement.y = 0;
      _fallTime = 0;
    }
  }

  if (isSprinting)
  {
    _sprintTime = Mathf.Min(_sprintTime + Time.deltaTime, _sprintRampTime);
  }
  else
  {
    _sprintTime = 0;
  }

  _currentMovement.x = direction * Mathf.Lerp(_speed, _sprintSpeed, _sprintTime) * Time.deltaTime;

  if (!_isJumping)
  {
    if (_wasGroundedLastFrame && didJump)
    {
      _isJumping = true;
      _jumpTime = Time.deltaTime;
      _currentMovement.y = _jumpForce;
    }
    else
    {
      _fallTime += Time.deltaTime;
      _currentMovement.y -= _gravity * _fallTime;
    }
  }
  else
  {
    _jumpTime += Time.deltaTime;
    _isJumping = _jumpTime < _extraJumpTime;
    if (!_isJumping)
    {
      _currentMovement.y = 0;
      _fallTime = 0;
    }
  }

  if (_currentMovement.y != 0)
  {
    CheckVerticalCollision(direction);
  }

  if (_currentMovement.x != 0)
  {
    CheckHorizontalCollision();
  }

  _transform.position += new Vector3(_currentMovement.x, _currentMovement.y);
}

Ok this is given input from the user I'm going to briefly describe what this method is doing, but the meat of what I want to talk about is in the physics methods. Ok here we go, lines 3 through 15 handle jump logic. 17 through 24 handles the sprinting/running. Lines 26 through 51 handle movement and gravity. And 53 through 61 runs the physics checks. Finally line 63 actually moves the character in the world.

Ok now that you have some frame of reference lets talk about those physics methods! I'll start with the simpler method the horizontal collision. So the basic idea is we take the players collider (which is a box) and cast it in the direction that the user is moving toward. If we hit something then we check if its a slope, if it's not a slope we stop horizontal movement (can't go through walls, if the slope is too great can't go up it).

private void CheckHorizontalCollision()
{
  Debug.DrawRay(_transform.position + new Vector3(_collider.center.x, _collider.center.y),
                new Vector3(_currentMovement.normalized.x, 0) * (Mathf.Abs(_currentMovement.x) + (_collider.size.x * 0.5f)));

  var hitInfo = Physics2D.BoxCast(new Vector2(_transform.position.x, _transform.position.y) + _collider.center,
                                  new Vector2(_collider.size.x * 0.1f, _collider.size.y * 0.95f),
                                  0,
                                  new Vector2(_currentMovement.normalized.x, 0),
                                  Mathf.Abs(_currentMovement.x) + (_collider.size.x * 0.45f));

  if (hitInfo.collider != null)
  {
    // Horizontal checks if we are going up a slope.
    var angle = Vector2.Angle(Vector2.up, hitInfo.normal);
    if (angle >= 46)
    {
      _currentMovement.x = 0;
    }
  }
}

Ok so the box cast is as long as the collider is on the y but is really thin, the cast starts from the center of the collider and extends half the collider's width plus the movement direction. If we hit something we do the slope logic I mentioned before, the angle threshold (46) should be a variable but I neglected to do so.

And now for the problematic stuff, the vertical physics. Here we do the same as we did with horizontal, we change the direction for jumping and gravity will bring us back down. We do collision detection for jumping into things and not going through the floor, we also do special logic for slopes. If we are grounded we detect if we are going down a slope and process logic if we are, the problem with that is that the whole collider technically isn't over the slope until we are off our platform and the character's collider is fully over the slope. To alleviate this issue I put a ray cast for in the center of the player shooting down, this ray detects slope information. If we detected we are going down a slope then we process logic for that. Here is the code:

private void CheckVerticalCollision(float direction)
{
  Debug.DrawRay(_transform.position + new Vector3(_collider.center.x, _collider.center.y),
                new Vector3(0, _currentMovement.normalized.y) * (Mathf.Abs(_currentMovement.y) + (_collider.size.y * 0.5f)));

  var hitInfo = Physics2D.BoxCast(new Vector2(_transform.position.x, _transform.position.y) + _collider.center,
                                  new Vector2(_collider.size.x, _collider.size.y * 0.1f),
                                  0,
                                  new Vector2(0, _currentMovement.normalized.y),
                                  Mathf.Abs(_currentMovement.y) + (_collider.size.y * 0.5f));

  if (hitInfo.collider != null)
  {
    _currentMovement.y = -_gravity * Time.deltaTime;
    _fallTime = 0;
    if (hitInfo.point.y > _transform.position.y + _collider.center.y)
    {
      transform.position = new Vector3(transform.position.x,
                                       hitInfo.point.y - _collider.size.y,
                                       transform.position.z);

      _collisionFlag |= CollisionFlag.Above;
    }
    else
    {
      transform.position = new Vector3(transform.position.x,
                                       hitInfo.point.y,
                                       transform.position.z);

      _collisionFlag |= CollisionFlag.Below;

      // check for slope
      // get the bottom Y edge of the player collider
      var yEdge = _transform.position.y - _collider.bounds.extents.y + _collider.center.y;
      Debug.Log("edge: " + yEdge);
      // Make a ray starting from yEdge with one tick of gravity as the distance.
      var rayInfo = Physics2D.Raycast(new Vector2(_transform.position.x, yEdge - 0.02f), -Vector2.up, 0.5f);
      Debug.DrawRay(new Vector2(_transform.position.x, yEdge - 0.02f), -Vector2.up * (0.5f));
      if (rayInfo.collider != null)
      {
        Debug.Log("name: " + rayInfo.collider.name);
        // get the angle between the normal we hit with the direction we are going.
        var directionalAngle = Vector2.Angle(Vector2.right * direction, rayInfo.normal);
        // If its less than or equal 46 then we are going down a slope, do logic for that.
        if (directionalAngle <= 46)
        {
          Debug.Log("point: " + rayInfo.point);
          Debug.Log("y: " + (yEdge - rayInfo.point.y));
          _currentMovement.y -= yEdge - rayInfo.point.y;
        }
      }
    }
  }
}

So we can start a slope and everything is fine however when we reach the bottom of a slope and the ray cast hits a flat collider we instantly snap to the slope. Theres also a few issues with getting caught on colliders at odd angles.

Essentially you should follow the methods done here and here and I haven't read this but their other tutorials are good. The basic idea is to have multiple raycast for both horizontal and vertical directions, and you need more than two to hit the ground otherwise you fall through the ground.