diff --git a/Assets/Scripts/Control/FeetController.cs b/Assets/Scripts/Control/FeetController.cs new file mode 100644 index 0000000..b9536ec --- /dev/null +++ b/Assets/Scripts/Control/FeetController.cs @@ -0,0 +1,227 @@ +using System.Collections; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +public sealed class FeetController : MonoBehaviour +{ + [SerializeField] private Rigidbody playerRb; + [SerializeField] private float acceptableRestingDistance = 0.3f; + [SerializeField] private float footLiftDelay = 0.1f; + [SerializeField] private float footBackToGroundDelay = 0.5f; + [SerializeField] private float feetDistanceToCenter = 0.2f; + [SerializeField] private float stepDistanceMultiplier = 1f; + [SerializeField] private float maxStepHeight = 1f; + [SerializeField] private float stepMinDistanceToWalls = 0.1f; + [SerializeField] private float distanceUnderFootFloorDetection = 0.1f; + + private Vector3 leftFootPosition = Vector3.zero; + private Vector3 rightFootPosition = Vector3.zero; + + private bool isRightFootNextToLift = true; // Updated when lifting a foot + private bool isAtRest = true; + private bool isLiftingFoot = false; + +#if UNITY_EDITOR + public const float feetRadius = 0.15f; + [SerializeField] private GameObject leftfoot_debug; + [SerializeField] private GameObject rightfoot_debug; + + private void OnDrawGizmosSelected() + { + Handles.color = Color.magenta; + Handles.DrawSolidDisc(leftFootPosition, Vector3.up, feetRadius); + Handles.DrawSolidDisc(rightFootPosition, Vector3.up, feetRadius); + } +#endif + + private void Awake() + { + ResetFeetPositions(); + } + + /// + /// Reset the feet position without playing sounds. + /// Should be used for init or reset purposes only, as otherwise footsteps might sound odd. + /// + private void ResetFeetPositions() + { + (leftFootPosition, rightFootPosition) = GetFeetRestPosition(); + } + + /// + /// Get the expected feet location if we were at rest + /// + /// Tuple or expected feet location: (expectedLeft, expectedRight) + private (Vector3, Vector3) GetFeetRestPosition() + { + Vector3 feetCenter = transform.position; + Vector3 towardsRight = transform.right; + Vector3 expectedLeft = feetCenter - feetDistanceToCenter*towardsRight; + Vector3 expectedRight = feetCenter + feetDistanceToCenter*towardsRight; + return (expectedLeft, expectedRight); + } + + /// + /// Know whether feet are in rest position or not + /// + /// Whether feet are in rest position or not + private bool AreFeetInRestPosition() + { + var (expectedLeft, expectedRight) = GetFeetRestPosition(); + return ( + Vector3.Distance(expectedLeft, leftFootPosition) < acceptableRestingDistance + || Vector3.Distance(expectedRight, rightFootPosition) < acceptableRestingDistance + ); + } + + /// + /// Lift the foot. Says it all. + /// + private void LiftFoot() + { + if (isLiftingFoot) return; // Can't lift two feet at the same time + isLiftingFoot = true; + + Vector3 liftSoundPosition = isRightFootNextToLift ? rightFootPosition : leftFootPosition; + isRightFootNextToLift = ! isRightFootNextToLift; + + // Trigger sound at position + GameObject floor = getFloorUnderPosition(liftSoundPosition); + if (floor == null) return; // No floor under foot + + TriggerFootLift footLift = floor.GetComponent(); + if (footLift == null) return; // No foot lifting behavior + + footLift.OnFootLift(liftSoundPosition); + } + + /// + /// Predict where the next foot to place should be placed + /// Returns a Vector3: predicted position, or rest position when no suitable position found + /// Returns a Collider: collider the foot is placed on, or null if no suitable position found + /// + /// Whether the foot can be placed forward ; where it should be placed ; the collider under the foot + private (Vector3, Collider) PredictFootPosition() + { + // Get position at rest + var (restLeft, restRight) = GetFeetRestPosition(); + // We want the foot opposite to the next to lift (we want the next to place on ground) + Vector3 restFoot = isRightFootNextToLift ? restLeft : restRight; + + // Project position forward, in moving direction + Vector3 stepMovement = playerRb.linearVelocity * stepDistanceMultiplier; + + Vector3 projectedPosition; + // Test for walls: feet can't go through walls + if (Physics.Raycast(restFoot, stepMovement, out RaycastHit hit, stepMovement.magnitude)) // TODO wall mask + { + // There is a wall => foot cannot go through. Must place it in front of the wall. + projectedPosition = restFoot + stepMovement.normalized * (hit.distance - stepMinDistanceToWalls); + } + else + { + // No wall => can take a step forward + projectedPosition = restFoot + stepMovement; + } + + // Raycast downwards to take slopes into account + Vector3 rayStart = projectedPosition + Vector3.up * maxStepHeight; + if (Physics.Raycast(rayStart, Vector3.down, out hit, maxStepHeight*2)) // TODO floor mask + { + // Found ground to place foot on at raycast hit point + return (hit.point, hit.collider); + } + else + { + // No ground to place foot on => use rest position as a backup + return (restFoot, null); + } + } + + /// + /// Place a foot on ground. Says it all. + /// + private void PlaceFootOnGround() + { + if (!isLiftingFoot) return; // Can't place foot on ground if none are lifted + isLiftingFoot = false; + + var (predictedFootPosition, underFootCollider) = PredictFootPosition(); + if (isRightFootNextToLift) + { + leftFootPosition = predictedFootPosition; + } + else + { + rightFootPosition = predictedFootPosition; + } + + // Activate colliders at new foot location + if (underFootCollider != null) + { + // Walking on a collider + TriggerFootstep trigger = underFootCollider.GetComponent(); + if (trigger != null) + { + trigger.OnFootstep(predictedFootPosition); + } + // Otherwise, collider is not supposed to be walked on (edge case) + } + // Otherwise, no collider found under foot, we should probably do nothing (edge case) + } + + /// + /// Get the floor gameobject that is under the given position + /// + /// The position to check + /// The floor object that is under the given position, or null if foot is not on a floor + private GameObject getFloorUnderPosition(Vector3 position) { + Vector3 rayStart = position + distanceUnderFootFloorDetection*Vector3.down; + if (Physics.Raycast(rayStart, Vector3.down, out RaycastHit hit, distanceUnderFootFloorDetection*2)) { // TODO floor mask + return hit.collider.gameObject; + } + return null; + } + + /// + /// Walk until at rest again + /// + /// + IEnumerator WalkCoroutine() + { + isAtRest = false; + bool isWalking = true; + while (isWalking) + { + LiftFoot(); + yield return new WaitForSeconds(footBackToGroundDelay); // Wait after lifting to foot + + PlaceFootOnGround(); + yield return new WaitForSeconds(footLiftDelay); // Wait before lifting the foot again + if (AreFeetInRestPosition()) + { + isWalking = false; + } + } + isAtRest = true; + } + + /// + /// Update performed every physics frame + /// + private void FixedUpdate() + { + // When at rest, start moving feet when leaving acceptable resting distance + if (isAtRest) + { + if (! AreFeetInRestPosition()) + { + StartCoroutine(WalkCoroutine()); + } + } + leftfoot_debug.transform.position = leftFootPosition; + rightfoot_debug.transform.position = rightFootPosition; + } +} diff --git a/Assets/Scripts/Control/FeetController.cs.meta b/Assets/Scripts/Control/FeetController.cs.meta new file mode 100644 index 0000000..164888e --- /dev/null +++ b/Assets/Scripts/Control/FeetController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c1ad1d866e9c78439fa3d7b156ff06a \ No newline at end of file