Custom 3D Platformer Controller

A custom 3D platformer controller made in Unity. The goal for this project was a tight 3D platformer controller with custom physics. It doesn’t utilize Unity’s Rigidbody so we have more control about giving it new movement abilities without relying too much on Unity’s physics system.

The features of the 3D platformer controller:

  • Tweakable procedural jump with multiple curves
  • Coyote time
  • Ledge grab
  • Tight custom collision function
  • Grounded check based on slope angle
  • Modular state system so new abilities could be added
  • Smooth up/down movement
  • Smooth 3D collision camera

Platformer Controller Code

Responsible for character collision and movement.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CharacterControllerCustom : MonoBehaviour {
    [Header("Updated variables")]
    public Vector3 velocity;
    public bool isGrounded;
    public bool isColliding;
    public Collider activeCharacterCollider;
    public Collider[] collidersInReach;
    public int groundedCollidersCount;
    public int collidersInReachCount;
    public float currentGravity;

    [Header("Constant variables")]
    [SerializeField] private const float floatiness = 2f;
    [SerializeField] private const float maximumSlopeAngle = 60f;
    [SerializeField] private const float friction = 20f;
    [SerializeField] private const float acceleration = 5f;
    [SerializeField] private const float groundCheckRadius = 0.49f;
    [SerializeField] private const float targetGravity = 2f;
    [SerializeField] private const float overlapSphereRadius = 2.05f;
    [SerializeField] private LayerMask layerMask;
    [SerializeField] private const float groundCheckSphereHeightOffset = -1;
    private Vector3 position;
    private float currentSlopeAngle;
    protected Vector3 currentVelocity;

    private void Awake() {
        isGrounded = false;
    }
    private void FixedUpdate() {
        CollisionPhysics();
    }

    public void TranslateTransform(Vector3 moveInput) {
        velocity += moveInput * acceleration * Time.deltaTime;
        position += velocity * Time.deltaTime;
        velocity -= friction * Time.deltaTime * velocity;
        transform.position += velocity - transform.up * currentGravity;

        currentGravity = Mathf.Lerp(currentGravity, targetGravity, Time.deltaTime * floatiness);

    }

    protected void CollisionPhysics() {
        // check collisions
        groundedCollidersCount = Physics.OverlapSphere(activeCharacterCollider.gameObject.transform.position + Vector3.up * groundCheckSphereHeightOffset, groundCheckRadius, layerMask, QueryTriggerInteraction.UseGlobal).Length;
        collidersInReach = Physics.OverlapSphere(activeCharacterCollider.gameObject.transform.position, overlapSphereRadius, layerMask, QueryTriggerInteraction.UseGlobal);
        collidersInReachCount = collidersInReach.Length;

        bool currentIsGrounded = false;

        for (int i = 0; i < collidersInReachCount; i++) {
            Vector3 direction;
            float distance;
            if (Physics.ComputePenetration(activeCharacterCollider, transform.position, transform.rotation, collidersInReach[i], collidersInReach[i].transform.position, collidersInReach[i].transform.rotation, out direction, out distance)) {
                Vector3 penetrationVector = direction * distance;
                currentSlopeAngle = Vector3.Angle(-direction, -transform.up.normalized);
                Vector3 velocityProjected = Vector3.Project(velocity, -direction);
                isColliding = true;

                //Check for slopes and change current down force
                if (currentSlopeAngle > maximumSlopeAngle) {
                    transform.position = transform.position + penetrationVector;
                } else {
                    currentIsGrounded = true;
                    currentVelocity = velocityProjected;
                    currentGravity = currentVelocity.y;
                    transform.position = transform.position + penetrationVector.y * transform.up.normalized;
                }
                
                
                velocity -= new Vector3(0, velocityProjected.y, 0);
                //Debug.Log("OnCollisionEnter with " + colliders[i].gameObject.name + " penetration vector: " + penetrationVector + " projected vector: " + velocityProjected);
            } else {
                isColliding = false;
            }
        }

        if (!currentIsGrounded && isGrounded && groundedCollidersCount > 0) {
            isGrounded = true;
        } else isGrounded = false;
        if (currentIsGrounded == true) {
            isGrounded = true;
        } 
    }

    private void OnDrawGizmos() {
        Gizmos.color = Color.blue;
        Gizmos.DrawWireSphere(transform.position, overlapSphereRadius);
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(activeCharacterCollider.gameObject.transform.position + Vector3.up * groundCheckSphereHeightOffset, groundCheckRadius);
    }
}

In addition to the default movement and physics, a state machine class that includes the different player actions.

using UnityEngine;
using System.Collections;
//The player state class

[RequireComponent(typeof(CharacterControllerCustom))]
[RequireComponent(typeof(PlayerInputController))]
public class PlayerStateMachine : MonoBehaviour {
    public Animator playerAnimator;
    public ParticleSystem particleSystem;
    public CharacterControllerCustom characterController;
    public PlayerInput currentInput;
    public Vector3 hangRotationDirection;
    public Vector3 hangPosition;

