Created
March 13, 2023 00:30
-
-
Save dresswithpockets/d713dc739a5dcfe0bbdba409cf33eb6d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#define DEBUG_CC2D_RAYS | |
using UnityEngine; | |
using System; | |
using System.Collections.Generic; | |
// ReSharper disable All | |
namespace Prime31 | |
{ | |
[RequireComponent(typeof(BoxCollider2D), typeof(Rigidbody2D))] | |
public sealed class CharacterController2D : MonoBehaviour | |
{ | |
#region internal types | |
private struct CharacterRaycastOrigins | |
{ | |
public Vector3 TopLeft; | |
public Vector3 BottomRight; | |
public Vector3 BottomLeft; | |
} | |
public sealed class CharacterCollisionState2D | |
{ | |
public bool Right; | |
public bool Left; | |
public bool Above; | |
public bool Below; | |
public bool BecameGroundedThisFrame; | |
public bool WasGroundedLastFrame; | |
public bool MovingDownSlope; | |
public float SlopeAngle; | |
public bool OnLeftEdge; | |
public bool OnRightEdge; | |
public bool HasCollision() | |
{ | |
return Below || Right || Left || Above; | |
} | |
public void Reset() | |
{ | |
Right = Left = Above = Below = BecameGroundedThisFrame = MovingDownSlope = false; | |
OnLeftEdge = false; | |
OnRightEdge = false; | |
SlopeAngle = 0f; | |
} | |
public override string ToString() | |
{ | |
return string.Format( | |
"[CharacterCollisionState2D] r: {0}, l: {1}, a: {2}, b: {3}, movingDownSlope: {4}, angle: {5}, wasGroundedLastFrame: {6}, becameGroundedThisFrame: {7}", | |
Right, Left, Above, Below, MovingDownSlope, SlopeAngle, WasGroundedLastFrame, | |
BecameGroundedThisFrame); | |
} | |
} | |
#endregion | |
#region events, properties and fields | |
public event Action<RaycastHit2D> OnControllerCollidedEvent; | |
public event Action<Collider2D> OnTriggerEnterEvent; | |
public event Action<Collider2D> OnTriggerStayEvent; | |
public event Action<Collider2D> OnTriggerExitEvent; | |
/// <summary> | |
/// when true, one way platforms will be ignored when moving vertically for a single frame | |
/// </summary> | |
public bool ignoreOneWayPlatformsThisFrame; | |
[SerializeField] [Range(0.001f, 0.3f)] private float _skinWidth = 0.02f; | |
/// <summary> | |
/// defines how far in from the edges of the collider rays are cast from. If cast with a 0 extent it will often result in ray hits that are | |
/// not desired (for example a foot collider casting horizontally from directly on the surface can result in a hit) | |
/// </summary> | |
public float skinWidth | |
{ | |
get => _skinWidth; | |
set | |
{ | |
_skinWidth = value; | |
RecalculateDistanceBetweenRays(); | |
} | |
} | |
/// <summary> | |
/// mask with all layers that the player should interact with | |
/// </summary> | |
public LayerMask platformMask = 0; | |
/// <summary> | |
/// mask with all layers that trigger events should fire when intersected | |
/// </summary> | |
public LayerMask triggerMask = 0; | |
/// <summary> | |
/// mask with all layers that should act as one-way platforms. Note that one-way platforms should always be EdgeCollider2Ds. This is because it does not support being | |
/// updated anytime outside of the inspector for now. | |
/// </summary> | |
[SerializeField] private LayerMask oneWayPlatformMask = 0; | |
/// <summary> | |
/// the max slope angle that the CC2D can climb | |
/// </summary> | |
/// <value>The slope limit.</value> | |
[Range(0f, 90f)] public float slopeLimit = 30f; | |
/// <summary> | |
/// the threshold in the change in vertical movement between frames that constitutes jumping | |
/// </summary> | |
/// <value>The jumping threshold.</value> | |
public float jumpingThreshold = 0.07f; | |
public bool jumpingThisFrame = false; | |
/// <summary> | |
/// curve for multiplying speed based on slope (negative = down slope and positive = up slope) | |
/// </summary> | |
public AnimationCurve slopeSpeedMultiplier = | |
new AnimationCurve(new Keyframe(-90f, 1.5f), new Keyframe(0f, 1f), new Keyframe(90f, 0f)); | |
[Range(2, 20)] public int totalHorizontalRays = 8; | |
[Range(2, 20)] public int totalVerticalRays = 4; | |
/// <summary> | |
/// this is used to calculate the downward ray that is cast to check for slopes. We use the somewhat arbitrary value 75 degrees | |
/// to calculate the length of the ray that checks for slopes. | |
/// </summary> | |
private float _slopeLimitTangent = Mathf.Tan(75f * Mathf.Deg2Rad); | |
[HideInInspector] [NonSerialized] public Transform Transform; | |
[HideInInspector] [NonSerialized] public BoxCollider2D BoxCollider; | |
[HideInInspector] [NonSerialized] public Rigidbody2D RigidBody2D; | |
[HideInInspector] [NonSerialized] | |
public CharacterCollisionState2D CollisionState = new(); | |
[HideInInspector] [NonSerialized] public Vector3 Velocity; | |
public bool IsGrounded | |
{ | |
get { return CollisionState.Below; } | |
} | |
private const float KSkinWidthFloatFudgeFactor = 0.001f; | |
#endregion | |
/// <summary> | |
/// holder for our raycast origin corners (TR, TL, BR, BL) | |
/// </summary> | |
private CharacterRaycastOrigins _raycastOrigins; | |
/// <summary> | |
/// stores our raycast hit during movement | |
/// </summary> | |
private RaycastHit2D _raycastHit; | |
/// <summary> | |
/// stores any raycast hits that occur this frame. we have to store them in case we get a hit moving | |
/// horizontally and vertically so that we can send the events after all collision state is set | |
/// </summary> | |
private readonly List<RaycastHit2D> _raycastHitsThisFrame = new(2); | |
// horizontal/vertical movement data | |
private float _verticalDistanceBetweenRays; | |
private float _horizontalDistanceBetweenRays; | |
// we use this flag to mark the case where we are travelling up a slope and we modified our delta.y to allow the climb to occur. | |
// the reason is so that if we reach the end of the slope we can make an adjustment to stay grounded | |
private bool _isGoingUpSlope = false; | |
#region Monobehaviour | |
private void Awake() | |
{ | |
// add our one-way platforms to our normal platform mask so that we can land on them from above | |
platformMask |= oneWayPlatformMask; | |
// cache some components | |
Transform = GetComponent<Transform>(); | |
BoxCollider = GetComponent<BoxCollider2D>(); | |
RigidBody2D = GetComponent<Rigidbody2D>(); | |
// here, we trigger our properties that have setters with bodies | |
skinWidth = _skinWidth; | |
// we want to set our CC2D to ignore all collision layers except what is in our triggerMask | |
for (var i = 0; i < 32; i++) | |
{ | |
// see if our triggerMask contains this layer and if not ignore it | |
if ((triggerMask.value & 1 << i) == 0) | |
Physics2D.IgnoreLayerCollision(gameObject.layer, i); | |
} | |
} | |
public void OnTriggerEnter2D(Collider2D col) | |
{ | |
if (OnTriggerEnterEvent != null) | |
OnTriggerEnterEvent(col); | |
} | |
public void OnTriggerStay2D(Collider2D col) | |
{ | |
if (OnTriggerStayEvent != null) | |
OnTriggerStayEvent(col); | |
} | |
public void OnTriggerExit2D(Collider2D col) | |
{ | |
if (OnTriggerExitEvent != null) | |
OnTriggerExitEvent(col); | |
} | |
#endregion | |
[System.Diagnostics.Conditional("DEBUG_CC2D_RAYS")] | |
private void DrawRay(Vector3 start, Vector3 dir, Color color) | |
{ | |
Debug.DrawRay(start, dir, color); | |
} | |
#region Public | |
/// <summary> | |
/// attempts to move the character to position + deltaMovement. Any colliders in the way will cause the movement to | |
/// stop when run into. | |
/// </summary> | |
/// <param name="deltaMovement">Delta movement.</param> | |
public void Move(Vector3 deltaMovement) | |
{ | |
// save off our current grounded state which we will use for wasGroundedLastFrame and becameGroundedThisFrame | |
CollisionState.WasGroundedLastFrame = CollisionState.Below; | |
// clear our state | |
CollisionState.Reset(); | |
_raycastHitsThisFrame.Clear(); | |
_isGoingUpSlope = false; | |
PrimeRaycastOrigins(); | |
// first, we check for a slope below us before moving | |
// only check slopes if we are going down and grounded | |
if (deltaMovement.y < 0f && CollisionState.WasGroundedLastFrame) | |
HandleVerticalSlope(ref deltaMovement); | |
// now we check movement in the horizontal dir | |
if (deltaMovement.x != 0f) | |
MoveHorizontally(ref deltaMovement); | |
// next, check movement in the vertical dir | |
if (deltaMovement.y != 0f) | |
MoveVertically(ref deltaMovement); | |
// move then update our state | |
deltaMovement.z = 0; | |
Transform.Translate(deltaMovement, Space.World); | |
// only calculate velocity if we have a non-zero deltaTime | |
if (Time.deltaTime > 0f) | |
Velocity = deltaMovement / Time.deltaTime; | |
// set our becameGrounded state based on the previous and current collision state | |
if (!CollisionState.WasGroundedLastFrame && CollisionState.Below) | |
CollisionState.BecameGroundedThisFrame = true; | |
// if we are going up a slope we artificially set a y velocity so we need to zero it out here | |
if (_isGoingUpSlope) | |
Velocity.y = 0; | |
// send off the collision events if we have a listener | |
if (OnControllerCollidedEvent != null) | |
{ | |
for (var i = 0; i < _raycastHitsThisFrame.Count; i++) | |
OnControllerCollidedEvent(_raycastHitsThisFrame[i]); | |
} | |
ignoreOneWayPlatformsThisFrame = false; | |
jumpingThisFrame = false; | |
} | |
/// <summary> | |
/// moves directly down until grounded | |
/// </summary> | |
public void WarpToGrounded() | |
{ | |
do | |
{ | |
Move(new Vector3(0, -1f, 0)); | |
} while (!IsGrounded); | |
} | |
/// <summary> | |
/// this should be called anytime you have to modify the BoxCollider2D at runtime. It will recalculate the distance between the rays used for collision detection. | |
/// It is also used in the skinWidth setter in case it is changed at runtime. | |
/// </summary> | |
public void RecalculateDistanceBetweenRays() | |
{ | |
// figure out the distance between our rays in both directions | |
// horizontal | |
var colliderUseableHeight = BoxCollider.size.y * Mathf.Abs(Transform.localScale.y) - (2f * _skinWidth); | |
_verticalDistanceBetweenRays = colliderUseableHeight / (totalHorizontalRays - 1); | |
// vertical | |
var colliderUseableWidth = BoxCollider.size.x * Mathf.Abs(Transform.localScale.x) - (2f * _skinWidth); | |
_horizontalDistanceBetweenRays = colliderUseableWidth / (totalVerticalRays - 1); | |
} | |
#endregion | |
#region Movement Methods | |
/// <summary> | |
/// resets the raycastOrigins to the current extents of the box collider inset by the skinWidth. It is inset | |
/// to avoid casting a ray from a position directly touching another collider which results in wonky normal data. | |
/// </summary> | |
private void PrimeRaycastOrigins() | |
{ | |
// our raycasts need to be fired from the bounds inset by the skinWidth | |
var modifiedBounds = BoxCollider.bounds; | |
modifiedBounds.Expand(-2f * _skinWidth); | |
_raycastOrigins.TopLeft = new Vector2(modifiedBounds.min.x, modifiedBounds.max.y); | |
_raycastOrigins.BottomRight = new Vector2(modifiedBounds.max.x, modifiedBounds.min.y); | |
_raycastOrigins.BottomLeft = modifiedBounds.min; | |
} | |
/// <summary> | |
/// we have to use a bit of trickery in this one. The rays must be cast from a small distance inside of our | |
/// collider (skinWidth) to avoid zero distance rays which will get the wrong normal. Because of this small offset | |
/// we have to increase the ray distance skinWidth then remember to remove skinWidth from deltaMovement before | |
/// actually moving the player | |
/// </summary> | |
private void MoveHorizontally(ref Vector3 deltaMovement) | |
{ | |
var isGoingRight = deltaMovement.x > 0; | |
var rayDistance = Mathf.Abs(deltaMovement.x) + _skinWidth; | |
var rayDirection = isGoingRight ? Vector2.right : -Vector2.right; | |
var initialRayOrigin = isGoingRight ? _raycastOrigins.BottomRight : _raycastOrigins.BottomLeft; | |
for (var i = 0; i < totalHorizontalRays; i++) | |
{ | |
var ray = new Vector2(initialRayOrigin.x, initialRayOrigin.y + i * _verticalDistanceBetweenRays); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
// if we are grounded we will include oneWayPlatforms only on the first ray (the bottom one). this will allow us to | |
// walk up sloped oneWayPlatforms | |
if (i == 0 && CollisionState.WasGroundedLastFrame) | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, platformMask); | |
else | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, platformMask & ~oneWayPlatformMask); | |
if (_raycastHit) | |
{ | |
// the bottom ray can hit a slope but no other ray can so we have special handling for these cases | |
if (i == 0 && HandleHorizontalSlope(ref deltaMovement, | |
Vector2.Angle(_raycastHit.normal, Vector2.up))) | |
{ | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// if we weren't grounded last frame, that means we're landing on a slope horizontally. | |
// this ensures that we stay flush to that slope | |
if (!CollisionState.WasGroundedLastFrame) | |
{ | |
float flushDistance = Mathf.Sign(deltaMovement.x) * (_raycastHit.distance - skinWidth); | |
Transform.Translate(new Vector2(flushDistance, 0)); | |
} | |
break; | |
} | |
// set our new deltaMovement and recalculate the rayDistance taking it into account | |
deltaMovement.x = _raycastHit.point.x - ray.x; | |
rayDistance = Mathf.Abs(deltaMovement.x); | |
// remember to remove the skinWidth from our deltaMovement | |
if (isGoingRight) | |
{ | |
deltaMovement.x -= _skinWidth; | |
CollisionState.Right = true; | |
} | |
else | |
{ | |
deltaMovement.x += _skinWidth; | |
CollisionState.Left = true; | |
} | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// we add a small fudge factor for the float operations here. if our rayDistance is smaller | |
// than the width + fudge bail out because we have a direct impact | |
if (rayDistance < _skinWidth + KSkinWidthFloatFudgeFactor) | |
break; | |
} | |
} | |
} | |
/// <summary> | |
/// handles adjusting deltaMovement if we are going up a slope. | |
/// </summary> | |
/// <returns><c>true</c>, if horizontal slope was handled, <c>false</c> otherwise.</returns> | |
/// <param name="deltaMovement">Delta movement.</param> | |
/// <param name="angle">Angle.</param> | |
private bool HandleHorizontalSlope(ref Vector3 deltaMovement, float angle) | |
{ | |
// disregard 90 degree angles (walls) | |
if (Mathf.RoundToInt(angle) == 90) | |
return false; | |
// if we can walk on slopes and our angle is small enough we need to move up | |
if (angle < slopeLimit) | |
{ | |
// we only need to adjust the deltaMovement if we are not jumping | |
// TODO: this uses a magic number which isn't ideal! The alternative is to have the user pass in if there is a jump this frame | |
if (!jumpingThisFrame) | |
{ | |
// apply the slopeModifier to slow our movement up the slope | |
var slopeModifier = slopeSpeedMultiplier.Evaluate(angle); | |
deltaMovement.x *= slopeModifier; | |
// we dont set collisions on the sides for this since a slope is not technically a side collision. | |
// smooth y movement when we climb. we make the y movement equivalent to the actual y location that corresponds | |
// to our new x location using our good friend Pythagoras | |
deltaMovement.y = Mathf.Abs(Mathf.Tan(angle * Mathf.Deg2Rad) * deltaMovement.x); | |
var isGoingRight = deltaMovement.x > 0; | |
// safety check. we fire a ray in the direction of movement just in case the diagonal we calculated above ends up | |
// going through a wall. if the ray hits, we back off the horizontal movement to stay in bounds. | |
var ray = isGoingRight ? _raycastOrigins.BottomRight : _raycastOrigins.BottomLeft; | |
RaycastHit2D raycastHit; | |
if (CollisionState.WasGroundedLastFrame) | |
raycastHit = Physics2D.Raycast(ray, deltaMovement.normalized, deltaMovement.magnitude, | |
platformMask); | |
else | |
raycastHit = Physics2D.Raycast(ray, deltaMovement.normalized, deltaMovement.magnitude, | |
platformMask & ~oneWayPlatformMask); | |
if (raycastHit) | |
{ | |
// we crossed an edge when using Pythagoras calculation, so we set the actual delta movement to the ray hit location | |
deltaMovement = (Vector3)raycastHit.point - ray; | |
if (isGoingRight) | |
deltaMovement.x -= _skinWidth; | |
else | |
deltaMovement.x += _skinWidth; | |
} | |
_isGoingUpSlope = true; | |
CollisionState.Below = true; | |
CollisionState.SlopeAngle = -angle; | |
} | |
} | |
else // too steep. get out of here | |
{ | |
deltaMovement.x = 0; | |
} | |
return true; | |
} | |
private void MoveVertically(ref Vector3 deltaMovement) | |
{ | |
var isGoingUp = deltaMovement.y > 0; | |
var rayDistance = Mathf.Abs(deltaMovement.y) + _skinWidth; | |
var rayDirection = isGoingUp ? Vector2.up : -Vector2.up; | |
var initialRayOrigin = isGoingUp ? _raycastOrigins.TopLeft : _raycastOrigins.BottomLeft; | |
// apply our horizontal deltaMovement here so that we do our raycast from the actual position we would be in if we had moved | |
initialRayOrigin.x += deltaMovement.x; | |
// if we are moving up, we should ignore the layers in oneWayPlatformMask | |
var mask = platformMask; | |
if ((isGoingUp && !CollisionState.WasGroundedLastFrame) || ignoreOneWayPlatformsThisFrame) | |
mask &= ~oneWayPlatformMask; | |
var brokenBeforeRightEdge = false; | |
for (var i = 0; i < totalVerticalRays; i++) | |
{ | |
var ray = new Vector2(initialRayOrigin.x + i * _horizontalDistanceBetweenRays, initialRayOrigin.y); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, mask); | |
if (_raycastHit) | |
{ | |
// set our new deltaMovement and recalculate the rayDistance taking it into account | |
deltaMovement.y = _raycastHit.point.y - ray.y; | |
rayDistance = Mathf.Abs(deltaMovement.y); | |
// remember to remove the skinWidth from our deltaMovement | |
if (isGoingUp) | |
{ | |
deltaMovement.y -= _skinWidth; | |
CollisionState.Above = true; | |
} | |
else | |
{ | |
deltaMovement.y += _skinWidth; | |
CollisionState.Below = true; | |
} | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// this is a hack to deal with the top of slopes. if we walk up a slope and reach the apex we can get in a situation | |
// where our ray gets a hit that is less then skinWidth causing us to be ungrounded the next frame due to residual velocity. | |
if (!isGoingUp && deltaMovement.y > 0.00001f) | |
_isGoingUpSlope = true; | |
// we add a small fudge factor for the float operations here. if our rayDistance is smaller | |
// than the width + fudge bail out because we have a direct impact | |
if (rayDistance < _skinWidth + KSkinWidthFloatFudgeFactor) | |
{ | |
brokenBeforeRightEdge = i != totalVerticalRays - 1; | |
break; | |
} | |
} | |
else | |
{ | |
if (i == 0) | |
CollisionState.OnLeftEdge = true; | |
else if (i == totalVerticalRays - 1) | |
CollisionState.OnRightEdge = true; | |
} | |
} | |
// since we broke out of the loop before we got to the right-edge ray, we need check the right edge for our | |
// collision state | |
if (brokenBeforeRightEdge) | |
{ | |
var i = totalVerticalRays - 1; | |
var ray = new Vector2(initialRayOrigin.x + i * _horizontalDistanceBetweenRays, initialRayOrigin.y); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, mask); | |
if (!_raycastHit) | |
CollisionState.OnRightEdge = true; | |
} | |
} | |
/// <summary> | |
/// checks the center point under the BoxCollider2D for a slope. If it finds one then the deltaMovement is adjusted so that | |
/// the player stays grounded and the slopeSpeedModifier is taken into account to speed up movement. | |
/// </summary> | |
/// <param name="deltaMovement">Delta movement.</param> | |
private void HandleVerticalSlope(ref Vector3 deltaMovement) | |
{ | |
// slope check from the center of our collider | |
var centerOfCollider = (_raycastOrigins.BottomLeft.x + _raycastOrigins.BottomRight.x) * 0.5f; | |
var rayDirection = -Vector2.up; | |
// the ray distance is based on our slopeLimit | |
var slopeCheckRayDistance = _slopeLimitTangent * (_raycastOrigins.BottomRight.x - centerOfCollider); | |
var slopeRay = new Vector2(centerOfCollider, _raycastOrigins.BottomLeft.y); | |
DrawRay(slopeRay, rayDirection * slopeCheckRayDistance, Color.yellow); | |
_raycastHit = Physics2D.Raycast(slopeRay, rayDirection, slopeCheckRayDistance, platformMask); | |
if (_raycastHit) | |
{ | |
// bail out if we have no slope | |
var angle = Vector2.Angle(_raycastHit.normal, Vector2.up); | |
if (angle == 0) | |
return; | |
// we are moving down the slope if our normal and movement direction are in the same x direction | |
var isMovingDownSlope = Mathf.Sign(_raycastHit.normal.x) == Mathf.Sign(deltaMovement.x); | |
if (isMovingDownSlope) | |
{ | |
// going down we want to speed up in most cases so the slopeSpeedMultiplier curve should be > 1 for negative angles | |
var slopeModifier = slopeSpeedMultiplier.Evaluate(-angle); | |
// we add the extra downward movement here to ensure we "stick" to the surface below | |
deltaMovement.y += _raycastHit.point.y - slopeRay.y - skinWidth; | |
deltaMovement = new Vector3(0, deltaMovement.y, 0) + | |
(Quaternion.AngleAxis(-angle, Vector3.forward) * | |
new Vector3(deltaMovement.x * slopeModifier, 0, 0)); | |
CollisionState.MovingDownSlope = true; | |
CollisionState.SlopeAngle = angle; | |
} | |
} | |
} | |
#endregion | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment