Search Unity

  1. Megacity Metro Demo now available. Download now.
    Dismiss Notice
  2. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Figuring out "FPS microgame"-style jump velocity

Discussion in 'Getting Started' started by Gifty, Apr 14, 2021.

  1. Gifty

    Gifty

    Joined:
    Apr 21, 2016
    Posts:
    2
    Hi! I've been spending a lot of time attempting to reverse-engineer some aspects of the FPS microgame character controller, in service of helping build a (similarly non physics-based) FPS controller of my own. Namely, I've been climbing up the walls lately trying to figure out how their jump velocity system works, wherein the player retains their move direction+velocity upon entering a jump, but can also redirect midair through a separate "air acceleration" (essentially air control) variable. The script is so simple and barebones that I'm having trouble figuring out exactly which lines of code are achieving this effect, as whenever I try to recreate it with my own character it doesn't work.

    I'm assuming they're storing a snapshot of the player's pre-jump velocity somewhere, and then allowing air control to simply modify that velocity, but caching a pre-jump velocity and then adding any new velocity produces wacky and undesirable results in my case ("snapping" back to original jump direction once move keys are released, redirecting based on mouse direction and not body velocity, etc). Thanks for any help!
     
  2. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    9,859
    I wouldn't expect it to be anything so complicated. Usually you just have a velocity, and while in the air we allow control inputs to add a bit to that velocity.

    See this article for an example and several implementations of the same basic idea (though in 2D rather than 3D).
     
    Schneider21 and Ryiah like this.
  3. Ryiah

    Ryiah

    Joined:
    Oct 11, 2012
    Posts:
    20,965
    This. FPS Microgame stores velocity in a Vector3 named CharacterVelocity. When you jump it zeroes out vertical movement (Y-axis) and then adds the strength of the player's jump to that axis.

    Concerning horizontal movement while you're on the ground it directly adds new movement to CharacterVelocity, but once you're in the air it multiplies it by an air speed modifier variable before adding it to CharacterVelocity to make air movement feel more sluggish than ground movement.

    In the spoiler below is the full code of the character controller for references. Line 267 is the start of the movement function. Line 318 is the start of jump logic. Lines 305 to 314 control grounded movement. Lines 357 to 364 control air movement. Line 367 applies gravity. Line 374 applies CharacterVelocity to the character.

    Code (csharp):
    1. using Unity.FPS.Game;
    2. using UnityEngine;
    3. using UnityEngine.Events;
    4.  
    5. namespace Unity.FPS.Gameplay
    6. {
    7.     [RequireComponent(typeof(CharacterController), typeof(PlayerInputHandler), typeof(AudioSource))]
    8.     public class PlayerCharacterController : MonoBehaviour
    9.     {
    10.         [Header("References")] [Tooltip("Reference to the main camera used for the player")]
    11.         public Camera PlayerCamera;
    12.  
    13.         [Tooltip("Audio source for footsteps, jump, etc...")]
    14.         public AudioSource AudioSource;
    15.  
    16.         [Header("General")] [Tooltip("Force applied downward when in the air")]
    17.         public float GravityDownForce = 20f;
    18.  
    19.         [Tooltip("Physic layers checked to consider the player grounded")]
    20.         public LayerMask GroundCheckLayers = -1;
    21.  
    22.         [Tooltip("distance from the bottom of the character controller capsule to test for grounded")]
    23.         public float GroundCheckDistance = 0.05f;
    24.  
    25.         [Header("Movement")] [Tooltip("Max movement speed when grounded (when not sprinting)")]
    26.         public float MaxSpeedOnGround = 10f;
    27.  
    28.         [Tooltip(
    29.             "Sharpness for the movement when grounded, a low value will make the player accelerate and decelerate slowly, a high value will do the opposite")]
    30.         public float MovementSharpnessOnGround = 15;
    31.  
    32.         [Tooltip("Max movement speed when crouching")] [Range(0, 1)]
    33.         public float MaxSpeedCrouchedRatio = 0.5f;
    34.  
    35.         [Tooltip("Max movement speed when not grounded")]
    36.         public float MaxSpeedInAir = 10f;
    37.  
    38.         [Tooltip("Acceleration speed when in the air")]
    39.         public float AccelerationSpeedInAir = 25f;
    40.  
    41.         [Tooltip("Multiplicator for the sprint speed (based on grounded speed)")]
    42.         public float SprintSpeedModifier = 2f;
    43.  
    44.         [Tooltip("Height at which the player dies instantly when falling off the map")]
    45.         public float KillHeight = -50f;
    46.  
    47.         [Header("Rotation")] [Tooltip("Rotation speed for moving the camera")]
    48.         public float RotationSpeed = 200f;
    49.  
    50.         [Range(0.1f, 1f)] [Tooltip("Rotation speed multiplier when aiming")]
    51.         public float AimingRotationMultiplier = 0.4f;
    52.  
    53.         [Header("Jump")] [Tooltip("Force applied upward when jumping")]
    54.         public float JumpForce = 9f;
    55.  
    56.         [Header("Stance")] [Tooltip("Ratio (0-1) of the character height where the camera will be at")]
    57.         public float CameraHeightRatio = 0.9f;
    58.  
    59.         [Tooltip("Height of character when standing")]
    60.         public float CapsuleHeightStanding = 1.8f;
    61.  
    62.         [Tooltip("Height of character when crouching")]
    63.         public float CapsuleHeightCrouching = 0.9f;
    64.  
    65.         [Tooltip("Speed of crouching transitions")]
    66.         public float CrouchingSharpness = 10f;
    67.  
    68.         [Header("Audio")] [Tooltip("Amount of footstep sounds played when moving one meter")]
    69.         public float FootstepSfxFrequency = 1f;
    70.  
    71.         [Tooltip("Amount of footstep sounds played when moving one meter while sprinting")]
    72.         public float FootstepSfxFrequencyWhileSprinting = 1f;
    73.  
    74.         [Tooltip("Sound played for footsteps")]
    75.         public AudioClip FootstepSfx;
    76.  
    77.         [Tooltip("Sound played when jumping")] public AudioClip JumpSfx;
    78.         [Tooltip("Sound played when landing")] public AudioClip LandSfx;
    79.  
    80.         [Tooltip("Sound played when taking damage froma fall")]
    81.         public AudioClip FallDamageSfx;
    82.  
    83.         [Header("Fall Damage")]
    84.         [Tooltip("Whether the player will recieve damage when hitting the ground at high speed")]
    85.         public bool RecievesFallDamage;
    86.  
    87.         [Tooltip("Minimun fall speed for recieving fall damage")]
    88.         public float MinSpeedForFallDamage = 10f;
    89.  
    90.         [Tooltip("Fall speed for recieving th emaximum amount of fall damage")]
    91.         public float MaxSpeedForFallDamage = 30f;
    92.  
    93.         [Tooltip("Damage recieved when falling at the mimimum speed")]
    94.         public float FallDamageAtMinSpeed = 10f;
    95.  
    96.         [Tooltip("Damage recieved when falling at the maximum speed")]
    97.         public float FallDamageAtMaxSpeed = 50f;
    98.  
    99.         public UnityAction<bool> OnStanceChanged;
    100.  
    101.         public Vector3 CharacterVelocity { get; set; }
    102.         public bool IsGrounded { get; private set; }
    103.         public bool HasJumpedThisFrame { get; private set; }
    104.         public bool IsDead { get; private set; }
    105.         public bool IsCrouching { get; private set; }
    106.  
    107.         public float RotationMultiplier
    108.         {
    109.             get
    110.             {
    111.                 if (m_WeaponsManager.IsAiming)
    112.                 {
    113.                     return AimingRotationMultiplier;
    114.                 }
    115.  
    116.                 return 1f;
    117.             }
    118.         }
    119.  
    120.         Health m_Health;
    121.         PlayerInputHandler m_InputHandler;
    122.         CharacterController m_Controller;
    123.         PlayerWeaponsManager m_WeaponsManager;
    124.         Actor m_Actor;
    125.         Vector3 m_GroundNormal;
    126.         Vector3 m_CharacterVelocity;
    127.         Vector3 m_LatestImpactSpeed;
    128.         float m_LastTimeJumped = 0f;
    129.         float m_CameraVerticalAngle = 0f;
    130.         float m_FootstepDistanceCounter;
    131.         float m_TargetCharacterHeight;
    132.  
    133.         const float k_JumpGroundingPreventionTime = 0.2f;
    134.         const float k_GroundCheckDistanceInAir = 0.07f;
    135.  
    136.         void Awake()
    137.         {
    138.             ActorsManager actorsManager = FindObjectOfType<ActorsManager>();
    139.             if (actorsManager != null)
    140.                 actorsManager.SetPlayer(gameObject);
    141.         }
    142.  
    143.         void Start()
    144.         {
    145.             // fetch components on the same gameObject
    146.             m_Controller = GetComponent<CharacterController>();
    147.             DebugUtility.HandleErrorIfNullGetComponent<CharacterController, PlayerCharacterController>(m_Controller,
    148.                 this, gameObject);
    149.  
    150.             m_InputHandler = GetComponent<PlayerInputHandler>();
    151.             DebugUtility.HandleErrorIfNullGetComponent<PlayerInputHandler, PlayerCharacterController>(m_InputHandler,
    152.                 this, gameObject);
    153.  
    154.             m_WeaponsManager = GetComponent<PlayerWeaponsManager>();
    155.             DebugUtility.HandleErrorIfNullGetComponent<PlayerWeaponsManager, PlayerCharacterController>(
    156.                 m_WeaponsManager, this, gameObject);
    157.  
    158.             m_Health = GetComponent<Health>();
    159.             DebugUtility.HandleErrorIfNullGetComponent<Health, PlayerCharacterController>(m_Health, this, gameObject);
    160.  
    161.             m_Actor = GetComponent<Actor>();
    162.             DebugUtility.HandleErrorIfNullGetComponent<Actor, PlayerCharacterController>(m_Actor, this, gameObject);
    163.  
    164.             m_Controller.enableOverlapRecovery = true;
    165.  
    166.             m_Health.OnDie += OnDie;
    167.  
    168.             // force the crouch state to false when starting
    169.             SetCrouchingState(false, true);
    170.             UpdateCharacterHeight(true);
    171.         }
    172.  
    173.         void Update()
    174.         {
    175.             // check for Y kill
    176.             if (!IsDead && transform.position.y < KillHeight)
    177.             {
    178.                 m_Health.Kill();
    179.             }
    180.  
    181.             HasJumpedThisFrame = false;
    182.  
    183.             bool wasGrounded = IsGrounded;
    184.             GroundCheck();
    185.  
    186.             // landing
    187.             if (IsGrounded && !wasGrounded)
    188.             {
    189.                 // Fall damage
    190.                 float fallSpeed = -Mathf.Min(CharacterVelocity.y, m_LatestImpactSpeed.y);
    191.                 float fallSpeedRatio = (fallSpeed - MinSpeedForFallDamage) /
    192.                                        (MaxSpeedForFallDamage - MinSpeedForFallDamage);
    193.                 if (RecievesFallDamage && fallSpeedRatio > 0f)
    194.                 {
    195.                     float dmgFromFall = Mathf.Lerp(FallDamageAtMinSpeed, FallDamageAtMaxSpeed, fallSpeedRatio);
    196.                     m_Health.TakeDamage(dmgFromFall, null);
    197.  
    198.                     // fall damage SFX
    199.                     AudioSource.PlayOneShot(FallDamageSfx);
    200.                 }
    201.                 else
    202.                 {
    203.                     // land SFX
    204.                     AudioSource.PlayOneShot(LandSfx);
    205.                 }
    206.             }
    207.  
    208.             // crouching
    209.             if (m_InputHandler.GetCrouchInputDown())
    210.             {
    211.                 SetCrouchingState(!IsCrouching, false);
    212.             }
    213.  
    214.             UpdateCharacterHeight(false);
    215.  
    216.             HandleCharacterMovement();
    217.         }
    218.  
    219.         void OnDie()
    220.         {
    221.             IsDead = true;
    222.  
    223.             // Tell the weapons manager to switch to a non-existing weapon in order to lower the weapon
    224.             m_WeaponsManager.SwitchToWeaponIndex(-1, true);
    225.  
    226.             EventManager.Broadcast(Events.PlayerDeathEvent);
    227.         }
    228.  
    229.         void GroundCheck()
    230.         {
    231.             // Make sure that the ground check distance while already in air is very small, to prevent suddenly snapping to ground
    232.             float chosenGroundCheckDistance =
    233.                 IsGrounded ? (m_Controller.skinWidth + GroundCheckDistance) : k_GroundCheckDistanceInAir;
    234.  
    235.             // reset values before the ground check
    236.             IsGrounded = false;
    237.             m_GroundNormal = Vector3.up;
    238.  
    239.             // only try to detect ground if it's been a short amount of time since last jump; otherwise we may snap to the ground instantly after we try jumping
    240.             if (Time.time >= m_LastTimeJumped + k_JumpGroundingPreventionTime)
    241.             {
    242.                 // if we're grounded, collect info about the ground normal with a downward capsule cast representing our character capsule
    243.                 if (Physics.CapsuleCast(GetCapsuleBottomHemisphere(), GetCapsuleTopHemisphere(m_Controller.height),
    244.                     m_Controller.radius, Vector3.down, out RaycastHit hit, chosenGroundCheckDistance, GroundCheckLayers,
    245.                     QueryTriggerInteraction.Ignore))
    246.                 {
    247.                     // storing the upward direction for the surface found
    248.                     m_GroundNormal = hit.normal;
    249.  
    250.                     // Only consider this a valid ground hit if the ground normal goes in the same direction as the character up
    251.                     // and if the slope angle is lower than the character controller's limit
    252.                     if (Vector3.Dot(hit.normal, transform.up) > 0f &&
    253.                         IsNormalUnderSlopeLimit(m_GroundNormal))
    254.                     {
    255.                         IsGrounded = true;
    256.  
    257.                         // handle snapping to the ground
    258.                         if (hit.distance > m_Controller.skinWidth)
    259.                         {
    260.                             m_Controller.Move(Vector3.down * hit.distance);
    261.                         }
    262.                     }
    263.                 }
    264.             }
    265.         }
    266.  
    267.         void HandleCharacterMovement()
    268.         {
    269.             // horizontal character rotation
    270.             {
    271.                 // rotate the transform with the input speed around its local Y axis
    272.                 transform.Rotate(
    273.                     new Vector3(0f, (m_InputHandler.GetLookInputsHorizontal() * RotationSpeed * RotationMultiplier),
    274.                         0f), Space.Self);
    275.             }
    276.  
    277.             // vertical camera rotation
    278.             {
    279.                 // add vertical inputs to the camera's vertical angle
    280.                 m_CameraVerticalAngle += m_InputHandler.GetLookInputsVertical() * RotationSpeed * RotationMultiplier;
    281.  
    282.                 // limit the camera's vertical angle to min/max
    283.                 m_CameraVerticalAngle = Mathf.Clamp(m_CameraVerticalAngle, -89f, 89f);
    284.  
    285.                 // apply the vertical angle as a local rotation to the camera transform along its right axis (makes it pivot up and down)
    286.                 PlayerCamera.transform.localEulerAngles = new Vector3(m_CameraVerticalAngle, 0, 0);
    287.             }
    288.  
    289.             // character movement handling
    290.             bool isSprinting = m_InputHandler.GetSprintInputHeld();
    291.             {
    292.                 if (isSprinting)
    293.                 {
    294.                     isSprinting = SetCrouchingState(false, false);
    295.                 }
    296.  
    297.                 float speedModifier = isSprinting ? SprintSpeedModifier : 1f;
    298.  
    299.                 // converts move input to a worldspace vector based on our character's transform orientation
    300.                 Vector3 worldspaceMoveInput = transform.TransformVector(m_InputHandler.GetMoveInput());
    301.  
    302.                 // handle grounded movement
    303.                 if (IsGrounded)
    304.                 {
    305.                     // calculate the desired velocity from inputs, max speed, and current slope
    306.                     Vector3 targetVelocity = worldspaceMoveInput * MaxSpeedOnGround * speedModifier;
    307.                     // reduce speed if crouching by crouch speed ratio
    308.                     if (IsCrouching)
    309.                         targetVelocity *= MaxSpeedCrouchedRatio;
    310.                     targetVelocity = GetDirectionReorientedOnSlope(targetVelocity.normalized, m_GroundNormal) *
    311.                                      targetVelocity.magnitude;
    312.  
    313.                     // smoothly interpolate between our current velocity and the target velocity based on acceleration speed
    314.                     CharacterVelocity = Vector3.Lerp(CharacterVelocity, targetVelocity,
    315.                         MovementSharpnessOnGround * Time.deltaTime);
    316.  
    317.                     // jumping
    318.                     if (IsGrounded && m_InputHandler.GetJumpInputDown())
    319.                     {
    320.                         // force the crouch state to false
    321.                         if (SetCrouchingState(false, false))
    322.                         {
    323.                             // start by canceling out the vertical component of our velocity
    324.                             CharacterVelocity = new Vector3(CharacterVelocity.x, 0f, CharacterVelocity.z);
    325.  
    326.                             // then, add the jumpSpeed value upwards
    327.                             CharacterVelocity += Vector3.up * JumpForce;
    328.  
    329.                             // play sound
    330.                             AudioSource.PlayOneShot(JumpSfx);
    331.  
    332.                             // remember last time we jumped because we need to prevent snapping to ground for a short time
    333.                             m_LastTimeJumped = Time.time;
    334.                             HasJumpedThisFrame = true;
    335.  
    336.                             // Force grounding to false
    337.                             IsGrounded = false;
    338.                             m_GroundNormal = Vector3.up;
    339.                         }
    340.                     }
    341.  
    342.                     // footsteps sound
    343.                     float chosenFootstepSfxFrequency =
    344.                         (isSprinting ? FootstepSfxFrequencyWhileSprinting : FootstepSfxFrequency);
    345.                     if (m_FootstepDistanceCounter >= 1f / chosenFootstepSfxFrequency)
    346.                     {
    347.                         m_FootstepDistanceCounter = 0f;
    348.                         AudioSource.PlayOneShot(FootstepSfx);
    349.                     }
    350.  
    351.                     // keep track of distance traveled for footsteps sound
    352.                     m_FootstepDistanceCounter += CharacterVelocity.magnitude * Time.deltaTime;
    353.                 }
    354.                 // handle air movement
    355.                 else
    356.                 {
    357.                     // add air acceleration
    358.                     CharacterVelocity += worldspaceMoveInput * AccelerationSpeedInAir * Time.deltaTime;
    359.  
    360.                     // limit air speed to a maximum, but only horizontally
    361.                     float verticalVelocity = CharacterVelocity.y;
    362.                     Vector3 horizontalVelocity = Vector3.ProjectOnPlane(CharacterVelocity, Vector3.up);
    363.                     horizontalVelocity = Vector3.ClampMagnitude(horizontalVelocity, MaxSpeedInAir * speedModifier);
    364.                     CharacterVelocity = horizontalVelocity + (Vector3.up * verticalVelocity);
    365.  
    366.                     // apply the gravity to the velocity
    367.                     CharacterVelocity += Vector3.down * GravityDownForce * Time.deltaTime;
    368.                 }
    369.             }
    370.  
    371.             // apply the final calculated velocity value as a character movement
    372.             Vector3 capsuleBottomBeforeMove = GetCapsuleBottomHemisphere();
    373.             Vector3 capsuleTopBeforeMove = GetCapsuleTopHemisphere(m_Controller.height);
    374.             m_Controller.Move(CharacterVelocity * Time.deltaTime);
    375.  
    376.             // detect obstructions to adjust velocity accordingly
    377.             m_LatestImpactSpeed = Vector3.zero;
    378.             if (Physics.CapsuleCast(capsuleBottomBeforeMove, capsuleTopBeforeMove, m_Controller.radius,
    379.                 CharacterVelocity.normalized, out RaycastHit hit, CharacterVelocity.magnitude * Time.deltaTime, -1,
    380.                 QueryTriggerInteraction.Ignore))
    381.             {
    382.                 // We remember the last impact speed because the fall damage logic might need it
    383.                 m_LatestImpactSpeed = CharacterVelocity;
    384.  
    385.                 CharacterVelocity = Vector3.ProjectOnPlane(CharacterVelocity, hit.normal);
    386.             }
    387.         }
    388.  
    389.         // Returns true if the slope angle represented by the given normal is under the slope angle limit of the character controller
    390.         bool IsNormalUnderSlopeLimit(Vector3 normal)
    391.         {
    392.             return Vector3.Angle(transform.up, normal) <= m_Controller.slopeLimit;
    393.         }
    394.  
    395.         // Gets the center point of the bottom hemisphere of the character controller capsule
    396.         Vector3 GetCapsuleBottomHemisphere()
    397.         {
    398.             return transform.position + (transform.up * m_Controller.radius);
    399.         }
    400.  
    401.         // Gets the center point of the top hemisphere of the character controller capsule
    402.         Vector3 GetCapsuleTopHemisphere(float atHeight)
    403.         {
    404.             return transform.position + (transform.up * (atHeight - m_Controller.radius));
    405.         }
    406.  
    407.         // Gets a reoriented direction that is tangent to a given slope
    408.         public Vector3 GetDirectionReorientedOnSlope(Vector3 direction, Vector3 slopeNormal)
    409.         {
    410.             Vector3 directionRight = Vector3.Cross(direction, transform.up);
    411.             return Vector3.Cross(slopeNormal, directionRight).normalized;
    412.         }
    413.  
    414.         void UpdateCharacterHeight(bool force)
    415.         {
    416.             // Update height instantly
    417.             if (force)
    418.             {
    419.                 m_Controller.height = m_TargetCharacterHeight;
    420.                 m_Controller.center = Vector3.up * m_Controller.height * 0.5f;
    421.                 PlayerCamera.transform.localPosition = Vector3.up * m_TargetCharacterHeight * CameraHeightRatio;
    422.                 m_Actor.AimPoint.transform.localPosition = m_Controller.center;
    423.             }
    424.             // Update smooth height
    425.             else if (m_Controller.height != m_TargetCharacterHeight)
    426.             {
    427.                 // resize the capsule and adjust camera position
    428.                 m_Controller.height = Mathf.Lerp(m_Controller.height, m_TargetCharacterHeight,
    429.                     CrouchingSharpness * Time.deltaTime);
    430.                 m_Controller.center = Vector3.up * m_Controller.height * 0.5f;
    431.                 PlayerCamera.transform.localPosition = Vector3.Lerp(PlayerCamera.transform.localPosition,
    432.                     Vector3.up * m_TargetCharacterHeight * CameraHeightRatio, CrouchingSharpness * Time.deltaTime);
    433.                 m_Actor.AimPoint.transform.localPosition = m_Controller.center;
    434.             }
    435.         }
    436.  
    437.         // returns false if there was an obstruction
    438.         bool SetCrouchingState(bool crouched, bool ignoreObstructions)
    439.         {
    440.             // set appropriate heights
    441.             if (crouched)
    442.             {
    443.                 m_TargetCharacterHeight = CapsuleHeightCrouching;
    444.             }
    445.             else
    446.             {
    447.                 // Detect obstructions
    448.                 if (!ignoreObstructions)
    449.                 {
    450.                     Collider[] standingOverlaps = Physics.OverlapCapsule(
    451.                         GetCapsuleBottomHemisphere(),
    452.                         GetCapsuleTopHemisphere(CapsuleHeightStanding),
    453.                         m_Controller.radius,
    454.                         -1,
    455.                         QueryTriggerInteraction.Ignore);
    456.                     foreach (Collider c in standingOverlaps)
    457.                     {
    458.                         if (c != m_Controller)
    459.                         {
    460.                             return false;
    461.                         }
    462.                     }
    463.                 }
    464.  
    465.                 m_TargetCharacterHeight = CapsuleHeightStanding;
    466.             }
    467.  
    468.             if (OnStanceChanged != null)
    469.             {
    470.                 OnStanceChanged.Invoke(crouched);
    471.             }
    472.  
    473.             IsCrouching = crouched;
    474.             return true;
    475.         }
    476.     }
    477. }
     
    Last edited: Apr 14, 2021
    Schneider21 likes this.
  4. Gifty

    Gifty

    Joined:
    Apr 21, 2016
    Posts:
    2
    Hi, thanks for the replies! I studied the microgame script a little harder and have gotten it working on my end. However, I'm still not really sure how it's working, I guess lines 355-367 (of the above code) was the crucial part I needed help understanding, as that's the step that kept going wrong in my own version. Specifically, how they're getting their pre-jump velocity to stay constant through the jump, while also allowing mid-air input without the two values fighting. Is grabbing worldSpaceMoveInput directly what allows the two to coexist smoothly? Is ProjectOnPlane doing some sort of magic here, or is it simply a shorthand to only clamp the horizontal velocity? Thanks again!