    [Header("Constant variables")]
    [SerializeField] private float airControlSmoothing = 5f;
    [SerializeField] private float jumpSpeed = 2f;
    [SerializeField] private float currentJumpSpeed = 0;
    [SerializeField] private float airTime = 0.1f;
    [SerializeField] private Vector3 hangPositionOffset;
    [SerializeField] private float sprintMultiplier = 2f;
    [SerializeField] private float animationRunSpeed = 12f;
    [SerializeField] private float rotationSmoothingGround = 6f;
    [SerializeField] private float rotationSmoothingAir = 3f;

    private Vector3 directionInAir;
    private PlayerInputController playerInputController;

    //The different states the enemy can be in
    private enum PlayerFSM {
        GroundMovement,
        Sprinting,
        Jumping,
        Falling,
        Hanging,
    }

    private void Awake() {
        characterController = GetComponent<CharacterControllerCustom>();
        playerInputController = GetComponent<PlayerInputController>();
    }

    //Do something based on a state
    protected void DoAction(PlayerFSM playerMode) {
        currentInput = playerInputController.Current;

        switch (playerMode) {
            case PlayerFSM.GroundMovement:
                GroundMovement(1f);
            break;
            case PlayerFSM.Sprinting:
                GroundMovement(sprintMultiplier);
            break;
            case PlayerFSM.Jumping:
                particleSystem.Stop();
                SetOneAnimatorBool("isJumping");
                currentJumpSpeed = Mathf.Lerp(currentJumpSpeed, 0, Time.deltaTime * 1/airTime);
                Vector3 currentDirectionInAir;
                if ( currentInput.MoveInput != Vector3.zero) {
                    currentDirectionInAir = currentInput.MoveInput;
                    RotateTowardsCurrentVelocity(rotationSmoothingAir);
                } else {
                    currentDirectionInAir = Vector3.zero;
                }
                directionInAir = Vector3.Lerp(directionInAir, currentDirectionInAir, Time.deltaTime * airControlSmoothing);
                characterController.TranslateTransform(directionInAir + new Vector3(0, currentJumpSpeed, 0));
                characterController.currentGravity = -0.3f;
            break;
            case PlayerFSM.Falling:
                particleSystem.Stop();
                SetOneAnimatorBool("isFalling");
                if (currentInput.MoveInput != Vector3.zero) {
                    currentDirectionInAir = currentInput.MoveInput;
                    RotateTowardsCurrentVelocity(rotationSmoothingAir);
                } else {
                    currentDirectionInAir = Vector3.zero;
                }
                directionInAir = Vector3.Lerp(directionInAir, currentDirectionInAir, Time.deltaTime * airControlSmoothing);
                characterController.TranslateTransform(directionInAir);
            break;
            case PlayerFSM.Hanging:
                SetOneAnimatorBool("isHanging");
                characterController.currentGravity = 0;
                transform.rotation = Quaternion.LookRotation(hangRotationDirection);
                characterController.velocity = Vector3.zero;
                directionInAir = currentInput.MoveInput;
                transform.position = hangPosition;
                currentJumpSpeed = jumpSpeed;
            break;
        }
    }

    private void GroundMovement(float sprintMultiplier) {
        directionInAir = currentInput.MoveInput;
        currentJumpSpeed = jumpSpeed;
        if (currentInput.MoveInput.x == 0 && currentInput.MoveInput.z == 0) {
            SetOneAnimatorBool("isIdle");
            particleSystem.Stop();
        } else {
            SetAllAnimatorBoolsFalse();
            RotateTowardsCurrentVelocity(rotationSmoothingGround);
            float runSpeed = (Mathf.Abs(characterController.velocity.x) * sprintMultiplier + Mathf.Abs(characterController.velocity.z) * sprintMultiplier) * animationRunSpeed;
            playerAnimator.SetFloat("runSpeed", runSpeed);
            if (runSpeed > 0.8f) {

                if (!particleSystem.isPlaying) {
                    particleSystem.Play();
                }
            } else {
                particleSystem.Stop();
            }
        }
        characterController.TranslateTransform(currentInput.MoveInput * sprintMultiplier);
    }

    private void SetOneAnimatorBool(string animationBool) {
        SetAllAnimatorBoolsFalse();
        foreach (AnimatorControllerParameter parameter in playerAnimator.parameters) {
            playerAnimator.SetFloat(parameter.name, 0f);
        }
        playerAnimator.SetBool(animationBool, true);
    }

    private void SetAllAnimatorBoolsFalse() {
        foreach (AnimatorControllerParameter parameter in playerAnimator.parameters) {
            playerAnimator.SetBool(parameter.name, false);
        }
    }

    public void RotateTowardsCurrentVelocity() {
        Quaternion targetRotation = Quaternion.LookRotation(new Vector3(currentInput.MoveInput.x, 0, currentInput.MoveInput.z));
        transform.rotation = targetRotation;
    }
    public void RotateTowardsCurrentVelocity(float smoothing) {
        Quaternion targetRotation = Quaternion.LookRotation(new Vector3(currentInput.MoveInput.x, 0, currentInput.MoveInput.z));
        transform.rotation = Quaternion.Slerp(transform.localRotation, targetRotation, smoothing * Time.deltaTime);
    }
}

