Friday, March 6, 2015

Unity Touch Joysticks Canvas UI Tutorial - Touch Input FPS Controller

In this Unity C# tutorial we will learn how to make touch joysticks using the 4.6 Canvas UI system. We will create 2 joystick to drive a FPS controller similar to mobile games like Dead Trigger or ShadowGun.

Check out the code below the videos

Time Breakdown:
0:48 - Download the Sample Assets package
1:35 - Importing only what we need
2:52 - Setting up our scene with the prefabs and settings we'll need
4:43 - Demonstration of touch joystick as-is before we edit it
5:47 - Fixing the joystick so it doesn't snap to the bottom right corner
6:55 - Quick tip: Adding custom Task tokens for comments
8:02 - Clamping the joysticks in a circle rather than a square
10:00 - Using the joystick as a slider instead
10:43 - Explanation of how the joystick GetAxis and how you can use it for other purpose, not just a FPS controller
13:13 - Adding the Look joystick in Unity
14:33 - Difference between GetAxis and GetAxisRaw
15:25 - Editing RotateView to work with our touch joystick
18:57 - Checking and limiting camera angles
23:42 - Drawn explaination of camera angles and when we need to limit them
28:50 - Demonstration of the look joystick working in Unity
30:47 - (Optional) Dampening the vertical look speed slightly
31:43 - Demonstration of everything working on Android
32:50 - Outro, thanks for watching :D


Check out the video above to hear the explanation and see the code in action.
/*
 * This script is from the 4.6 Sample Assets with edits from Devin Curry
 * Search for changes tagged with the //DCURRY comment
 * Watch the tutorial here: www.Devination.com
 */
using UnityEngine;
using UnityEngine.EventSystems;
using UnitySampleAssets.CrossPlatformInput;

public class Joystick : MonoBehaviour , IPointerUpHandler , IPointerDownHandler , IDragHandler {

    public int MovementRange = 100;

    public enum AxisOption
    {                                                    // Options for which axes to use                                                     
        Both,                                                                   // Use both
        OnlyHorizontal,                                                         // Only horizontal
        OnlyVertical                                                            // Only vertical
    }

    public AxisOption axesToUse = AxisOption.Both;   // The options for the axes that the still will use
    public string horizontalAxisName = "Horizontal";// The name given to the horizontal axis for the cross platform input
    public string verticalAxisName = "Vertical";    // The name given to the vertical axis for the cross platform input 

    private Vector3 startPos;
    private bool useX;                                                          // Toggle for using the x axis
    private bool useY;                                                          // Toggle for using the Y axis
    private CrossPlatformInputManager.VirtualAxis horizontalVirtualAxis;               // Reference to the joystick in the cross platform input
    private CrossPlatformInputManager.VirtualAxis verticalVirtualAxis;                 // Reference to the joystick in the cross platform input
      
 void Start () {//DCURRY changed this to Start from OnEnable

        startPos = transform.position;
        CreateVirtualAxes ();
    }

    private void UpdateVirtualAxes (Vector3 value) {

  var delta = startPos - value;
        delta.y = -delta.y;
        delta /= MovementRange;
        if(useX)
        horizontalVirtualAxis.Update (-delta.x);

        if(useY)
        verticalVirtualAxis.Update (delta.y);

    }

    private void CreateVirtualAxes()
    {
        // set axes to use
        useX = (axesToUse == AxisOption.Both || axesToUse == AxisOption.OnlyHorizontal);
        useY = (axesToUse == AxisOption.Both || axesToUse == AxisOption.OnlyVertical);

        // create new axes based on axes to use
        if (useX)
            horizontalVirtualAxis = new CrossPlatformInputManager.VirtualAxis(horizontalAxisName);
        if (useY)
            verticalVirtualAxis = new CrossPlatformInputManager.VirtualAxis(verticalAxisName);
    }


    public  void OnDrag(PointerEventData data) {

        Vector3 newPos = Vector3.zero;

        if (useX) {
            int delta = (int) (data.position.x - startPos.x);//DCURRY deleted clamp
            newPos.x = delta;
        }

        if (useY)
        {
   int delta = (int)(data.position.y - startPos.y);//DCURRY deleted clamp
            newPos.y = delta;
        }
  //DCURRY added ClampMagnitude
  transform.position = Vector3.ClampMagnitude( new Vector3(newPos.x , newPos.y , newPos.z), MovementRange) + startPos;
        UpdateVirtualAxes (transform.position);
    }


    public  void OnPointerUp(PointerEventData data)
    {
        transform.position = startPos;
        UpdateVirtualAxes (startPos);
    }


    public  void OnPointerDown (PointerEventData data) {
    }

    void OnDisable () {
        // remove the joysticks from the cross platform input
        if (useX)
        {
            horizontalVirtualAxis.Remove();
        }
        if (useY)
        {
            verticalVirtualAxis.Remove();
        }
    }
}


