// 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; } } }