You need to track if they’ve landed on what you consider the ground. If they haven’t, don’t allow jumping.
I break these things into their own components for my movement ‘styles’ (I have a movementmotor that is a state machine for the various movement styles that an entity can have). I call them ‘resolvers’.
I have resolvers like:
GravityResolver
GroundingResolver
SurfaceResolver (like grounding, but treats walls and ceilings as ground as well… for wall walking and climbing)
JumpResolver
example:
GroundingResolver
using UnityEngine;
using System.Collections.Generic;
using com.spacepuppy;
using com.spacepuppy.Movement;
using com.spacepuppy.Utils;
namespace com.apoc.Movement
{
[AddComponentMenu("Apoc/Movement/Resolver: Grounding")]
[RequireComponent(typeof(ApocMovementMotor))]
public class GroundingResolver : SPNotifyingComponent, IGroundingResolver
{
public enum GroundingState
{
Unknown = -2,
Hanging = -1,
Grounded = 0,
Jumping = 1,
Descending = 2,
Falling = 3
}
#region Fields
[Tooltip("Distance to project below the player to check ground. Should be greater than or equal to the skin width of the attached CharacterController.")]
public float GroundingSkinWidth = 0.05f;
public float TerminalFallDistance = 15.0f;
[Tooltip("Duration of time considered just jumped. This way if we're near the ground, we don't signal as grounded if we're initiating a jump.")]
public float JustJumpedCooldown = 0.1f;
[Tooltip("The ground normal is calculated using a CapsuleCast which improperly calculates the surface normal. Set this true to repair the surface normal, only if necessary, as it's a lot more extra work.")]
public bool RepairSurfaceNormal = false;
private ApocMovementMotor _motor;
private Vector2 _groundNormal;
private GroundingState _currentState;
private Vector2 _lastGroundedPos;
private float _lastGroundedTime;
private float _lastJumpedTime;
#endregion
#region CONSTRUCTOR
protected override void Awake()
{
base.Awake();
_motor = this.GetComponent<ApocMovementMotor>();
}
protected override void OnStartOrEnable()
{
base.OnStartOrEnable();
_motor.BeforeUpdateMovement -= this.OnBeforeUpdateMovement;
_motor.BeforeUpdateMovement += this.OnBeforeUpdateMovement;
}
protected override void OnDisable()
{
base.OnDisable();
_motor.BeforeUpdateMovement -= this.OnBeforeUpdateMovement;
}
#endregion
#region IGroundingResolver Interface
/// <summary>
/// Returns true if the last ground test trace hit something
/// </summary>
public bool IsGrounded
{
get { return _groundNormal != Vector2.zero; }
}
public Vector2 GroundNormal
{
get { return _groundNormal; }
}
public Vector2 LastGroundedPosition { get { return _lastGroundedPos; } }
/// <summary>
/// The time at which we last calculated being on the ground.
/// </summary>
public float LastGroundedTime { get { return _lastGroundedTime; } }
public Vector2 DesiredJumpNormal { get { return Vector2.up; } }
public float LastJumpedTime { get { return _lastJumpedTime; } }
public void SetJumping()
{
_currentState = GroundingState.Jumping;
_lastGroundedPos = _motor.SurfaceConstraint.ProjectPosition2D(this.entityRoot.transform.position);
_lastJumpedTime = Time.time;
}
#endregion
#region Properties
public GroundingState CurrentState { get { return _currentState; } }
/// <summary>
/// Returns true if the time since the last time jumped is less than JustJumpedCooldown.
/// </summary>
public bool JustJumped
{
get { return Time.time - _lastJumpedTime < this.JustJumpedCooldown; }
}
#endregion
#region Methods
public void SetGrounded(Vector2 groundNormal)
{
_groundNormal = groundNormal;
_currentState = GroundingState.Grounded;
var oldPos = _lastGroundedPos;
_lastGroundedPos = _motor.SurfaceConstraint.ProjectPosition2D(this.entityRoot.transform.position);
Notification.PostNotification<LandedNotification>(this, new LandedNotification(_lastGroundedPos, oldPos, this.GroundNormal), true);
}
public void SetDropping(bool takeCurrentPositionAsLastGroundedPosition)
{
_currentState = GroundingState.Descending;
if (takeCurrentPositionAsLastGroundedPosition)
{
_lastGroundedPos = _motor.SurfaceConstraint.ProjectPosition2D(this.entityRoot.transform.position);
}
Notification.PostNotification<DroppedNotification>(this, new DroppedNotification(), true);
}
public void SetHanging()
{
_currentState = GroundingState.Hanging;
}
public GroundingState UpdateGroundingState()
{
if (_currentState == GroundingState.Hanging)
{
return _currentState;
}
if (_currentState == GroundingState.Grounded)
{
_lastGroundedPos = _motor.LastPosition;
if (!this.IsGrounded)
{
//LEFT GROUND
_currentState = GroundingState.Descending;
}
}
else if (_currentState > GroundingState.Grounded)
{
if (this.IsGrounded)
{
//LANDED
_currentState = GroundingState.Grounded;
var oldPos = _lastGroundedPos;
_lastGroundedPos = _motor.SurfaceConstraint.ProjectPosition2D(this.entityRoot.transform.position);
Notification.PostNotification<LandedNotification>(this, new LandedNotification(_lastGroundedPos, oldPos, this.GroundNormal), true);
}
else if (_currentState == GroundingState.Jumping)
{
if (_motor.LastMoveVelocity.y < 0) _currentState = GroundingState.Descending;
}
else if (_currentState == GroundingState.Descending)
{
if (_lastGroundedPos.y - this.entityRoot.transform.position.y > this.TerminalFallDistance)
{
_currentState = GroundingState.Falling;
Notification.PostNotification<FallingNotification>(this, new FallingNotification(), true);
}
}
else if (_currentState == GroundingState.Falling)
{
}
}
else
{
_currentState = (this.IsGrounded) ? GroundingState.Grounded : GroundingState.Descending;
_lastGroundedPos = _motor.SurfaceConstraint.ProjectPosition2D(this.entityRoot.transform.position);
}
return _currentState;
}
/// <summary>
/// Retests the ground normal and returns true if grounded.
/// </summary>
/// <returns></returns>
public bool UpdateGroundNormal()
{
if (this.JustJumped)
{
_groundNormal = Vector2.zero;
return false;
}
var geom = _motor.Controller.GetGeom(true);
var d = this.GroundingSkinWidth + _motor.Controller.SkinWidth;
RaycastHit hit;
if (geom.Cast(Vector3.down, out hit, d, Constants.MASK_SURFACE))
{
if(this.RepairSurfaceNormal)
{
_groundNormal = _motor.SurfaceConstraint.ProjectVectorTo2D(com.spacepuppy.Geom.PhysicsUtil.RepairHitSurfaceNormal(hit, Constants.MASK_SURFACE));
}
else
{
_groundNormal = _motor.SurfaceConstraint.ProjectVectorTo2D(hit.normal);
}
_lastGroundedTime = Time.time;
}
else
{
_groundNormal = Vector2.zero;
}
return _groundNormal != Vector2.zero;
}
#endregion
#region IMovementEnhancer Interface
private void OnBeforeUpdateMovement(object sender, System.EventArgs e)
{
this.UpdateGroundNormal();
this.UpdateGroundingState();
}
#endregion
#region Notification Types
public class LandedNotification : Notification
{
private Vector2 _pos;
private Vector2 _lastGroundedPos;
private Vector2 _groundNormal;
private float _fallDistance;
public LandedNotification(Vector2 currentAndLastPos, Vector2 groundNormal)
{
_pos = currentAndLastPos;
_lastGroundedPos = currentAndLastPos;
_groundNormal = groundNormal;
_fallDistance = 0f;
}
public LandedNotification(Vector2 currentPos, Vector2 lastGroundedPos, Vector2 groundNormal)
{
_pos = currentPos;
_lastGroundedPos = lastGroundedPos;
_groundNormal = groundNormal;
_fallDistance = Mathf.Max(0f, _lastGroundedPos.y - _pos.y);
}
public Vector2 Position { get { return _pos; } }
public Vector2 LastGroundedPosition { get { return _lastGroundedPos; } }
public Vector2 GroundNormal { get { return _groundNormal; } }
public float FallDistance { get { return _fallDistance; } }
}
public class DroppedNotification : Notification
{
public DroppedNotification()
{
}
}
public class FallingNotification : Notification
{
public FallingNotification()
{
}
}
#endregion
}
}
A little complex as I gather a lot of information, a lot of which may be superficial to general usage.
But used in tandem with one of my JumpResolvers:
using UnityEngine;
using System.Collections.Generic;
using com.spacepuppy;
using com.spacepuppy.Movement;
using com.spacepuppy.Utils;
namespace com.apoc.Movement
{
[AddComponentMenu("Apoc/Movement/Resolver: Jumping")]
[RequireComponent(typeof(ApocMovementMotor))]
[RequireLikeComponent(typeof(IGroundingResolver))]
public class JumpResolver : SPNotifyingComponent, IDoubleJumpResolver
{
#region Fields
[Tooltip("Duration of time upon leaving the ground that you're still allowed to initialize a jump.")]
public float JumpInitDelay = 0.1f;
public float JumpSpeed = 7.0f;
public bool DoubleJump = false;
public float DoubleJumpCooldown = 0.1f;
[Tooltip("Restrict double jump to when you're at some height in your first jump. Defaults to negative infinity to allow double jump at any time.")]
public float MinimumHeightAllowDoubleJump = float.NegativeInfinity;
public float DoubleJumpSpeed = 7.0f;
private ApocMovementMotor _motor;
private IGroundingResolver _groundingResolver;
private float _cooldownTimer;
private bool _bDidDoubleJump = false;
private bool _bJumpCached;
private float? _cachedJumpSpeed;
#endregion
#region CONSTRUCTOR
protected override void Awake()
{
base.Awake();
_motor = this.GetComponent<ApocMovementMotor>();
_groundingResolver = this.GetComponentAlt<IGroundingResolver>();
}
protected override void OnStartOrEnable()
{
base.OnStartOrEnable();
Notification.RegisterObserver<GroundingResolver.LandedNotification>(this.entityRoot, this.OnLanded);
_motor.BeforeUpdateMovement -= this.OnBeforeUpdateMovement;
_motor.BeforeUpdateMovement += this.OnBeforeUpdateMovement;
}
protected override void OnDisable()
{
base.OnDisable();
Notification.RemoveObserver<GroundingResolver.LandedNotification>(this.entityRoot, this.OnLanded);
_motor.BeforeUpdateMovement -= this.OnBeforeUpdateMovement;
}
#endregion
#region Properties
public bool IsCoolingDown { get { return _cooldownTimer > 0.0f; } }
public bool DidDoubleJump { get { return _bDidDoubleJump; } }
#endregion
#region Methods
public bool ApplyJump(ref Vector2 mv, float overrideJumpSpeed, bool bDesiresToJump = false)
{
if (!this.enabled) return false;
if (this.IsCoolingDown) return false;
var t = Time.time;
if (_groundingResolver.IsGrounded ||
(t - _groundingResolver.LastJumpedTime > this.JumpInitDelay && t - _groundingResolver.LastGroundedTime < this.JumpInitDelay))
{
if (_bJumpCached || bDesiresToJump)
{
var spd = (_cachedJumpSpeed != null) ? _cachedJumpSpeed.Value : overrideJumpSpeed;
//mv.y = spd;
mv = VectorUtil.SetLengthOnAxis(mv, _groundingResolver.DesiredJumpNormal, spd);
_bJumpCached = false;
_cachedJumpSpeed = null;
_cooldownTimer = this.DoubleJumpCooldown;
return true;
}
}
else if (this.DoubleJump && !_bDidDoubleJump)
{
if (_bJumpCached || bDesiresToJump)
{
if (!_groundingResolver.IsGrounded && (_motor.Position.y - _groundingResolver.LastGroundedPosition.y) > this.MinimumHeightAllowDoubleJump)
{
var spd = (_cachedJumpSpeed != null) ? _cachedJumpSpeed.Value : overrideJumpSpeed;
//mv.y = spd;
mv = VectorUtil.SetLengthOnAxis(mv, _groundingResolver.DesiredJumpNormal, spd);
_bJumpCached = false;
_cachedJumpSpeed = null;
_cooldownTimer = this.DoubleJumpCooldown;
_bDidDoubleJump = true;
return true;
}
}
}
_bJumpCached = false;
return false;
}
public void CacheJump(float overrideJumpSpeed)
{
if (this.IsCoolingDown) return;
_bJumpCached = true;
_cachedJumpSpeed = overrideJumpSpeed;
}
public void ResetCooldown()
{
_cooldownTimer = 0.0f;
}
#endregion
#region IJumpResolver Interface
public bool IsGrounded
{
get { return _groundingResolver.IsGrounded; }
}
public bool ApplyJump(ref Vector2 mv, bool bDesiresToJump = false)
{
if (!this.enabled) return false;
if (this.IsCoolingDown) return false;
var t = Time.time;
if (_groundingResolver.IsGrounded ||
(t - _groundingResolver.LastJumpedTime > this.JumpInitDelay && t - _groundingResolver.LastGroundedTime < this.JumpInitDelay))
{
if (_bJumpCached || bDesiresToJump)
{
var spd = (_cachedJumpSpeed != null) ? _cachedJumpSpeed.Value : this.JumpSpeed;
//mv.y = spd;
mv = VectorUtil.SetLengthOnAxis(mv, _groundingResolver.DesiredJumpNormal, spd);
_bJumpCached = false;
_cachedJumpSpeed = null;
_cooldownTimer = this.DoubleJumpCooldown;
return true;
}
}
else if (this.DoubleJump && !_bDidDoubleJump)
{
if (_bJumpCached || bDesiresToJump)
{
if (!_groundingResolver.IsGrounded && (_motor.Position.y - _groundingResolver.LastGroundedPosition.y) > this.MinimumHeightAllowDoubleJump)
{
var spd = (_cachedJumpSpeed != null) ? _cachedJumpSpeed.Value : this.DoubleJumpSpeed;
//mv.y = spd;
mv = VectorUtil.SetLengthOnAxis(mv, _groundingResolver.DesiredJumpNormal, spd);
_bJumpCached = false;
_cachedJumpSpeed = null;
_cooldownTimer = this.DoubleJumpCooldown;
_bDidDoubleJump = true;
return true;
}
}
}
_bJumpCached = false;
return false;
}
public void CacheJump()
{
if (this.IsCoolingDown) return;
_bJumpCached = true;
_cachedJumpSpeed = null;
}
public void SignalJumping()
{
_groundingResolver.SetJumping();
Notification.PostNotification<JumpedNotification>(this, JumpedNotification.Create(_bDidDoubleJump), true);
}
public void ResetDoubleJump()
{
_bDidDoubleJump = false;
}
#endregion
#region Notification Handlers
private void OnLanded(object sender, GroundingResolver.LandedNotification n)
{
this.ResetCooldown();
this.ResetDoubleJump();
}
#endregion
#region IMovementEnhancer Interface
private void OnBeforeUpdateMovement(object sender, System.EventArgs e)
{
if (_cooldownTimer > 0f)
{
_cooldownTimer -= Time.deltaTime;
if (_cooldownTimer < 0.0f)
{
_cooldownTimer = 0.0f;
}
}
}
#endregion
}
}
Of course each implement an interface of IGroundingResolver and IJumpResolver in case I need to write an alternate version of each.
Then from my movement script I just call ‘ApplyJump’ to my velocity vector, to get jumps added on.