And this is the modified FirstPersonController script
/*
 * This script is from the 4.6 Sample Assets with edits from Devin Curry
 * Search for changes tagged with the //DCURRY comment
 * Watch the tutorial here: www.Devination.com
 */
using UnityEngine;
using UnitySampleAssets.CrossPlatformInput;
using UnitySampleAssets.Utility;

namespace UnitySampleAssets.Characters.FirstPerson
{
    [RequireComponent(typeof (CharacterController))]
    [RequireComponent(typeof (AudioSource))]
    public class FirstPersonController : MonoBehaviour
    {

        //////////////////////// exposed privates ///////////////////////
        [SerializeField] private bool _isWalking;
  [SerializeField] private float walkSpeed;
  [SerializeField] private float lookSpeed = 4;//DCURRY add
        [SerializeField] private float runSpeed;
        [SerializeField] [Range(0f, 1f)] private float runstepLenghten;
        [SerializeField] private float jumpSpeed;
        [SerializeField] private float stickToGroundForce;
        [SerializeField] private float _gravityMultiplier;
        [SerializeField] private MouseLook _mouseLook;
        [SerializeField] private bool useFOVKick;
        [SerializeField] private FOVKick _fovKick = new FOVKick();
        [SerializeField] private bool useHeadBob;
        [SerializeField] private CurveControlledBob _headBob = new CurveControlledBob();
        [SerializeField] private LerpControlledBob _jumpBob = new LerpControlledBob();
        [SerializeField] private float _stepInterval;

        [SerializeField] private AudioClip[] _footstepSounds;
                                             // an array of footstep sounds that will be randomly selected from.

        [SerializeField] private AudioClip _jumpSound; // the sound played when character leaves the ground.
        [SerializeField] private AudioClip _landSound; // the sound played when character touches back on ground.

        ///////////////// non exposed privates /////////////////////////
        private Camera _camera;
        private bool _jump;
        private float _yRotation;
        private CameraRefocus _cameraRefocus;
        private Vector2 _input;
        private Vector3 _moveDir = Vector3.zero;
        private CharacterController _characterController;
        private CollisionFlags _collisionFlags;
        private bool _previouslyGrounded;
        private Vector3 _originalCameraPosition;
        private float _stepCycle = 0f;
        private float _nextStep = 0f;
        private bool _jumping = false;

        // Use this for initialization
        private void Start()
        {
            _characterController = GetComponent<CharacterController>();
            _camera = Camera.main;
            _originalCameraPosition = _camera.transform.localPosition;
            _cameraRefocus = new CameraRefocus(_camera, transform, _camera.transform.localPosition);
            _fovKick.Setup(_camera);
            _headBob.Setup(_camera, _stepInterval);
            _stepCycle = 0f;
            _nextStep = _stepCycle/2f;
            _jumping = false;
        }

        // Update is called once per frame
        private void Update()
        {
            RotateView();
            // the jump state needs to read here to make sure it is not missed
            if (!_jump)
                _jump = CrossPlatformInputManager.GetButtonDown("Jump");

            if (!_previouslyGrounded && _characterController.isGrounded)
            {
                StartCoroutine(_jumpBob.DoBobCycle());
                PlayLandingSound();
                _moveDir.y = 0f;
                _jumping = false;
            }
            if (!_characterController.isGrounded && !_jumping && _previouslyGrounded)
            {
                _moveDir.y = 0f;
            }

            _previouslyGrounded = _characterController.isGrounded;
        }

        private void PlayLandingSound()
        {
            audio.clip = _landSound;
            audio.Play();
            _nextStep = _stepCycle + .5f;
        }

        private void FixedUpdate()
        {
            float speed;
            GetInput(out speed);
            // always move along the camera forward as it is the direction that it being aimed at
            Vector3 desiredMove = _camera.transform.forward*_input.y + _camera.transform.right*_input.x;

            // get a normal for the surface that is being touched to move along it
            RaycastHit hitInfo;
            Physics.SphereCast(transform.position, _characterController.radius, Vector3.down, out hitInfo,
                               _characterController.height/2f);
            desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal).normalized;

            _moveDir.x = desiredMove.x*speed;
            _moveDir.z = desiredMove.z*speed;


            if (_characterController.isGrounded)
            {
                _moveDir.y = -stickToGroundForce;

                if (_jump)
                {
                    _moveDir.y = jumpSpeed;
                    PlayJumpSound();
                    _jump = false;
                    _jumping = true;
                }
            }
            else
            {
                _moveDir += Physics.gravity*_gravityMultiplier;
            }

            _collisionFlags = _characterController.Move(_moveDir*Time.fixedDeltaTime);

            ProgressStepCycle(speed);
            UpdateCameraPosition(speed);
        }

        private void PlayJumpSound()
        {
            audio.clip = _jumpSound;
            audio.Play();
        }