This class implements PlayerStateMachine.cs and controls the state switches. It also includes a ledge check so the ledge grab method can be triggered at the right moment.

using UnityEngine;
using System.Collections;

public class PlayerUpdate : PlayerStateMachine {
    [SerializeField] private Vector3 hangingRaycastWallCheckOffset;
    [SerializeField] private Vector3 hangingRaycastGroundCheckDownOffset;
    [SerializeField] private LayerMask layerMask;

    private PlayerFSM playerMode = PlayerFSM.GroundMovement;
    private RaycastHit hitForward;
    private RaycastHit hitGround;

    public override void UpdatePlayer() {
        switch (playerMode) {
            case PlayerFSM.GroundMovement:
            if (Input.GetButton("Jump")) {
                playerMode = PlayerFSM.Jumping;
            } else if (!characterController.isGrounded) {
                playerMode = PlayerFSM.Falling;
            }

            if (currentInput.SprintInput) {
                playerMode = PlayerFSM.Sprinting;
            }
            break;
            case PlayerFSM.Sprinting:
                if (Input.GetButton("Jump")) {
                    playerMode = PlayerFSM.Jumping;
                } else if (!characterController.isGrounded) {
                    playerMode = PlayerFSM.Falling;
                }
            if (!currentInput.SprintInput) {
                    playerMode = PlayerFSM.GroundMovement;
                }
            break;
            case PlayerFSM.Jumping:
                if (characterController.isGrounded ) {
                playerMode = PlayerFSM.GroundMovement;
                }
                if (currentJumpSpeed <= 0.2f || !Input.GetButton("Jump")) {
                characterController.currentGravity = -0.3f;
                    playerMode = PlayerFSM.Falling;
                }
            break;
            case PlayerFSM.Falling:
                if (characterController.isGrounded) {
                    playerMode = PlayerFSM.GroundMovement;
                }

                LedgeCheck();
                    
            break;
            case PlayerFSM.Hanging:
                if (Input.GetButton("Jump")) {
                    playerMode = PlayerFSM.Jumping;
                }
                if (currentInput.MoveInput.z < -0.5f) {
                playerMode = PlayerFSM.Falling;
            }
            break;
        }
    }

    private void LedgeCheck() {
        RaycastHit hit;
        float angle;
        if (Physics.Raycast(transform.position + hangingRaycastWallCheckOffset, transform.forward, out hitForward,  1, layerMask)) {
            if (Physics.Raycast(transform.position + hangingRaycastGroundCheckDownOffset.z * transform.forward + hangingRaycastGroundCheckDownOffset.y * transform.up, -transform.up, out hitGround, 1 , layerMask) && Vector3.Angle(hitForward.normal, transform.up) <= 90) {
                hangRotationDirection = -new Vector3(hitForward.normal.x, 0, hitForward.normal.z);
                hangPosition = new Vector3(hitForward.point.x , hitGround.point.y + hangPositionOffset.y, hitForward.point.z) + hangRotationDirection * hangPositionOffset.x;
                playerMode = PlayerFSM.Hanging;
            }
        }
    }

    private void Update() {
        UpdatePlayer();
    }

    private void FixedUpdate() {
        DoAction(playerMode);
    }

    private void OnDrawGizmos () {
        Gizmos.color = Color.cyan;
        Gizmos.DrawLine(transform.position + hangingRaycastWallCheckOffset, transform.forward * 1f + transform.position + hangingRaycastWallCheckOffset);
        Gizmos.DrawLine(transform.position + hangingRaycastGroundCheckDownOffset.z * transform.forward + hangingRaycastGroundCheckDownOffset.y * transform.up, -transform.up * 1f + transform.position + hangingRaycastGroundCheckDownOffset.z * transform.forward + hangingRaycastGroundCheckDownOffset.y * transform.up);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Spawner : MonoBehaviour {
    public bool startSpawning = true;
    public bool isSpawning;
    public float spawnRate = 1f;
    public GameObject objectToSpawn;
    public Transform parent;
    private void OnEnable() {
        if (startSpawning)
            StartCoroutine("Spawn");
    }

    private void OnDestroy() {
        isSpawning = false;
        StopAllCoroutines();
    }

    private void OnDisable() {
        isSpawning = false;
        StopAllCoroutines();
    }

    IEnumerator Spawn() {
        //waiting to spawn
        Instantiate(objectToSpawn, transform.position, Quaternion.identity, parent);
        yield return new WaitForSeconds(spawnRate);

        if (gameObject.activeInHierarchy)
            StartCoroutine("Spawn");
    }

    public void StartSpawning() {
        isSpawning = true;
        StartCoroutine("Spawn");
    }

    public void StopSpawning() {
        //isSpawning = false;
        StopAllCoroutines();
    }
}

Translate »