// 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;
/// Horizontal speed this frame, in m/s. Handy for driving an Animator.
public float HorizontalSpeed { get; private set; }
private void Awake()
{
controller = GetComponent();
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(), 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;
}
}
}