Right now you probably regret asking, but here is the player controller script (and the only script on the player):
I’m sorry for the mess it is, but it’s my first script I wrote myself since starting unity recently.
Most of the functions can probably be ignored as they are just re-calculating and altering the variable moveDir which is just the direction vector, which is then used in FixedUpdate with charactercontroller.Move().
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerControllerV6 : MonoBehaviour
{
[Header("References")]
public CharacterController controller;
public Transform cam;
public PlayerControls controls;
//LAYERS SLOPE CALCULATION, SLIDE & JUMP FORGIVENESS
[SerializeField]
private LayerMask walkableLayers;
[Space(10)]
[Header("Movement")]
//MOVEMENT
[SerializeField] [Range(0.0f, 15.0f)]
private float speed = 10;
private Vector3 moveDir;
//DIRECTION
private float turnSmoothVelocityGround;
private float turnSmoothVelocityAir;
private float turnSmoothVelocitySlide;
private float turnSmoothTime = 0.1f;
[SerializeField]
//JUMPING
private float jumpHeight = 2f;
[SerializeField]
private float cutJumpSpeedLimit = 2.5f; // 1/10th of gravity is generally good, smooth value // better feel with just above jump height, or 1.5 of jump height
private bool canDoubleJump = true;
private bool canSlideJump = true;
[SerializeField] [Range(0.1f, 1.0f)]
private float doubleJumpFactor = 0.7f;
private bool willJump;
private bool willCancelJump;
//AIR CONTROL
[SerializeField] [Range(0.1f, 1.0f)]
private float airControlFactor = 0.8f;
[SerializeField] [Range(45.0f, 50.0f)]
private float airMovementDampeningFactor = 48f; // 0 is full dampening, 50 is none
private Vector2 directionBufferAirControl = new Vector2(0, 0);
private Vector2 directionBufferAirControlStatic = new Vector2(0, 0);
private float maxMagnitudeAirControl;
private bool isInAirControlCenterLock = false;
[Space(10)]
[Header("World & Design Adjustments")]
//GRAVITY
private float floorStickiness = -2f;
private float gravityValue = -9.81f;
[SerializeField] [Range(1.0f, 3.0f)]
private float gravityValueModifier = 2; //1 is no modification, <1 reduced gravity >1 increases - calculation in Start function
private bool isGravityModified = false;
private float normalGravityValue;
private float modifiedGravityValue;
[Header("General")]
//INPUTS
private bool playerInputJump;
private bool playerInputJumpCancel;
private Vector2 playerInputXY;
//CONTROLLER GROUND CHECK
private bool isPlayerGrounded;
//SLIDING
private bool isSliding;
private Vector3 slopeColliderHitPoint;
[SerializeField]
private float slideLimit = 51; //should be slope limit (or slightly higher to avoid jittering on slope from "isSliding true -> false -> true)
//GRACE TIMES
private int offLedgeGraceTime = 5; //5 is a good value (0.1s)
private int offLedgeGraceTimer = 0;
private int toSlideGraceTime = 5;
private int toSlideGraceTimer;
private void Start()
{
normalGravityValue = gravityValue;
modifiedGravityValue = gravityValue * gravityValueModifier;
}
//NEW INPUT SYSTEM
private void Awake()
{
controls = new PlayerControls();
controls.Player.Move.performed += ctx => MoveInput(ctx.ReadValue<Vector2>());
controls.Player.Move.canceled += ctx => MoveInput(Vector2.zero);
controls.Player.Jump.performed += ctx => JumpInput();
controls.Player.Jump.canceled += ctx => CancelJumpInput();
}
private void OnEnable()
{
controls.Player.Enable();
}
private void OnDisable()
{
controls.Player.Disable();
}
private void MoveInput(Vector2 direction)
{
playerInputXY = direction;
}
private void JumpInput()
{
willJump = true;
//Debug.Log("Pressed Jump");
}
private void CancelJumpInput()
{
willCancelJump = true;
//Debug.Log("Cancel Jump");
}
//OLD INPUT SYSTEM
private void Update()
{
/* //grab inputs
playerInputXY.x = Input.GetAxis("Horizontal");
playerInputXY.y = Input.GetAxis("Vertical");
playerInputJump = Input.GetButtonDown("Jump");
playerInputJumpCancel = Input.GetButtonUp("Jump");
if (playerInputJump)
{
willJump = true;
}
if (playerInputJumpCancel) //no "else if" because both can happen in the same frame - then a hop should occur
{
willCancelJump = true;
}*/
}
void FixedUpdate()
{
CalculateMovement(playerInputXY, willJump, willCancelJump);
controller.Move(moveDir * Time.deltaTime);
AdjustFallingGravity();
}
private Vector3 CalculateMovement(Vector2 xyInput, bool jumpTrigger, bool jumpCancelTrigger)
{
//GRAVITY PART1
//check if grounded and negate falling
isPlayerGrounded = controller.isGrounded;
if (isPlayerGrounded == true && moveDir.y <= 0)
{
//always apply small gravity to make isGrounded work properly
moveDir.y = floorStickiness;
}
//FORGIVING MECHANICS RAY
//raycast down from player when in the air
RaycastHit hitBelowPlayerGracejump;
bool rayPlayerDownHitSmthGracejump = false;
if (isPlayerGrounded == false)
{
CastRayDown(transform.position, 1.2f, out hitBelowPlayerGracejump, out rayPlayerDownHitSmthGracejump);
}
//INPUT CONTROLS
//get sidewards movement direction and clamp at 1 (normalize above 1)
xyInput = Vector2.ClampMagnitude(xyInput, 1f);
//ignore minor joystick movements
if (xyInput.magnitude < 0.1f)
{
xyInput.x = 0f;
xyInput.y = 0f;
}
//HORIZONTAL MOVEMENT
Vector2 basicMovement = HorizontalDirectionCalculation(xyInput);
moveDir.x = basicMovement.x;
moveDir.z = basicMovement.y;
//AIRCONTROL
if (isPlayerGrounded == true)
{
//reset variables on landing and store values of ground movement
directionBufferAirControlStatic = new Vector2(moveDir.x, moveDir.z);
directionBufferAirControl = directionBufferAirControlStatic;
maxMagnitudeAirControl = directionBufferAirControl.magnitude;
isInAirControlCenterLock = false;
}
else //if in air, then re-calculate moveDir under new movement conditions
{
Vector2 airMovement = AirControl(new Vector2(moveDir.x, moveDir.z));
moveDir.x = airMovement.x;
moveDir.z = airMovement.y;
}
//SLOPE CHECK (ANTI-BUMP, SPEED ADJUSTMENT, SLIDING)
if (isPlayerGrounded == true)
{
//raycast down from player when on ground
CastRayDown(transform.position, 1.05f, out RaycastHit hitBelowPlayerSlope, out bool rayPlayerDownHitSmthSlope);
//if nothing is hit, recast from character collider
if (rayPlayerDownHitSmthSlope == false)
{
CastRayDown(new Vector3(slopeColliderHitPoint.x, slopeColliderHitPoint.y + 0.15f, slopeColliderHitPoint.z), 0.25f, out hitBelowPlayerSlope, out rayPlayerDownHitSmthSlope);
}
//do slope adjustment only if: ray hits something && the player won't jump in this frame (no need to calculate in air)
if (rayPlayerDownHitSmthSlope == true)
{
if (Vector3.Angle(hitBelowPlayerSlope.normal, Vector3.up) < slideLimit)
{
isSliding = false;
toSlideGraceTimer = toSlideGraceTime;
if (jumpTrigger == false) //this is so the player doesn't jump flat when jumping while moving downhill
{
moveDir = SlopeCalculation(hitBelowPlayerSlope, moveDir);
}
}
else
{
moveDir = SlidingCalculation(hitBelowPlayerSlope);
}
}
}
else
{
isSliding = false;
toSlideGraceTimer = toSlideGraceTime;
}
//apply speed
moveDir.x *= speed;
moveDir.z *= speed;
//remove sliding and random jittering around
if (new Vector2(moveDir.x, moveDir.z).magnitude / speed < 0.1f)
{
moveDir.x = 0f;
moveDir.z = 0f;
}
//JUMPING
Jump();
//GRAVITY PART 2
//gravity (2* Time.deltaTime as it's applied as acceleration, not velocity)
moveDir.y += gravityValue * Time.deltaTime;
return moveDir;
//FUNCTIONS
Vector2 HorizontalDirectionCalculation(Vector2 _directionXYIn)
{
//calculate direction from Input + Camera
float targetAngle = Mathf.Atan2(_directionXYIn.x, _directionXYIn.y) * Mathf.Rad2Deg + cam.eulerAngles.y;
float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref turnSmoothVelocityGround, turnSmoothTime);
//apply movement to moveDir
Vector2 _moveDir;
_moveDir.x = (Quaternion.Euler(0f, targetAngle, 0f) * new Vector3(0, 0, _directionXYIn.magnitude)).x; //quaternion * vector3 returns a rotated vector (adds rotation to vector) //changed for direction.magnitude to make controller sensitive
_moveDir.y = (Quaternion.Euler(0f, targetAngle, 0f) * new Vector3(0, 0, _directionXYIn.magnitude)).z;
//rotate player
if (_directionXYIn.magnitude >= 0.1f && isPlayerGrounded == true && isSliding == false) //xyInput so player does not rotate with camera when no input
{
transform.rotation = Quaternion.Euler(0f, angle, 0f);
}
return _moveDir;
}
Vector2 AirControl(Vector2 _directionXYIn)
{
Vector2 _airMovement;
if (_directionXYIn.magnitude >= 0.2f && Vector2.Angle(_directionXYIn, directionBufferAirControlStatic) < 150 && isInAirControlCenterLock == false) //needed to avoid "snapping" when controller returns to 0 && needed to always be able to go back to 0, independent of AC factor && to disable this movement once in center
{
// overlay original direction with input
_airMovement = directionBufferAirControlStatic + (_directionXYIn * airControlFactor); //no time.deltatime as not an acceleration
}
else //keep direction when no input
{
//keep old direction and also slow down by dampening factor
_airMovement = directionBufferAirControl * airMovementDampeningFactor * Time.deltaTime; //time.deltatime as it is a deceleration (Buffer not static but getting less each frame)
}
//add -0.3 moveability in all directions if returned to 0
if (_airMovement.magnitude <= 0.3f)
{
_airMovement = Vector2.ClampMagnitude(_directionXYIn, 0.3f);
isInAirControlCenterLock = true;
}
else //clamp to current magnitude to avoid returning to old speed without input //"else" because not needed when under 0.3, also causes jittering on rotation for unknown reason
{
_airMovement = Vector2.ClampMagnitude(_airMovement, maxMagnitudeAirControl);
maxMagnitudeAirControl = MaxClampVector(_airMovement);
}
//double jump controls
if (willJump && canDoubleJump && rayPlayerDownHitSmthGracejump == false)
{
//overlay half the current movement with half the input
_airMovement = _airMovement * 0.5f + _directionXYIn * 0.5f;
directionBufferAirControlStatic = _airMovement;
maxMagnitudeAirControl = MaxClampVector(_airMovement);
isInAirControlCenterLock = false;
}
//rotation in air with if requirement so it does not reset the direction to 0 when jumping)
if (_airMovement.magnitude >= 0.15f) //only rotate when moving
{
//rotate player
float targetAngleAir = Mathf.Atan2(_airMovement.x, _airMovement.y) * Mathf.Rad2Deg;
float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleAir, ref turnSmoothVelocityAir, turnSmoothTime * 2f);
transform.rotation = Quaternion.Euler(0f, angle, 0f);
}
//store direction in case of no input in next frame
directionBufferAirControl = _airMovement;
return _airMovement;
//FUNCTIONS
float MaxClampVector(Vector2 _vectorToClamp)
{
float _maxMagnitude = _vectorToClamp.magnitude;
//always allow certain move ability
if (_maxMagnitude < 0.3f)
{
_maxMagnitude = 0.3f;
}
return _maxMagnitude;
}
//OLD APPROACHES FOR FUTURE REFERENCE
//_directionXYIn = Vector2.Lerp(directionBufferAirControl, _directionXYIn, airControlFactor * Time.deltaTime);
//_directionXYIn = Vector2.SmoothDamp(directionBufferAirControl, _directionXYIn, ref airControlSmoothVelocity, airControlFactor);
//Debug.Log(_directionXYIn);
//NOTES:
//- possible issue: jump from standing. Currently a jump in a direction is possible in all directions e.g. 0 +- 0.8(ACF). However, this is then being clamped as soon as it is above 0.2, but at a "random" value, depending on how fast the input is registered.
// However, it can never be more than the ACF (e.g. 0.8) or less than the input clamping requirement (e.g. 0.15), so it might be acceptable.
}
void CastRayDown(Vector3 rayOrigin, float rayLength, out RaycastHit _hit, out bool _hitSomething)
{
Ray rayPlayerDown = new Ray(rayOrigin, Vector3.down);
_hitSomething = Physics.Raycast(rayPlayerDown, out _hit, rayLength, walkableLayers);
}
Vector3 SlopeCalculation(RaycastHit _hitBelowPlayer, Vector3 _directionXYZIn)
{
float _slopeAngle = Vector3.Angle(_hitBelowPlayer.normal, new Vector3(_directionXYZIn.x, 0, _directionXYZIn.z)) - 90;
//calculate slopefactor to adjust speed accordingly (only apply to Y downwards, keep -floorStickiness for upwards)
float slopeSpeedFactor = 1;
if (_slopeAngle > -90 && _slopeAngle < 90)
{
slopeSpeedFactor = 1 / Mathf.Cos(_slopeAngle * Mathf.Deg2Rad); //always 1, not directionXZ.magnitude to not cancel it out
}
//move character y according to slope; only when moving down to prevent "overshoot" at end of slope
if (_slopeAngle < 0)
{
_directionXYZIn.y += -(Quaternion.Euler(_slopeAngle, 0f, 0f) * new Vector3(0, 0, new Vector2(_directionXYZIn.x, _directionXYZIn.z).magnitude) * speed / slopeSpeedFactor).y;
}
//adjust XZ speed to slope
_directionXYZIn.x /= slopeSpeedFactor;
_directionXYZIn.z /= slopeSpeedFactor;
return _directionXYZIn;
}
Vector3 SlidingCalculation(RaycastHit _hitBelowPlayer)
{
//forgiving mechanics
GraceTimeToSlide();
isSliding = true;
if (toSlideGraceTimer == 0)
{
Vector3 _slopeNormal = _hitBelowPlayer.normal;
Vector3 _temp = Vector3.Cross(Vector3.up, _slopeNormal); //left/right to slope
Vector3 _moveDir = (Vector3.Cross(_temp, _slopeNormal).normalized); //90 to left right = parallel to slope
_moveDir.y = floorStickiness + (_moveDir.y * speed); //apply speed to y as this is usually not done (same is done in SlopeCalculation)
//rotate player
float targetAngleSlide = Mathf.Atan2(_moveDir.x, _moveDir.z) * Mathf.Rad2Deg;
float angleSlide = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleSlide, ref turnSmoothVelocitySlide, turnSmoothTime * 2f);
transform.rotation = Quaternion.Euler(0f, angleSlide, 0f);
return _moveDir;
}
else
{
return moveDir;
}
//FUNCTIONS
void GraceTimeToSlide()
{
toSlideGraceTimer -= 1;
if (toSlideGraceTimer < 0)
{
toSlideGraceTimer = 0;
}
}
}
void Jump()
{
//Forgiving mechanics
GraceTimeOffLedge();
//reset double / slide jump ability
if (isPlayerGrounded)
{
canDoubleJump = true;
}
if (isPlayerGrounded && isSliding == false)
{
canSlideJump = true;
}
//initiate normal jump
if (offLedgeGraceTimer > 0 && jumpTrigger)
{
moveDir.y += Mathf.Sqrt(jumpHeight * -2.0f * normalGravityValue) - floorStickiness;
offLedgeGraceTimer = 0;
}
//initiate slide jump //play around with different jumps... for example normal or double jump
else if (isSliding == true && canSlideJump && jumpTrigger)
{
moveDir.y = Mathf.Sqrt(jumpHeight * doubleJumpFactor * -2.0f * normalGravityValue);
offLedgeGraceTimer = 0;
canSlideJump = false;
}
//initiate double jump
else if (canDoubleJump && jumpTrigger && rayPlayerDownHitSmthGracejump == false && isSliding == false) //add additional requirement here and to aircontrol function //rayPlayerDownHitSmthGraceump == false to avoid double jump just before landing
{
moveDir.y = Mathf.Sqrt(jumpHeight * doubleJumpFactor * -2.0f * normalGravityValue);
canDoubleJump = false;
}
//jump cancellation
//check if player is currently jumping state by checking if moveDir.y is positive. If it's positive and very small because it's the end of the jump, don't do anything (cutJumpSpeedLimit). Add a minor upwards movement for fluent movement (same as cut-off).
//PROBLEM: No effective "is jumping" check; if moved up by other force and then released, it will stop that movement... possibly not a problem if nothing else moves the Character up while controls enabled.
if (jumpCancelTrigger && moveDir.y > cutJumpSpeedLimit)
{
moveDir.y = cutJumpSpeedLimit;
}
//forgiving mechanics part 2 and jump trigger reset
//jump trigger stays active when pressed in air unless ray is not hitting ground || player moves upwards (to reset it directly after a jump)
//same for cancelTrigger
if (rayPlayerDownHitSmthGracejump == false || moveDir.y > 0)
{
willJump = false;
willCancelJump = false;
}
//FUNCTIONS
void GraceTimeOffLedge()
{
if (isPlayerGrounded && isSliding == false) // MAYBE && SLOPE LESS THAN 50 - THIS WILL ALLOW A JUMP SHORTLY AFTER "SLIPPING" //or maybe "is not sliding"
{
offLedgeGraceTimer = offLedgeGraceTime;
}
else
{
offLedgeGraceTimer -= 1;
if (offLedgeGraceTimer < 0)
{
offLedgeGraceTimer = 0;
}
}
}
}
}
private void AdjustFallingGravity()
{
if (isPlayerGrounded == false && moveDir.y < 0 && isGravityModified == false)
{
gravityValue = modifiedGravityValue;
isGravityModified = true;
}
if ((isPlayerGrounded == true || moveDir.y > 0) && isGravityModified == true)
{
gravityValue = normalGravityValue;
isGravityModified = false;
}
}
private void OnControllerColliderHit(ControllerColliderHit hit)
{
slopeColliderHitPoint = hit.point;
}
}