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