        private void ProgressStepCycle(float speed)
        {
            if (_characterController.velocity.sqrMagnitude > 0 && (_input.x != 0 || _input.y != 0))
                _stepCycle += (_characterController.velocity.magnitude + (speed*(_isWalking ? 1f : runstepLenghten)))*
                              Time.fixedDeltaTime;

            if (!(_stepCycle > _nextStep)) return;

            _nextStep = _stepCycle + _stepInterval;

            PlayFootStepAudio();
        }

        private void PlayFootStepAudio()
        {
            if (!_characterController.isGrounded) return;
            // pick & play a random footstep sound from the array,
            // excluding sound at index 0
            int n = Random.Range(1, _footstepSounds.Length);
            audio.clip = _footstepSounds[n];
            audio.PlayOneShot(audio.clip);
            // move picked sound to index 0 so it's not picked next time
            _footstepSounds[n] = _footstepSounds[0];
            _footstepSounds[0] = audio.clip;
        }

        private void UpdateCameraPosition(float speed)
        {
            Vector3 newCameraPosition;
            if (!useHeadBob) return;
            if (_characterController.velocity.magnitude > 0 && _characterController.isGrounded)
            {
                _camera.transform.localPosition =
                    _headBob.DoHeadBob(_characterController.velocity.magnitude +
                                       (speed*(_isWalking ? 1f : runstepLenghten)));
                newCameraPosition = _camera.transform.localPosition;
                newCameraPosition.y = _camera.transform.localPosition.y - _jumpBob.Offset();
            }
            else
            {
                newCameraPosition = _camera.transform.localPosition;
                newCameraPosition.y = _originalCameraPosition.y - _jumpBob.Offset();
            }
            _camera.transform.localPosition = newCameraPosition;

            _cameraRefocus.SetFocusPoint();
        }

        private void GetInput(out float speed)
        {
            // Read input
            float horizontal = CrossPlatformInputManager.GetAxisRaw("Horizontal");
            float vertical = CrossPlatformInputManager.GetAxisRaw("Vertical");

            bool waswalking = _isWalking;

#if !MOBILE_INPUT
            // On standalone builds, walk/run speed is modified by a key press.
            // keep track of whether or not the character is walking or running
            _isWalking = !Input.GetKey(KeyCode.LeftShift);
#endif
            // set the desired speed to be walking or running
            speed = _isWalking ? walkSpeed : runSpeed;
            _input = new Vector2(horizontal, vertical);

            // normalize input if it exceeds 1 in combined length:
            if (_input.sqrMagnitude > 1) _input.Normalize();

            // handle speed change to give an fov kick
            // only if the player is going to a run, is running and the fovkick is to be used
            if (_isWalking != waswalking && useFOVKick && _characterController.velocity.sqrMagnitude > 0)
            {
                StopAllCoroutines();
                StartCoroutine(!_isWalking ? _fovKick.FOVKickUp() : _fovKick.FOVKickDown());
            }
        }

        private void RotateView()
        {
   //DCURRY added else for mobile input
   #if !MOBILE_INPUT
            Vector2 mouseInput = _mouseLook.Clamped(_yRotation, transform.localEulerAngles.y);

   _camera.transform.localEulerAngles = new Vector3(-mouseInput.y, _camera.transform.localEulerAngles.y,
                                                    _camera.transform.localEulerAngles.z);
   transform.localEulerAngles = new Vector3(0, mouseInput.x, 0);
   #else
   Vector2 mouseInput = new Vector2(CrossPlatformInputManager.GetAxisRaw("HorizontalLook"),
                                    CrossPlatformInputManager.GetAxisRaw("VerticalLook"));

   float camX = _camera.transform.localEulerAngles.x;

   if((camX > 280 && camX <= 360) ||
      (camX >= 0 && camX < 80) ||
      (camX >= 80 && camX < 180 && mouseInput.y > 0) ||
      (camX > 180 && camX <= 280 && mouseInput.y < 0))
   {
    _camera.transform.localEulerAngles += new Vector3(-mouseInput.y * lookSpeed * .7f, _camera.transform.localEulerAngles.y,
                                                      _camera.transform.localEulerAngles.z);
   }

   transform.localEulerAngles += new Vector3(0, mouseInput.x * lookSpeed, 0);
   #endif
            // handle the roation round the x axis on the camera
            
            _yRotation = mouseInput.y;
            _cameraRefocus.GetFocusPoint();
        }

        private void OnControllerColliderHit(ControllerColliderHit hit)
        {
            Rigidbody body = hit.collider.attachedRigidbody;
            if (body == null || body.isKinematic)
                return;

            //dont move the rigidbody if the character is on top of it
            if (_collisionFlags == CollisionFlags.CollidedBelow) return;

            body.AddForceAtPosition(_characterController.velocity*0.1f, hit.point, ForceMode.Impulse);

        }
    }
}