Source file unity-csharp/3d/SimplePlayerController.cs from the GDnD code examples. Download raw file

// GDnD wiki example (1 of 2: SimplePlayerController.cs, ControllerRigidbodyPusher.cs)
// Demonstrates: camera-relative third-person movement with Unity's CharacterController.
//   - Reads "Move" (Vector2) and "Jump" (Button) from the Input System: either from
//     InputActionReference fields you assign, or from the project-wide actions asset.
//   - Flattens the camera's forward/right onto the floor so "up" on the stick always
//     means "away from the camera", whatever angle the camera is at.
//   - Turns the character to face its movement direction at a capped turn rate.
//   - Applies its own gravity and jump (CharacterController.Move does NOT add gravity),
//     with a small downward "stick" velocity so isGrounded stays reliable, and a short
//     coyote-time grace so a jump pressed just after walking off a ledge still works.
//   - Calls CharacterController.Move exactly once per frame, as Unity recommends.
// Related pages: [[unity-character-controller]], [[unity-input-system]],
//   [[monobehaviour-lifecycle]], [[cinemachine-overview]], [[3d-blocking-and-greyboxing]]
// Unity 6 (6000.x). Needs the Input System package (default in new Unity 6 projects).
// Do NOT add a Rigidbody to the same GameObject: a CharacterController is moved by script.
 
using UnityEngine;
using UnityEngine.InputSystem;
 
namespace GDnD.ThreeD
{
    [RequireComponent(typeof(CharacterController))]
    public class SimplePlayerController : MonoBehaviour
    {
        [Header("Movement")]
        [SerializeField] private float moveSpeed = 4f;        // metres per second
        [SerializeField] private float turnSpeed = 720f;      // degrees per second
 
        [Header("Jump and gravity")]
        [SerializeField] private float jumpHeight = 1.2f;     // metres
        [SerializeField] private float gravity = -20f;        // stronger than -9.81 feels less floaty
        [SerializeField] private float groundedStickSpeed = -2f;
        [SerializeField] private float coyoteTime = 0.1f;     // seconds of grace after leaving the ground
 
        [Header("References (optional)")]
        [Tooltip("Camera that movement is relative to. Empty = Camera.main.")]
        [SerializeField] private Transform cameraTransform;
        [Tooltip("Empty = the \"Move\" action from the project-wide actions asset.")]
        [SerializeField] private InputActionReference moveActionReference;
        [Tooltip("Empty = the \"Jump\" action from the project-wide actions asset.")]
        [SerializeField] private InputActionReference jumpActionReference;
 
        private CharacterController controller;
        private InputAction moveAction;
        private InputAction jumpAction;
        private float verticalVelocity;
        private float lastGroundedTime = float.NegativeInfinity;
 
        /// <summary>Horizontal speed this frame, in m/s. Handy for driving an Animator.</summary>
        public float HorizontalSpeed { get; private set; }
 
        private void Awake()
        {
            controller = GetComponent<CharacterController>();
            moveAction = ResolveAction(moveActionReference, "Move");
            jumpAction = ResolveAction(jumpActionReference, "Jump");
 
            if (cameraTransform == null && Camera.main != null)
                cameraTransform = Camera.main.transform;
        }
 
        private void OnEnable()
        {
            // Project-wide actions are enabled by default; actions from another asset may not be.
            if (moveAction != null && !moveAction.enabled) moveAction.Enable();
            if (jumpAction != null && !jumpAction.enabled) jumpAction.Enable();
        }
 
        private void Update()
        {
            if (moveAction == null || jumpAction == null) return;   // error already logged
 
            Vector3 horizontal = ReadCameraRelativeMove() * moveSpeed;
            HorizontalSpeed = horizontal.magnitude;
            FaceDirection(horizontal);
 
            ApplyGravityAndJump();
 
            Vector3 motion = horizontal;
            motion.y = verticalVelocity;
            controller.Move(motion * Time.deltaTime);   // the one Move call this frame
        }
 
        private Vector3 ReadCameraRelativeMove()
        {
            Vector2 input = Vector2.ClampMagnitude(moveAction.ReadValue<Vector2>(), 1f);
            if (cameraTransform == null)
                return new Vector3(input.x, 0f, input.y);            // fall back to world axes
 
            Vector3 forward = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up).normalized;
            Vector3 right = Vector3.ProjectOnPlane(cameraTransform.right, Vector3.up).normalized;
            return forward * input.y + right * input.x;
        }
 
        private void FaceDirection(Vector3 horizontal)
        {
            if (horizontal.sqrMagnitude < 0.0001f) return;           // keep facing when idle
            Quaternion target = Quaternion.LookRotation(horizontal, Vector3.up);
            transform.rotation = Quaternion.RotateTowards(transform.rotation, target, turnSpeed * Time.deltaTime);
        }
 
        private void ApplyGravityAndJump()
        {
            // isGrounded describes the PREVIOUS Move call, so read it before this frame's Move.
            if (controller.isGrounded)
            {
                lastGroundedTime = Time.time;
                if (verticalVelocity < 0f) verticalVelocity = groundedStickSpeed;   // keep touching the floor
            }
 
            bool canJump = Time.time - lastGroundedTime <= coyoteTime;
            if (canJump && jumpAction.WasPressedThisFrame())
            {
                verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);   // v = sqrt(2gh)
                lastGroundedTime = float.NegativeInfinity;                    // no double jump from coyote time
            }
 
            verticalVelocity += gravity * Time.deltaTime;
        }
 
        private InputAction ResolveAction(InputActionReference reference, string fallbackName)
        {
            InputAction action = reference != null ? reference.action : null;
            if (action == null && InputSystem.actions != null)
                action = InputSystem.actions.FindAction(fallbackName);
 
            if (action == null)
                Debug.LogError($"{name}: no \"{fallbackName}\" action. Assign one in the Inspector, or create the " +
                               "project-wide actions in Edit > Project Settings > Input System Package.", this);
            return action;
        }
    }
}