using System.Collections.Generic; using System.Linq; using UnityEngine; namespace TarodevController { /// <summary> /// Hey! /// Tarodev here. I built this controller as there was a severe lack of quality & free 2D controllers out there. /// Right now it only contains movement and jumping, but it should be pretty easy to expand... I may even do it myself /// if there's enough interest. You can play and compete for best times here: https://tarodev.itch.io/ /// If you hve any questions or would like to brag about your score, come to discord: https://discord.gg/GqeHHnhHpz /// </summary> public class PlayerController : MonoBehaviour, IPlayerController { // Public for external hooks public Vector3 Velocity { get; private set; } public FrameInput Input { get; private set; } public bool JumpingThisFrame { get; private set; } public bool LandingThisFrame { get; private set; } public Vector3 RawMovement { get; private set; } public bool Grounded => _colDown; private Vector3 _lastPosition; private float _currentHorizontalSpeed, _currentVerticalSpeed; // This is horrible, but for some reason colliders are not fully established when update starts... private bool _active; void Awake() => Invoke(nameof(Activate), 0.5f); void Activate() => _active = true; private void Update() { if (!_active) return; // Calculate velocity Velocity = (transform.position - _lastPosition) / Time.deltaTime; _lastPosition = transform.position; GatherInput(); RunCollisionChecks(); CalculateWalk(); // Horizontal movement CalculateJumpApex(); // Affects fall speed, so calculate before gravity CalculateGravity(); // Vertical movement CalculateJump(); // Possibly overrides vertical MoveCharacter(); // Actually perform the axis movement } #region Gather Input 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; } } #endregion #region Collisions [Header("COLLISION")][SerializeField] private Bounds _characterBounds; [SerializeField] private LayerMask _groundLayer; [SerializeField] private int _detectorCount = 3; [SerializeField] private float _detectionRayLength = 0.1f; [SerializeField][Range(0.1f, 0.3f)] private float _rayBuffer = 0.1f; // Prevents side detectors hitting the ground private RayRange _raysUp, _raysRight, _raysDown, _raysLeft; private bool _colUp, _colRight, _colDown, _colLeft; private float _timeLeftGrounded; // We use these raycast checks for pre-collision information private void RunCollisionChecks() { // Generate ray ranges. CalculateRayRanged(); // Ground LandingThisFrame = false; var groundedCheck = RunDetection(_raysDown); if (_colDown && !groundedCheck) _timeLeftGrounded = Time.time; // Only trigger when first leaving else if (!_colDown && groundedCheck) { _coyoteUsable = true; // Only trigger when first touching LandingThisFrame = true; } _colDown = groundedCheck; // The rest _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)); } } private void CalculateRayRanged() { // This is crying out for some kind of refactor. var b = new Bounds(transform.position + _characterBounds.center, _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); } } private void OnDrawGizmos() { // Bounds Gizmos.color = Color.yellow; Gizmos.DrawWireCube(transform.position + _characterBounds.center, _characterBounds.size); // Rays if (!Application.isPlaying) { CalculateRayRanged(); Gizmos.color = Color.blue; foreach (var range in new List<RayRange> { _raysUp, _raysRight, _raysDown, _raysLeft }) { foreach (var point in EvaluateRayPositions(range)) { Gizmos.DrawRay(point, range.Dir * _detectionRayLength); } } } if (!Application.isPlaying) return; // Draw the future position. Handy for visualizing gravity Gizmos.color = Color.red; var move = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed) * Time.deltaTime; Gizmos.DrawWireCube(transform.position + _characterBounds.center + move, _characterBounds.size); } #endregion #region Walk [Header("WALKING")][SerializeField] private float _acceleration = 90; [SerializeField] private float _moveClamp = 13; [SerializeField] private float _deAcceleration = 60f; [SerializeField] private float _apexBonus = 2; private void CalculateWalk() { if (Input.X != 0) { // Set horizontal move speed _currentHorizontalSpeed += Input.X * _acceleration * Time.deltaTime; // clamped by max frame movement _currentHorizontalSpeed = Mathf.Clamp(_currentHorizontalSpeed, -_moveClamp, _moveClamp); // Apply bonus at the apex of a jump var apexBonus = Mathf.Sign(Input.X) * _apexBonus * _apexPoint; _currentHorizontalSpeed += apexBonus * Time.deltaTime; } else { // No input. Let's slow the character down _currentHorizontalSpeed = Mathf.MoveTowards(_currentHorizontalSpeed, 0, _deAcceleration * Time.deltaTime); } if (_currentHorizontalSpeed > 0 && _colRight || _currentHorizontalSpeed < 0 && _colLeft) { // Don't walk through walls _currentHorizontalSpeed = 0; } } #endregion #region Gravity [Header("GRAVITY")][SerializeField] private float _fallClamp = -40f; [SerializeField] private float _minFallSpeed = 80f; [SerializeField] private float _maxFallSpeed = 120f; private float _fallSpeed; private void CalculateGravity() { if (_colDown) { // Move out of the ground if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0; } else { // Add downward force while ascending if we ended the jump early var fallSpeed = _endedJumpEarly && _currentVerticalSpeed > 0 ? _fallSpeed * _jumpEndEarlyGravityModifier : _fallSpeed; // Fall _currentVerticalSpeed -= fallSpeed * Time.deltaTime; // Clamp if (_currentVerticalSpeed < _fallClamp) _currentVerticalSpeed = _fallClamp; } } #endregion #region Jump [Header("JUMPING")][SerializeField] private float _jumpHeight = 30; [SerializeField] private float _jumpApexThreshold = 10f; [SerializeField] private float _coyoteTimeThreshold = 0.1f; [SerializeField] private float _jumpBuffer = 0.1f; [SerializeField] private float _jumpEndEarlyGravityModifier = 3; private bool _coyoteUsable; private bool _endedJumpEarly = true; private float _apexPoint; // Becomes 1 at the apex of a jump private float _lastJumpPressed; private bool CanUseCoyote => _coyoteUsable && !_colDown && _timeLeftGrounded + _coyoteTimeThreshold > Time.time; private bool HasBufferedJump => _colDown && _lastJumpPressed + _jumpBuffer > Time.time; private void CalculateJumpApex() { if (!_colDown) { // Gets stronger the closer to the top of the jump _apexPoint = Mathf.InverseLerp(_jumpApexThreshold, 0, Mathf.Abs(Velocity.y)); _fallSpeed = Mathf.Lerp(_minFallSpeed, _maxFallSpeed, _apexPoint); } else { _apexPoint = 0; } } private void CalculateJump() { // Jump if: grounded or within coyote threshold || sufficient jump buffer if (Input.JumpDown && CanUseCoyote || HasBufferedJump) { _currentVerticalSpeed = _jumpHeight; _endedJumpEarly = false; _coyoteUsable = false; _timeLeftGrounded = float.MinValue; JumpingThisFrame = true; } else { JumpingThisFrame = false; } // End the jump early if button released if (!_colDown && Input.JumpUp && !_endedJumpEarly && Velocity.y > 0) { // _currentVerticalSpeed = 0; _endedJumpEarly = true; } if (_colUp) { if (_currentVerticalSpeed > 0) _currentVerticalSpeed = 0; } } #endregion #region Move [Header("MOVE")] [SerializeField, Tooltip("Raising this value increases collision accuracy at the cost of performance.")] private int _freeColliderIterations = 10; // We cast our bounds before moving to avoid future collisions private void MoveCharacter() { var pos = transform.position + _characterBounds.center; RawMovement = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed); // Used externally var move = RawMovement * Time.deltaTime; var furthestPoint = pos + move; // check furthest movement. If nothing hit, move and don't do extra checks var hit = Physics2D.OverlapBox(furthestPoint, _characterBounds.size, 0, _groundLayer); if (!hit) { transform.position += move; return; } // otherwise increment away from current pos; see what closest position we can move to var positionToMoveTo = transform.position; for (int i = 1; i < _freeColliderIterations; i++) { // increment to check all but furthestPoint - we did that already var t = (float)i / _freeColliderIterations; var posToTry = Vector2.Lerp(pos, furthestPoint, t); if (Physics2D.OverlapBox(posToTry, _characterBounds.size, 0, _groundLayer)) { transform.position = positionToMoveTo; // We've landed on a corner or hit our head on a ledge. Nudge the player gently if (i == 1) { if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0; var dir = transform.position - hit.transform.position; transform.position += dir.normalized * move.magnitude; } return; } positionToMoveTo = posToTry; } } #endregion } }
Preview:
downloadDownload PNG
downloadDownload JPEG
downloadDownload SVG
Tip: You can change the style, width & colours of the snippet with the inspect tool before clicking Download!
Click to optimize width for Twitter