using System.Collections; using System.Collections.Generic; using UnityEditor; using UnityEngine; using UnityEngine.InputSystem; public class PlayerMovement : MonoBehaviour, PlayerInput.IMovementActions { // TYPES public enum State { INVALID = 0, GROUNDED = 1, SLIDING = 2, AIRBORNE = 3 } // REFERENCES public CapsuleCollider playerNavCollider; public Rigidbody rgdBdy; public PlayerCameraController camCntlr; // PARAMETERS public float smoothing; public string[] groundingLayers; public float moveForce = 30f; public float maxSpeed = 10f; public AnimationCurve brakingForceCurve = null; public float slidingAngle = 45f; public float jumpHeight = 3f; public float jumpCooldown = 0.15f; public float coyoteTime = 0.1f; public float turnRate = 270f; // PROPERTIES public State CurState { get { return this._curState; } set { // Guard; same state if (value == this._curState) return; // TODO: Teardown // Swap this._curState = value; // TODO: Setup } } // INTERNALS private int _groundingMask = -1; private PlayerInput _inpt = null; private State _curState = State.AIRBORNE; private Vector2 _curMvInpt = Vector2.zero; private Vector2 _smoothMvInpt = Vector2.zero; private bool _strafeToggleHeld = false; private float _jumpHoldStartTime = -1f; private float _lastJumpTime = -1f; // Record the results of the grounding check // as instance variables private float _groundedTime = -1f; private Vector3 _groundNormal = Vector3.up; // LIFECYCLE private void Awake() { this._groundingMask = LayerMask.GetMask(this.groundingLayers); } private void OnEnable() { if (this._inpt == null) { this._inpt = new PlayerInput(); this._inpt.Movement.SetCallbacks(this); } this._inpt.Enable(); } private void OnDisable() { if (this._inpt != null) { this._inpt.Disable(); } } private void FixedUpdate() { this.HandleInput(); this.GroundCheck(); switch(this._curState) { case State.GROUNDED: this.HandleGrounded(); break; case State.SLIDING: this.HandleSliding(); break; case State.AIRBORNE: this.HandleAirborne(); break; default: break; } } // BEHAVIORS private void HandleInput() { // Smooth input this._smoothMvInpt = MathExtras.LerpSmooth2(this._smoothMvInpt, Vector2.ClampMagnitude(this._curMvInpt, 1f), Time.fixedDeltaTime, this.smoothing); // Clip Input if (this._curMvInpt.sqrMagnitude < 1e-3f && this._smoothMvInpt.sqrMagnitude < 1e-3f) this._smoothMvInpt = Vector2.zero; } // This gets run once per FixedUpdate private void GroundCheck() { // Spherecast parameters determined from player's capsule collider Vector3 start = (((this.playerNavCollider.height * 0.5f) - this.playerNavCollider.radius - 0.1f) * Vector3.down) + this.rgdBdy.position; float dist = (Physics.gravity * Time.fixedDeltaTime * 3f).magnitude; Ray ry = new Ray(start, Physics.gravity); RaycastHit ht; // _groundingMask determines which layers are checked for ground // See LayerMask.GetMask(string[] layers) for more info. Can leave as -1 to check all layers. if (Physics.SphereCast(ry, this.playerNavCollider.radius, out ht, dist, this._groundingMask)) { // Hit ground, record this._groundedTime = Time.time; this._groundNormal = ht.normal; } else { // Timeout ground normal if (this._groundedTime > 0f && (Time.time - this._groundedTime) > this.coyoteTime) { this._groundedTime = -1f; this._groundNormal = Vector3.up; } } } private void ApplyGroundForces() { // Convert input direction to camera local Vector3 input = this.camCntlr.cam.transform.TransformVector(new Vector3(this._smoothMvInpt.x, 0f, this._smoothMvInpt.y)); // Rotate to horizontal, then to match ground orientation input = Quaternion.FromToRotation(Vector3.up, this._groundNormal) * Quaternion.FromToRotation(this.camCntlr.transform.up, Vector3.up) * input; // Apply input force this.rgdBdy.AddForce(input * this.moveForce, ForceMode.Force); Debug.DrawLine(this.rgdBdy.position, this.rgdBdy.position + (input * this.moveForce), Color.white); // Apply braking Vector3 groundSpeed = Vector3.ProjectOnPlane(this.rgdBdy.velocity, this._groundNormal); float brakingForce = this.brakingForceCurve.Evaluate(groundSpeed.magnitude / this.maxSpeed) * this.moveForce; this.rgdBdy.AddForce(groundSpeed.normalized * -1f * brakingForce, ForceMode.Force); Debug.DrawLine(this.rgdBdy.position, this.rgdBdy.position + (groundSpeed.normalized * -1f * brakingForce), Color.blue); // Counteract gravity while grounded on slope this.rgdBdy.AddForce(Vector3.ProjectOnPlane(Physics.gravity, this._groundNormal) * -1f); // Stopping force (only when no input, only when grounded) if (this._groundedTime == Time.time && this._smoothMvInpt == Vector2.zero) this.rgdBdy.AddForce(MathExtras.ClipVec3(groundSpeed, 1f) * -1f * this.moveForce, ForceMode.Force); } private bool ApplyJumpForces() { // Guard; Jumped to recently if ((Time.time - this._lastJumpTime) < this.jumpCooldown) return false; // Record this._lastJumpTime = Time.time; // Calculate jump height from gravity Vector3 jumpFrc = Physics.gravity.normalized * -1f * Mathf.Sqrt(2f * Physics.gravity.magnitude * this.jumpHeight); this.rgdBdy.AddForce(jumpFrc, ForceMode.VelocityChange); return true; } private void HandleFacing() { // Get desired facing direction Vector3 facingDir = this.transform.forward; if (this._strafeToggleHeld) { // Face camera direction facingDir = this.camCntlr.cam.transform.forward; facingDir.y = 0f; facingDir.Normalize(); } else { // Face direction of travel Vector3 groundVel = Vector3.ProjectOnPlane(this.rgdBdy.velocity, this._groundNormal); if (groundVel.sqrMagnitude > 1e-2f) facingDir = groundVel.normalized; } // Get target rotation Quaternion facingRot = Quaternion.FromToRotation(this.transform.forward, facingDir); facingRot = Quaternion.Euler(Vector3.Scale(facingRot.eulerAngles, new Vector3(0f, 1f, 0f))) * this.rgdBdy.rotation; // Yaw player towards facing direction this.rgdBdy.rotation = Quaternion.RotateTowards(this.rgdBdy.rotation, facingRot, this.turnRate * Time.fixedDeltaTime); } // FSM private void HandleGrounded() { // Watch for state change if (Time.time != this._groundedTime) { this.CurState = State.AIRBORNE; return; } else if (Vector3.Angle(Vector3.up, this._groundNormal) > this.slidingAngle) { this.CurState = State.SLIDING; return; } // Check and execute jump // The '&&' operator 'short-circuits', so ApplyJumpForces will only be run if the previous operands are true if (this._jumpHoldStartTime > 0f && (Time.time - this._jumpHoldStartTime) < this.coyoteTime && this.ApplyJumpForces()) return; this.ApplyGroundForces(); this.HandleFacing(); } private void HandleSliding() { // Watch for state change if (Time.time != this._groundedTime) { this.CurState = State.AIRBORNE; return; } else if (Vector3.Angle(Vector3.up, this._groundNormal) < this.slidingAngle) { this.CurState = State.GROUNDED; return; } // TODO: Any behavior here? Braking force? } private void HandleAirborne() { // Watch for state change if (Time.time == this._groundedTime) { if (Vector3.Angle(Vector3.up, this._groundNormal) > this.slidingAngle) this.CurState = State.SLIDING; else this.CurState = State.GROUNDED; return; } // TODO: Any behavior here? Air control? } // DEBUG #if UNITY_EDITOR private void OnDrawGizmos() { Handles.Label(this.transform.position + Vector3.up * 2f, $"State: {this._curState}\r\nSpeed: {this.rgdBdy.velocity.magnitude}\r\nAngle: {Vector3.Angle(Vector3.up, this._groundNormal)}"); } #endif // INPUT public void OnMoveInput(InputAction.CallbackContext context) { this._curMvInpt = context.ReadValue(); } public void OnStrafeToggle(InputAction.CallbackContext context) { if (context.phase == InputActionPhase.Started) this._strafeToggleHeld = true; else if (context.phase == InputActionPhase.Canceled) this._strafeToggleHeld = false; } public void OnJump(InputAction.CallbackContext context) { if (context.phase == InputActionPhase.Started) this._jumpHoldStartTime = Time.time; else if (context.phase == InputActionPhase.Canceled) this._jumpHoldStartTime = -1f; } }