using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.Events;
namespace Unity.Cinemachine.Samples
{
///
/// This is the base class for SimplePlayerController and SimplePlayerController2D.
/// You can also use it as a base class for your custom controllers.
/// It provides the following:
///
/// **Services:**
///
/// - 2D motion axes (MoveX and MoveZ)
/// - Jump button
/// - Sprint button
/// - API for strafe mode
///
/// **Actions:**
///
/// - PreUpdate - invoked at the beginning of `Update()`
/// - PostUpdate - invoked at the end of `Update()`
/// - StartJump - invoked when the player starts jumping
/// - EndJump - invoked when the player stops jumping
///
/// **Events:**
///
/// - Landed - invoked when the player lands on the ground
///
public abstract class SimplePlayerControllerBase : MonoBehaviour, Unity.Cinemachine.IInputAxisOwner
{
[Tooltip("Ground speed when walking")]
public float Speed = 1f;
[Tooltip("Ground speed when sprinting")]
public float SprintSpeed = 4;
[Tooltip("Initial vertical speed when jumping")]
public float JumpSpeed = 4;
[Tooltip("Initial vertical speed when sprint-jumping")]
public float SprintJumpSpeed = 6;
public Action PreUpdate;
public Action PostUpdate;
public Action StartJump;
public Action EndJump;
[Header("Input Axes")]
[Tooltip("X Axis movement. Value is -1..1. Controls the sideways movement")]
public InputAxis MoveX = InputAxis.DefaultMomentary;
[Tooltip("Z Axis movement. Value is -1..1. Controls the forward movement")]
public InputAxis MoveZ = InputAxis.DefaultMomentary;
[Tooltip("Jump movement. Value is 0 or 1. Controls the vertical movement")]
public InputAxis Jump = InputAxis.DefaultMomentary;
[Tooltip("Sprint movement. Value is 0 or 1. If 1, then is sprinting")]
public InputAxis Sprint = InputAxis.DefaultMomentary;
[Header("Events")]
[Tooltip("This event is sent when the player lands after a jump.")]
public UnityEvent Landed = new ();
/// Report the available input axes to the input axis controller.
/// We use the Input Axis Controller because it works with both the Input package
/// and the Legacy input system. This is sample code and we
/// want it to work everywhere.
void IInputAxisOwner.GetInputAxes(List axes)
{
axes.Add(new () { DrivenAxis = () => ref MoveX, Name = "Move X", Hint = IInputAxisOwner.AxisDescriptor.Hints.X });
axes.Add(new () { DrivenAxis = () => ref MoveZ, Name = "Move Z", Hint = IInputAxisOwner.AxisDescriptor.Hints.Y });
axes.Add(new () { DrivenAxis = () => ref Jump, Name = "Jump" });
axes.Add(new () { DrivenAxis = () => ref Sprint, Name = "Sprint" });
}
public virtual void SetStrafeMode(bool b) {}
public abstract bool IsMoving { get; }
}
///
/// Building on top of SimplePlayerControllerBase, this is the 3D character controller.
/// It provides the following services and settings:
///
/// - Damping (applied to the player's velocity, and to the player's rotation)
/// - Strafe Mode
/// - Gravity
/// - Input Frames (which reference frame is used fo interpreting input: Camera, World, or Player)
/// - Ground Detection (using raycasts, or delegating to Character Controller)
/// - Camera Override (camera is used only for determining the input frame)
///
/// This behaviour should be attached to the player GameObject's root. It moves the GameObject's
/// transform. If the GameObject also has a Unity Character Controller component, the Simple Player
/// Controller delegates grounded state and movement to it. If the GameObject does not have a
/// Character Controller, the Simple Player Controller manages its own movement and does raycasts
/// to test for grounded state.
///
/// Simple Player Controller does its best to interpret User input in the context of the
/// selected reference frame. Generally, this works well, but in Camera mode, the user
/// may potentially transition from being upright relative to the camera to being inverted.
/// When this happens, there can be a discontinuity in the interpretation of the input.
/// The Simple Player Controller has an ad-hoc technique of resolving this discontinuity,
/// (you can see this in the code), but it is only used in this very specific situation.
///
public class SimplePlayerController : SimplePlayerControllerBase
{
[Tooltip("Transition duration (in seconds) when the player changes velocity or rotation.")]
public float Damping = 0.5f;
[Tooltip("Makes the player strafe when moving sideways, otherwise it turns to face the direction of motion.")]
public bool Strafe = false;
public enum ForwardModes { Camera, Player, World };
public enum UpModes { Player, World };
[Tooltip("Reference frame for the input controls:\n"
+ "Camera: Input forward is camera forward direction.\n"
+ "Player: Input forward is Player's forward direction.\n"
+ "World: Input forward is World forward direction.")]
public ForwardModes InputForward = ForwardModes.Camera;
[Tooltip("Up direction for computing motion:\n"
+ "Player: Move in the Player's local XZ plane.\n"
+ "World: Move in global XZ plane.")]
public UpModes UpMode = UpModes.World;
[Tooltip("If non-null, take the input frame from this camera instead of Camera.main. Useful for split-screen games.")]
public Camera CameraOverride;
[Tooltip("Layers to include in ground detection via Raycasts.")]
public LayerMask GroundLayers = 1;
[Tooltip("Force of gravity in the down direction (m/s^2)")]
public float Gravity = 10;
const float kDelayBeforeInferringJump = 0.3f;
float m_TimeLastGrounded = 0;
Vector3 m_CurrentVelocityXZ;
Vector3 m_LastInput;
float m_CurrentVelocityY;
bool m_IsSprinting;
bool m_IsJumping;
CharacterController m_Controller; // optional
// These are part of a strategy to combat input gimbal lock when controlling a player
// that can move freely on surfaces that go upside-down relative to the camera.
// This is only used in the specific situation where the character is upside-down relative to the input frame,
// and the input directives become ambiguous.
// If the camera and input frame are travelling along with the player, then these are not used.
bool m_InTopHemisphere = true;
float m_TimeInHemisphere = 100;
Vector3 m_LastRawInput;
Quaternion m_Upsidedown = Quaternion.AngleAxis(180, Vector3.left);
public override void SetStrafeMode(bool b) => Strafe = b;
public override bool IsMoving => m_LastInput.sqrMagnitude > 0.01f;
public bool IsSprinting => m_IsSprinting;
public bool IsJumping => m_IsJumping;
public Camera Camera => CameraOverride == null ? Camera.main : CameraOverride;
public bool IsGrounded() => GetDistanceFromGround(transform.position, UpDirection, 10) < 0.01f;
// Note that m_Controller is an optional component: we'll use it if it's there.
void Start() => TryGetComponent(out m_Controller);
private void OnEnable()
{
m_CurrentVelocityY = 0;
m_IsSprinting = false;
m_IsJumping = false;
m_TimeLastGrounded = Time.time;
}
void Update()
{
PreUpdate?.Invoke();
// Process Jump and gravity
bool justLanded = ProcessJump();
// Get the reference frame for the input
var rawInput = new Vector3(MoveX.Value, 0, MoveZ.Value);
var inputFrame = GetInputFrame(Vector3.Dot(rawInput, m_LastRawInput) < 0.8f);
m_LastRawInput = rawInput;
// Read the input from the user and put it in the input frame
m_LastInput = inputFrame * rawInput;
if (m_LastInput.sqrMagnitude > 1)
m_LastInput.Normalize();
// Compute the new velocity and move the player, but only if not mid-jump
if (!m_IsJumping)
{
m_IsSprinting = Sprint.Value > 0.5f;
var desiredVelocity = m_LastInput * (m_IsSprinting ? SprintSpeed : Speed);
var damping = justLanded ? 0 : Damping;
if (Vector3.Angle(m_CurrentVelocityXZ, desiredVelocity) < 100)
m_CurrentVelocityXZ = Vector3.Slerp(
m_CurrentVelocityXZ, desiredVelocity,
Damper.Damp(1, damping, Time.deltaTime));
else
m_CurrentVelocityXZ += Damper.Damp(
desiredVelocity - m_CurrentVelocityXZ, damping, Time.deltaTime);
}
// Apply the position change
ApplyMotion();
// If not strafing, rotate the player to face movement direction
if (!Strafe && m_CurrentVelocityXZ.sqrMagnitude > 0.001f)
{
var fwd = inputFrame * Vector3.forward;
var qA = transform.rotation;
var qB = Quaternion.LookRotation(
(InputForward == ForwardModes.Player && Vector3.Dot(fwd, m_CurrentVelocityXZ) < 0)
? -m_CurrentVelocityXZ : m_CurrentVelocityXZ, UpDirection);
var damping = justLanded ? 0 : Damping;
transform.rotation = Quaternion.Slerp(qA, qB, Damper.Damp(1, damping, Time.deltaTime));
}
if (PostUpdate != null)
{
// Get local-space velocity
var vel = Quaternion.Inverse(transform.rotation) * m_CurrentVelocityXZ;
vel.y = m_CurrentVelocityY;
PostUpdate(vel, m_IsSprinting ? JumpSpeed / SprintJumpSpeed : 1);
}
}
Vector3 UpDirection => UpMode == UpModes.World ? Vector3.up : transform.up;
// Get the reference frame for the input. The idea is to map camera fwd/right
// to the player's XZ plane. There is some complexity here to avoid
// gimbal lock when the player is tilted 180 degrees relative to the input frame.
Quaternion GetInputFrame(bool inputDirectionChanged)
{
// Get the raw input frame, depending of forward mode setting
var frame = Quaternion.identity;
switch (InputForward)
{
case ForwardModes.Camera: frame = Camera.transform.rotation; break;
case ForwardModes.Player: return transform.rotation;
case ForwardModes.World: break;
}
// Map the raw input frame to something that makes sense as a direction for the player
var playerUp = transform.up;
var up = frame * Vector3.up;
// Is the player in the top or bottom hemisphere? This is needed to avoid gimbal lock,
// but only when the player is upside-down relative to the input frame.
const float BlendTime = 2f;
m_TimeInHemisphere += Time.deltaTime;
bool inTopHemisphere = Vector3.Dot(up, playerUp) >= 0;
if (inTopHemisphere != m_InTopHemisphere)
{
m_InTopHemisphere = inTopHemisphere;
m_TimeInHemisphere = Mathf.Max(0, BlendTime - m_TimeInHemisphere);
}
// If the player is untilted relative to the input frmae, then early-out with a simple LookRotation
var axis = Vector3.Cross(up, playerUp);
if (axis.sqrMagnitude < 0.001f && inTopHemisphere)
return frame;
// Player is tilted relative to input frame: tilt the input frame to match
var angle = UnityVectorExtensions.SignedAngle(up, playerUp, axis);
var frameA = Quaternion.AngleAxis(angle, axis) * frame;
// If the player is tilted, then we need to get tricky to avoid gimbal-lock
// when player is tilted 180 degrees. There is no perfect solution for this,
// we need to cheat it :/
Quaternion frameB = frameA;
if (!inTopHemisphere || m_TimeInHemisphere < BlendTime)
{
// Compute an alternative reference frame for the bottom hemisphere.
// The two reference frames are incompatible where they meet, especially
// when player up is pointing along the X axis of camera frame.
// There is no one reference frame that works for all player directions.
frameB = frame * m_Upsidedown;
var axisB = Vector3.Cross(frameB * Vector3.up, playerUp);
if (axisB.sqrMagnitude > 0.001f)
frameB = Quaternion.AngleAxis(180f - angle, axisB) * frameB;
}
// Blend timer force-expires when user changes input direction
if (inputDirectionChanged)
m_TimeInHemisphere = BlendTime;
// If we have been long enough in one hemisphere, then we can just use its reference frame
if (m_TimeInHemisphere >= BlendTime)
return inTopHemisphere ? frameA : frameB;
// Because frameA and frameB do not join seamlessly when player Up is along X axis,
// we blend them over a time in order to avoid degenerate spinning.
// This will produce weird movements occasionally, but it's the lesser of the evils.
if (inTopHemisphere)
return Quaternion.Slerp(frameB, frameA, m_TimeInHemisphere / BlendTime);
return Quaternion.Slerp(frameA, frameB, m_TimeInHemisphere / BlendTime);
}
bool ProcessJump()
{
bool justLanded = false;
var now = Time.time;
bool grounded = IsGrounded();
m_CurrentVelocityY -= Gravity * Time.deltaTime;
if (!m_IsJumping)
{
// Process jump command
if (grounded && Jump.Value > 0.01f)
{
m_IsJumping = true;
m_CurrentVelocityY = m_IsSprinting ? SprintJumpSpeed : JumpSpeed;
}
// If we are falling, assume the jump pose
if (!grounded && now - m_TimeLastGrounded > kDelayBeforeInferringJump)
m_IsJumping = true;
if (m_IsJumping)
{
StartJump?.Invoke();
grounded = false;
}
}
if (grounded)
{
m_TimeLastGrounded = Time.time;
m_CurrentVelocityY = 0;
// If we were jumping, complete the jump
if (m_IsJumping)
{
EndJump?.Invoke();
m_IsJumping = false;
justLanded = true;
Landed.Invoke();
}
}
return justLanded;
}
void ApplyMotion()
{
if (m_Controller != null)
m_Controller.Move((m_CurrentVelocityY * UpDirection + m_CurrentVelocityXZ) * Time.deltaTime);
else
{
var pos = transform.position + m_CurrentVelocityXZ * Time.deltaTime;
// Don't fall below ground
var up = UpDirection;
var altitude = GetDistanceFromGround(pos, up, 10);
if (altitude < 0 && m_CurrentVelocityY <= 0)
{
pos -= altitude * up;
m_CurrentVelocityY = 0;
}
else if (m_CurrentVelocityY < 0)
{
var dy = -m_CurrentVelocityY * Time.deltaTime;
if (dy > altitude)
{
pos -= altitude * up;
m_CurrentVelocityY = 0;
}
}
transform.position = pos + m_CurrentVelocityY * up * Time.deltaTime;
}
}
float GetDistanceFromGround(Vector3 pos, Vector3 up, float max)
{
float kExtraHeight = m_Controller == null ? 2 : 0; // start a little above the player in case it's moving down fast
if (Physics.Raycast(pos + up * kExtraHeight, -up, out var hit,
max + kExtraHeight, GroundLayers, QueryTriggerInteraction.Ignore))
return hit.distance - kExtraHeight;
return max + 1;
}
}
}