using System.Collections.Generic; using Alchemy.Inspector; using KinematicCharacterController; using LitMotion; using LitMotion.Extensions; using R3; using UnityEngine; namespace Code.Game.Gameplay.Player { public enum CharacterState { Default, Sliding } public enum OrientationMethod { TowardsCamera, TowardsMovement, } public struct PlayerCharacterInputs { public float MoveAxisForward; public float MoveAxisRight; public Quaternion CameraRotation; public bool JumpDown; public bool CrouchDown; public bool CrouchUp; public bool WalkOn; } public struct AICharacterInputs { public Vector3 MoveVector; public Quaternion Look; } public enum BonusOrientationMethod { None, TowardsGravity, TowardsGroundSlopeAndGravity, } public class PlayerMovementController : MonoBehaviour, ICharacterController { public KinematicCharacterMotor Motor; [TabGroup("Stable Movement")] public float MaxStableMoveSpeed = 10f; [TabGroup("Stable Movement")] public float SprintingModifier = 2f; [TabGroup("Stable Movement")] public float StableMovementSharpness = 15f; [TabGroup("Stable Movement")] public float OrientationSharpness = 10f; [TabGroup("Stable Movement")] public OrientationMethod OrientationMethod = OrientationMethod.TowardsCamera; [TabGroup("Sliding")] public AnimationCurve SlideSpeedMultiplierCurve; [TabGroup("Sliding")] public float SlideSpeedCurveStep = 0.1f; [TabGroup("Sliding")] public float MaxCrouchMoveSpeed = 5f; [TabGroup("Sliding")] public float InCrouchTime = 1f; [TabGroup("Sliding")] public float OutCrouchTime = 1f; [TabGroup("Sliding")] public float SlideStupidity = 0.1f; [TabGroup("Air Movement")] public float MaxAirMoveSpeed = 15f; [TabGroup("Air Movement")] public float AirAccelerationSpeed = 15f; [TabGroup("Air Movement")] public float Drag = 0.1f; [TabGroup("Jumping")] public bool AllowJumpingWhenSliding = false; [TabGroup("Jumping")] public float JumpUpSpeed = 10f; [TabGroup("Jumping")] public float JumpScalableForwardSpeed = 10f; [TabGroup("Jumping")] public float JumpPreGroundingGraceTime = 0f; [TabGroup("Jumping")] public float JumpPostGroundingGraceTime = 0f; [TabGroup("Jumping")] public BonusOrientationMethod BonusOrientationMethod = BonusOrientationMethod.None; [TabGroup("Jumping")] public float BonusOrientationSharpness = 10f; [TabGroup("Jumping")] public float BonusOrientationGravitySharpness; [TabGroup("Jumping")] public Vector3 Gravity = new Vector3(0, -30f, 0); [TabGroup("Misc")] public List IgnoredColliders = new List(); [TabGroup("Misc")] public Transform cameraTransform; [TabGroup("Misc")] public float CrouchedCapsuleHeight = 1f; public CharacterState CurrentCharacterState { get; private set; } public Vector3 CurrentVelocity => Motor.Velocity; public Quaternion CurrentRotation => Motor.TransientRotation; private Collider[] _probedColliders = new Collider[8]; private RaycastHit[] _probedHits = new RaycastHit[8]; private Vector3 _moveInputVector; private Vector3 _lookInputVector; private bool _jumpRequested = false; private bool _jumpConsumed = false; private bool _jumpedThisFrame = false; private float _timeSinceJumpRequested = Mathf.Infinity; private float _timeSinceLastAbleToJump = 0f; private Vector3 _internalVelocityAdd = Vector3.zero; private bool _shouldBeCrouching = false; private bool _isCrouching = false; public ReactiveProperty IsSliding { get; } = new(); private bool _isSliding = false; private float _slideCurveProgress; private bool _isSlideEnded = false; private bool _isSprinting = false; private Vector3 lastInnerNormal = Vector3.zero; private Vector3 lastOuterNormal = Vector3.zero; private void Awake() { // Handle initial state TransitionToState(CharacterState.Default); // Assign the characterController to the motor Motor.CharacterController = this; } /// /// Handles movement state transitions and enter/exit callbacks /// public void TransitionToState(CharacterState newState) { CharacterState tmpInitialState = CurrentCharacterState; OnStateExit(tmpInitialState, newState); CurrentCharacterState = newState; OnStateEnter(newState, tmpInitialState); } /// /// Event when entering a state /// public void OnStateEnter(CharacterState state, CharacterState fromState) { switch (state) { case CharacterState.Default: { break; } } } /// /// Event when exiting a state /// public void OnStateExit(CharacterState state, CharacterState toState) { switch (state) { case CharacterState.Default: { break; } } } /// /// This is called every frame by ExamplePlayer in order to tell the character what its inputs are /// public void SetInputs(ref PlayerCharacterInputs inputs) { // Clamp input Vector3 moveInputVector = Vector3.ClampMagnitude(new Vector3(inputs.MoveAxisRight, 0f, inputs.MoveAxisForward), 1f); // Calculate camera direction and rotation on the character plane Vector3 cameraPlanarDirection = Vector3.ProjectOnPlane(inputs.CameraRotation * Vector3.forward, Motor.CharacterUp).normalized; if (cameraPlanarDirection.sqrMagnitude == 0f) { cameraPlanarDirection = Vector3.ProjectOnPlane(inputs.CameraRotation * Vector3.up, Motor.CharacterUp) .normalized; } Quaternion cameraPlanarRotation = Quaternion.LookRotation(cameraPlanarDirection, Motor.CharacterUp); switch (CurrentCharacterState) { case CharacterState.Default: { if (_isSliding) { moveInputVector.x *= SlideStupidity; } // Move and look inputs _moveInputVector = cameraPlanarRotation * moveInputVector; switch (OrientationMethod) { case OrientationMethod.TowardsCamera: _lookInputVector = cameraPlanarDirection; break; case OrientationMethod.TowardsMovement: _lookInputVector = _moveInputVector.normalized; break; } // Jumping input if (inputs.JumpDown) { _timeSinceJumpRequested = 0f; _jumpRequested = true; } _isSprinting = !inputs.WalkOn; // Crouching input if (inputs.CrouchDown) { _shouldBeCrouching = true; if (!_isCrouching) { if(_isSprinting && !_isSlideEnded) { _isSliding = true; IsSliding.Value = true; } else _isSlideEnded = true; _isCrouching = true; Motor.SetCapsuleDimensions(0.5f, CrouchedCapsuleHeight, CrouchedCapsuleHeight * 0.5f); if(_crouchTransition.IsPlaying()) _crouchTransition.Cancel(); _crouchTransition = LMotion.Create(cameraTransform.localPosition.y, 0.85f, InCrouchTime) .WithEase(Ease.InOutQuad) .BindToLocalPositionY(cameraTransform); } } else if (inputs.CrouchUp) { _shouldBeCrouching = false; } break; } } } /// /// This is called every frame by the AI script in order to tell the character what its inputs are /// public void SetInputs(ref AICharacterInputs inputs) { // _moveInputVector = inputs.MoveVector; // _lookInputVector = inputs.Look; var cameraPlanarDirection = Vector3.ProjectOnPlane(inputs.Look * Vector3.forward, Motor.CharacterUp).normalized; if (cameraPlanarDirection.sqrMagnitude == 0f) { cameraPlanarDirection = Vector3.ProjectOnPlane(inputs.Look * Vector3.up, Motor.CharacterUp) .normalized; } //var cameraPlanarRotation = Quaternion.LookRotation(cameraPlanarDirection, Motor.CharacterUp); _moveInputVector = inputs.MoveVector; // cameraPlanarRotation * _lookInputVector = cameraPlanarDirection; } private Quaternion _tmpTransientRot; private MotionHandle _crouchTransition; /// /// (Called by KinematicCharacterMotor during its update cycle) /// This is called before the character begins its movement update /// public void BeforeCharacterUpdate(float deltaTime) { } /// /// (Called by KinematicCharacterMotor during its update cycle) /// This is where you tell your character what its rotation should be right now. /// This is the ONLY place where you should set the character's rotation /// public void UpdateRotation(ref Quaternion currentRotation, float deltaTime) { switch (CurrentCharacterState) { case CharacterState.Default: { if (_lookInputVector.sqrMagnitude > 0f && OrientationSharpness > 0f) { // Smoothly interpolate from current to target look direction Vector3 smoothedLookInputDirection = Vector3.Slerp(Motor.CharacterForward, _lookInputVector, 1 - Mathf.Exp(-OrientationSharpness * deltaTime)).normalized; // Set the current rotation (which will be used by the KinematicCharacterMotor) currentRotation = Quaternion.LookRotation(smoothedLookInputDirection, Motor.CharacterUp); } Vector3 currentUp = (currentRotation * Vector3.up); if (BonusOrientationMethod == BonusOrientationMethod.TowardsGravity) { // Rotate from current up to invert gravity Vector3 smoothedGravityDir = Vector3.Slerp(currentUp, -Gravity.normalized, 1 - Mathf.Exp(-BonusOrientationSharpness * deltaTime)); currentRotation = Quaternion.FromToRotation(currentUp, smoothedGravityDir) * currentRotation; } else if (BonusOrientationMethod == BonusOrientationMethod.TowardsGroundSlopeAndGravity) { if (Motor.GroundingStatus.IsStableOnGround) { Vector3 initialCharacterBottomHemiCenter = Motor.TransientPosition + (currentUp * Motor.Capsule.radius); Vector3 smoothedGroundNormal = Vector3.Slerp(Motor.CharacterUp, Motor.GroundingStatus.GroundNormal, 1 - Mathf.Exp(-BonusOrientationSharpness * deltaTime)); currentRotation = Quaternion.FromToRotation(currentUp, smoothedGroundNormal) * currentRotation; // Move the position to create a rotation around the bottom hemi center instead of around the pivot Motor.SetTransientPosition(initialCharacterBottomHemiCenter + (currentRotation * Vector3.down * Motor.Capsule.radius)); } else { BonusOrientationGravitySharpness = 0.01f; Vector3 smoothedGravityDir = Vector3.Slerp(currentUp, Motor.Velocity.normalized, 1 - Mathf.Exp(-BonusOrientationSharpness * deltaTime * BonusOrientationGravitySharpness)); // Vector3 smoothedGravityDir = Vector3.Slerp(currentUp, -Gravity.normalized, // 1 - Mathf.Exp(-BonusOrientationSharpness * deltaTime * BonusOrientationGravitySharpness)); currentRotation = Quaternion.FromToRotation(currentUp, smoothedGravityDir) * currentRotation; } } else { Vector3 smoothedGravityDir = Vector3.Slerp(currentUp, Vector3.up, 1 - Mathf.Exp(-BonusOrientationSharpness * deltaTime)); currentRotation = Quaternion.FromToRotation(currentUp, smoothedGravityDir) * currentRotation; } break; } } } /// /// (Called by KinematicCharacterMotor during its update cycle) /// This is where you tell your character what its velocity should be right now. /// This is the ONLY place where you can set the character's velocity /// public void UpdateVelocity(ref Vector3 currentVelocity, float deltaTime) { switch (CurrentCharacterState) { case CharacterState.Default: { // Ground movement if (Motor.GroundingStatus.IsStableOnGround) { float currentVelocityMagnitude = currentVelocity.magnitude; Vector3 effectiveGroundNormal = Motor.GroundingStatus.GroundNormal; // Reorient velocity on slope currentVelocity = Motor.GetDirectionTangentToSurface(currentVelocity, effectiveGroundNormal) * currentVelocityMagnitude; // Calculate target velocity Vector3 inputRight = Vector3.Cross(_moveInputVector, Motor.CharacterUp); Vector3 reorientedInput = Vector3.Cross(effectiveGroundNormal, inputRight).normalized * _moveInputVector.magnitude; Vector3 targetMovementVelocity; switch (_isCrouching) { case true when _isSliding && !_isSlideEnded: { _slideCurveProgress += deltaTime; var slideMultiplier = SlideSpeedMultiplierCurve .Evaluate(_slideCurveProgress * SlideSpeedCurveStep); targetMovementVelocity = reorientedInput * (MaxStableMoveSpeed * slideMultiplier); if (_slideCurveProgress >= 1f) _isSlideEnded = true; break; } case true: targetMovementVelocity = reorientedInput * MaxCrouchMoveSpeed; break; default: targetMovementVelocity = reorientedInput * MaxStableMoveSpeed; break; } if (_isSprinting) targetMovementVelocity *= SprintingModifier; // Smooth movement Velocity currentVelocity = Vector3.Lerp(currentVelocity, targetMovementVelocity, 1f - Mathf.Exp(-StableMovementSharpness * deltaTime)); } // Air movement else { // Add move input if (_moveInputVector.sqrMagnitude > 0f) { Vector3 addedVelocity = _moveInputVector * (AirAccelerationSpeed * deltaTime); Vector3 currentVelocityOnInputsPlane = Vector3.ProjectOnPlane(currentVelocity, Motor.CharacterUp); // Limit air velocity from inputs if (currentVelocityOnInputsPlane.magnitude < MaxAirMoveSpeed) { // clamp addedVel to make total vel not exceed max vel on inputs plane Vector3 newTotal = Vector3.ClampMagnitude(currentVelocityOnInputsPlane + addedVelocity, MaxAirMoveSpeed); addedVelocity = newTotal - currentVelocityOnInputsPlane; } else { // Make sure added vel doesn't go in the direction of the already-exceeding velocity if (Vector3.Dot(currentVelocityOnInputsPlane, addedVelocity) > 0f) { addedVelocity = Vector3.ProjectOnPlane(addedVelocity, currentVelocityOnInputsPlane.normalized); } } // Prevent air-climbing sloped walls if (Motor.GroundingStatus.FoundAnyGround) { if (Vector3.Dot(currentVelocity + addedVelocity, addedVelocity) > 0f) { Vector3 perpenticularObstructionNormal = Vector3 .Cross(Vector3.Cross(Motor.CharacterUp, Motor.GroundingStatus.GroundNormal), Motor.CharacterUp).normalized; addedVelocity = Vector3.ProjectOnPlane(addedVelocity, perpenticularObstructionNormal); } } // Apply added velocity currentVelocity += addedVelocity; } // Gravity currentVelocity += Gravity * deltaTime; // Drag currentVelocity *= (1f / (1f + (Drag * deltaTime))); } // Handle jumping _jumpedThisFrame = false; _timeSinceJumpRequested += deltaTime; if (_jumpRequested) { // See if we actually are allowed to jump if (!_jumpConsumed && ((AllowJumpingWhenSliding ? Motor.GroundingStatus.FoundAnyGround : Motor.GroundingStatus.IsStableOnGround) || _timeSinceLastAbleToJump <= JumpPostGroundingGraceTime)) { // Calculate jump direction before ungrounding Vector3 jumpDirection = Motor.CharacterUp; if (Motor.GroundingStatus.FoundAnyGround && !Motor.GroundingStatus.IsStableOnGround) { jumpDirection = Motor.GroundingStatus.GroundNormal; } // Makes the character skip ground probing/snapping on its next update. // If this line weren't here, the character would remain snapped to the ground when trying to jump. Try commenting this line out and see. Motor.ForceUnground(); // Add to the return velocity and reset jump state currentVelocity += (jumpDirection * JumpUpSpeed) - Vector3.Project(currentVelocity, Motor.CharacterUp); currentVelocity += (_moveInputVector * JumpScalableForwardSpeed); _jumpRequested = false; _jumpConsumed = true; _jumpedThisFrame = true; } } // Take into account additive velocity if (_internalVelocityAdd.sqrMagnitude > 0f) { currentVelocity += _internalVelocityAdd; _internalVelocityAdd = Vector3.zero; } break; } } } /// /// (Called by KinematicCharacterMotor during its update cycle) /// This is called after the character has finished its movement update /// public void AfterCharacterUpdate(float deltaTime) { switch (CurrentCharacterState) { case CharacterState.Default: { // Handle jump-related values { // Handle jumping pre-ground grace period if (_jumpRequested && _timeSinceJumpRequested > JumpPreGroundingGraceTime) { _jumpRequested = false; } if (AllowJumpingWhenSliding ? Motor.GroundingStatus.FoundAnyGround : Motor.GroundingStatus.IsStableOnGround) { // If we're on a ground surface, reset jumping values if (!_jumpedThisFrame) { _jumpConsumed = false; } _timeSinceLastAbleToJump = 0f; } else { // Keep track of time since we were last able to jump (for grace period) _timeSinceLastAbleToJump += deltaTime; } } if (_isSliding && _isSlideEnded) { _isSliding = false; IsSliding.Value = false; } // Handle uncrouching if (_isCrouching && !_shouldBeCrouching) { // Do an overlap test with the character's standing height to see if there are any obstructions Motor.SetCapsuleDimensions(0.5f, 2f, 1f); if (Motor.CharacterOverlap( Motor.TransientPosition, Motor.TransientRotation, _probedColliders, Motor.CollidableLayers, QueryTriggerInteraction.Ignore) > 0) { // If obstructions, just stick to crouching dimensions Motor.SetCapsuleDimensions(0.5f, CrouchedCapsuleHeight, CrouchedCapsuleHeight * 0.5f); } else { // If no obstructions, uncrouch if(_crouchTransition.IsPlaying()) _crouchTransition.Cancel(); _crouchTransition = LMotion.Create(cameraTransform.localPosition.y, 1.71f, OutCrouchTime) .WithEase(Ease.InOutQuad) .BindToLocalPositionY(cameraTransform); _isCrouching = false; _isSliding = false; IsSliding.Value = false; _isSlideEnded = false; _slideCurveProgress = 0f; } } break; } } } public void PostGroundingUpdate(float deltaTime) { // Handle landing and leaving ground if (Motor.GroundingStatus.IsStableOnGround && !Motor.LastGroundingStatus.IsStableOnGround) { OnLanded(); } else if (!Motor.GroundingStatus.IsStableOnGround && Motor.LastGroundingStatus.IsStableOnGround) { OnLeaveStableGround(); } } public bool IsColliderValidForCollisions(Collider coll) { if (IgnoredColliders.Count == 0) { return true; } if (IgnoredColliders.Contains(coll)) { return false; } return true; } public void OnGroundHit(Collider hitCollider, Vector3 hitNormal, Vector3 hitPoint, ref HitStabilityReport hitStabilityReport) { } public void OnMovementHit(Collider hitCollider, Vector3 hitNormal, Vector3 hitPoint, ref HitStabilityReport hitStabilityReport) { } public void AddVelocity(Vector3 velocity) { switch (CurrentCharacterState) { case CharacterState.Default: { _internalVelocityAdd += velocity; break; } } } public void ProcessHitStabilityReport(Collider hitCollider, Vector3 hitNormal, Vector3 hitPoint, Vector3 atCharacterPosition, Quaternion atCharacterRotation, ref HitStabilityReport hitStabilityReport) { } protected void OnLanded() { } protected void OnLeaveStableGround() { } public void OnDiscreteCollisionDetected(Collider hitCollider) { } } }