© Casey Hardman  2020
C. HardmanGame Programming with Unity and C#https://doi.org/10.1007/978-1-4842-5656-5_35

35. Advanced 3D Movement

Casey Hardman1 
(1)
West Palm Beach, FL, USA
 

In this chapter, we’ll be implementing the movement system for our player. This includes using the WASD keys to move, pressing Space to jump, and gravity to make the player fall back to the ground. In a later chapter, we’ll also implement a wall jumping mechanic that allows the player to “push off” of a nearby wall to gain extra upward and outward momentum while midair.

How It Works

Let’s get an overview of how we expect this system to work before we start poking into the code. This movement system will operate with a few concepts that are similar to that of our player movement in the first project:
  • Momentum is gained in the direction held by the WASD keys.

  • Ongoing momentum is lost over time when no WASD keys are held.

  • Attempting to move against ongoing momentum will provide an increase in momentum gain so the player can more easily reverse the direction they’re traveling in.

We’ll also use a CharacterController to perform our movement, just like in the first project. This time, however, we have to account for that pesky Y axis – the player has to fall when they run off of a ledge, so we’ll need to make them accumulate downward momentum when they’re midair.

To make things slightly more realistic, we’ll keep any ongoing momentum when the player becomes midair, whether by jumping or falling off a ledge. This means that all velocity they have acquired through movement will keep going in the same direction after they jump or step off a ledge. The velocity won’t begin to drag out until they land on the ground again. Because this can feel a bit sticky, we’ll still allow them to influence their movement with the WASD keys while midair, but we’ll use a multiplier for midair movement so we can decrease how effective it is. We want them to be able to draw back if they’ve made a poor jump or wiggle toward a wall if they didn’t jump straight enough at it, but we don’t want them to have the same control that they have on the ground.

This time, we have to make the movement local to our player model. In our previous projects, our movement systems used world space directions because the camera never rotated. This time, the player model will be facing whatever direction the player is looking toward. The WASD keys are no longer going to provide velocity in consistent directions. They’ll have to be local to the facing of the player.

We can achieve this in a few different ways. Your first idea might be to simply store movement velocity as local to the player. We could do it much the same way as we did in our first project, maintaining a “movementVelocity” variable where the Z axis corresponds to the W and S keys and the X axis corresponds to the A and D keys. For example, if we are traveling straight forward, we might have a movementVelocity of (0, 0, 26). This vector represents 26 units of forward (Z axis) momentum per second.

Since our CharacterController expects us to move it by giving a world space vector, not local to the Transform facing, this means we would have to make our “movementVelocity” local to the facing of our Model Holder, supplying the result to our CharacterController.Move method.

For example, when our movementVelocity is (0, 0, 26), we see it as “26 units of forward momentum, relative to the direction our Model Holder is facing.” But the way we want to look at it is not the way our CharacterController will see it. If we pass it into a CharacterController.Move call, it will see it as “26 units forward in world space,” which could also be considered “north.” It disregards the direction our Model Holder is facing and creates a very awkward experience for our player.

Thus, we have to make the “movementVelocity” local to our Model Holder, using a method on the Transform. This will handle the conversion from local to world space for us. If we transform “movementVelocity” from local to world space when it is (0, 0, 26), and our Model Holder is actually looking backward (south), the “movementVelocity” becomes (0, 0, –26), which takes us south instead of north. This goes for any direction the Model Holder faces. If we are facing directly right, it would become (26, 0, 0) instead or (–26, 0, 0) if we are facing directly left or any shade between.

This is viable and doing it this way can work. We can handle the velocity the same way we did in our first project, the only difference being that we must transform it from local to world space before we supply it to our CharacterController.

But maintaining this is not as easy when it comes to things like falling off of ledges and jumping. Remember, with the movement local to our Model Holder facing, we can obtain forward momentum and then easily steer it by turning the mouse. Whatever way we are facing, that is where the velocity takes us. But once the player becomes midair, we want them to no longer be able to turn their camera to adjust their momentum. You can’t just do a big leap forward, then spin around midair, and start going backward. That isn’t how it works!

So we must add complications. We would have to convert our “movementVelocity” to world space whenever we become midair. This way, it gets locked into the world direction we were facing when we started the jump and is stuck that way. But then what happens when we land? More complications! The world velocity will have to be converted back to local “movementVelocity” once we land, so that we can steer our momentum again.

This all adds a layer of complexity and finicking to the management of our velocity, and it becomes more awkward when you consider how you might have external velocities applied to the player that are not capped by their maximum movement speed, like if something shoved the player or if the player performed a “dash” move like the one we implemented in our first project.

We’ll use a different approach to simplify things. Rather than looking at how far we’re moving on each individual axis, we’ll look at what is known as the magnitude of our velocity Vector3. The magnitude of a vector is a math equation you can perform to get what can be seen as the distance that the vector traverses. It is also sometimes referred to as the length of the vector. We can get the magnitude of a vector by simply using the “Vector3.magnitude” property. It returns a float. We don’t really need to know what the math equation is, because the plain English version is simpler anyway: it returns “how far the vector travels.” If you were to add Vector3 “A” to a Vector3 position “B”, then “B” will be moving “A.magnitude” units of distance from where it was.

The counterpart of the magnitude, so to speak, is the Vector3.normalized member. A normalized vector is essentially “converting it to a direction.” More specifically, it is scaling the magnitude of the vector to 1. The “.normalized” member is a property that returns a new vector, going in the same direction but with its magnitude scaled down to 1.

We’ve used it before. Most notably, we have learned that “(to – from).normalized” is how we get the direction to point from a position of Vector3 “from” to another position of Vector3 “to”. For example, to get a direction pointing from an enemy to the player, do this:
(playerPosition - enemyPosition).normalized

A direction is still a Vector3, it’s just that it has a magnitude of 1. Thus, it can be multiplied by a float to scale the magnitude to whatever that float value is. This is why “.normalized” is effectively turning the vector into a direction. Once you’ve normalized a Vector3, you can then multiply it by a float “X” to get a Vector3 that travels X units in that direction.

We’re about to make use of these concepts to code our movement system. Our velocity will be stored in a single vector depicting the world space velocity our player currently has. This is always world space and never gets converted back and forth.

When we check for input of the WASD keys, we don’t manage our velocity in “units per second.” We just get the local direction that the movement keys are held down in. The local direction is (0, 0, 1) if just W is held. It’s (1, 0, –1) if D (right) and S (backward) are held. And so on.

We can then convert that local direction to a world space direction using the TransformDirection method with our Model Holder reference. It takes a direction that is meant to be local to this Transform and returns back that same direction, but conveyed in world space.

Using that direction, we can then apply our velocity gain per second (a float) to our world velocity. To cap the movement at a maximum speed, we’ll use the magnitude of worldVelocity. If the magnitude is equal to our movespeed variable, we are already moving “movespeed” units per second in the direction we are traveling, so we won’t allow our player to go any faster in that direction (you’ll see how that’s done in a bit).

That’s a general overview of how our process will work – and notably, how it differs from our first project. Before you get overwhelmed with all the details, let’s start implementing it all one piece at a time.

Player Script

We already set up our Player GameObject in the previous chapter, so all we have to do to get set up is add a CharacterController component to the root Player GameObject. Set its Center to (0, 3, 0), its Height to 6, and its Radius to 1, which will make it match the Capsule we added to our Model Holder before.

Now, create a Player script in your Scripts folder. Let’s get it started by declaring our variables and an outline of the methods that split up our Update logic:
public class Player : MonoBehaviour
{
    //Variables
    [Header("References")]
    [Tooltip("Reference to the root Transform, on which the Player script is attached.")]
    public Transform trans;
    [Tooltip("Reference to the Model Holder Transform.  Movement will be local to the facing of this Transform.")]
    public Transform modelHolder;
    [Tooltip("Reference to the CharacterController component.")]
    public CharacterController charController;
    [Header("Gravity")]
    [Tooltip("Maximum downward momentum the player can have due to gravity.")]
    public float maxGravity = 92;
    [Tooltip("Time taken for downward velocity to go from 0 to the maxGravity.")]
    public float timeToMaxGravity = .6f;
    //Property that gets the downward momentum per second to apply as gravity:
    public float GravityPerSecond
    {
        get
        {
            return maxGravity / timeToMaxGravity;
        }
    }
    //Y velocity is stored in a separate float, apart from the velocity vector:
    private float yVelocity = 0;
    [Header("Movement")]
    [Tooltip("Maximum ground speed per second with normal movement.")]
    public float movespeed = 42;
    [Tooltip("Time taken, in seconds, to reach maximum speed from a stand-still.")]
    public float timeToMaxSpeed = .3f;
    [Tooltip("Time taken, in seconds, to go from moving at full speed to a stand-still.")]
    public float timeToLoseMaxSpeed = .2f;
    [Tooltip("Multiplier for additional velocity gain when moving against ongoing momentum.  For example, 0 means no additional velocity, .5 means 50% extra, etc.")]
    public float reverseMomentumMulitplier = .6f;
    [Tooltip("Multiplier for velocity influence when moving while midair.  For example, .5 means 50% speed.  A value greater than 1 will make you move faster while midair.")]
    public float midairMovementMultiplier = .4f;
    [Tooltip("Multiplier for how much velocity is retained after bouncing off of a wall.  For example, 1 is full velocity, .2 is 20%.")]
    [Range(0,1)]
    public float bounciness = .2f;
    //Movement direction, local to the model holder facing:
    private Vector3 localMovementDirection = Vector3.zero;
    //Current world-space velocity; only the X and Z axes are used:
    private Vector3 worldVelocity = Vector3.zero;
    //True if we are currently on the ground, false if we are midair:
    private bool grounded = false;
    //Velocity gained per second.  Applies midairMovementMultiplier when we are not grounded:
    public float VelocityGainPerSecond
    {
        get
        {
            if (grounded)
                return movespeed / timeToMaxSpeed;
            //Only use the midairMovementMultiplier if we are not grounded:
            else
                return (movespeed / timeToMaxSpeed) * midairMovementMultiplier;
        }
    }
    //Velocity lost per second based on movespeed and timeToLoseMaxSpeed:
    public float VelocityLossPerSecond
    {
        get
        {
            return movespeed / timeToLoseMaxSpeed;
        }
    }
    [Header("Jumping")]
    [Tooltip("Upward velocity provided on jump.")]
    public float jumpPower = 76;
    //Update Logic:
    void Movement()
    {
    }
    void VelocityLoss()
    {
    }
    void Gravity()
    {
    }
    void Jumping()
    {
    }
    void ApplyVelocity()
    {
    }
    //Unity Events:
    void Update()
    {
        Movement();
        VelocityLoss();
        Gravity();
        Jumping();
        ApplyVelocity();
    }
}

To define gravity, we declare a maximum downward velocity value that can be applied by gravity and the time we want it to take, in seconds, to reach that maximum velocity. Decrease the timeToMaxGravity variable, and gravity will take full effect on your player faster. Increase it and the player will be “floatier,” taking longer to begin falling fast after they have jumped or stepped off an edge.

The actual Y velocity is stored as a float, while the X and Z axes of our velocity will be stored in the “worldVelocity” Vector3. This is so we can track the magnitude of our outward velocity for movement, without the Y axis affecting it. It’ll make more sense why we do it this way when we apply our movement to the velocity.

Our movement variables are similar to those of our first project. The “movespeed” is the maximum velocity on the X and Z axes that we want to be able to have just by moving while grounded. External forces might make us move faster than that, but if we’re just moving around while grounded, we won’t be able to pick up any more speed than “movespeed”.

We also use “timeToMaxSpeed” and “timeToLoseMaxSpeed” which are used in the VelocityGainPerSecond and VelocityLossPerSecond properties, just like in the movement system of our first project.

The “reverseMomentumMultiplier” is also the same concept we used in our first project, although we’ll be implementing it a little differently. It is a multiplier for our movespeed which is added as bonus speed when we are working to move against ongoing velocity – trying to go right when we are already traveling left, for example. The higher you set it, the quicker the player can switch directions.

The “midairMovementMultiplier” is how we prevent the player from gaining as much velocity from midair movement as they would with grounded movement. We apply it when getting the VelocityGainPerSecond property, but only while “grounded” is false (meaning we’re midair).

The “bounciness” is a new variable that we use to determine how hard the player bounces off of walls that they hit while midair. We don’t want the player to slide against walls that they strike while midair. If they did, then they would keep sliding against a wall; and if they rose up over the wall, they would keep going. Thus, you could jump at a wall, faceplant into it for a second, and then rise up over it and keep going forward as if you didn’t just smack into the wall. To avoid that, we’ll redirect the player’s momentum away whenever they strike a wall. Alternatively, you could set “bounciness” to 0 to make the player completely stop traveling outward when they hit a wall – they won’t bounce off of it, they’ll just plop against it like a ball of wet paper towel.

Beneath the variables, our process is outlined with our empty methods:
  • Movement() will check if the player is holding the WASD keys, updating the “localMovementDirection” vector variable we declared based on which keys are held. If any keys are held, it will convert the local movement direction to world space based on the direction the Model Holder is facing and then apply VelocityGainPerSecond in that direction. Since this occurs first, other methods can use the “localMovementDirection” to check which movement keys are held, if they need to.

  • VelocityLoss() causes our ongoing velocity to “drag out” while we are grounded; and we are either not holding any movement keys, or our velocity magnitude is greater than “movespeed”.

  • Gravity() subtracts from “yVelocity” as long as we are midair, but only if our downward velocity has not exceeded “maxGravity”.

  • Jumping() checks for the Space key being pressed while grounded. If so, it adds “jumpPower” upward momentum by adding to “yVelocity”.

  • ApplyVelocity() puts our “worldVelocity” and “yVelocity” together and applies it as movement per second with the CharacterController. We’ll use some information given to us by the CharacterController to determine if we touched ground during that movement, and if so, we’ll set “grounded” to true, or otherwise we’ll set it to “false.” Conversely, we’ll also check if we bumped our head during the movement. If so, we’ll lose our upward velocity so we start falling as soon as we hit our head instead of sliding against the surface until gravity begins to pull us down.

To make sure things are ready when we begin, go ahead and add an instance of the Player script to the root “Player” GameObject. Set the three references: “trans” should point to the root “Player” Transform, the Model Holder can be dragged from the Hierarchy onto the corresponding field to reference it, and the CharacterController you just added to the Player can be found and dragged to the Char Controller field through the same Inspector view.

Movement Velocity

Let’s start with basic grounded movement and work up from there. First, we’ll fill in the code for our Movement method, which will make our WASD keys affect our “worldVelocity”. Add the following code to your empty Movement method:

void Movement()
{
    //Every frame, we'll reset local movement direction to zero and set its X and Z based on WASD keys:
    localMovementDirection = Vector3.zero;
    //Right and left (D and A):
    if (Input.GetKey(KeyCode.D))
        localMovementDirection.x = 1;
    else if (Input.GetKey(KeyCode.A))
        localMovementDirection.x = -1;
    //Forward and back (W and S):
    if (Input.GetKey(KeyCode.W))
        localMovementDirection.z = 1;
    else if (Input.GetKey(KeyCode.S))
        localMovementDirection.z = -1;
    //If any of the movement keys are held this frame:
    if (localMovementDirection != Vector3.zero)
    {
        //Convert local movement direction to world direction, relative to the model holder:
        Vector3 worldMovementDirection = modelHolder.TransformDirection(localMovementDirection.normalized);
        //We'll calculate a multiplier to add the reverse momentum multiplier based on the direction we're trying to move.
        float multiplier = 1;
        //Dot product will be 1 if moving directly towards existing velocity,
        // 0 if moving perpendicular to existing velocity,
        // and -1 if moving directly away from existing velocity.
        float dot = Vector3.Dot(worldMovementDirection.normalized,worldVelocity.normalized);
        //If we're moving away from the velocity by any amount,
        if (dot < 0)
            //Now, flipping the 'dot' with a '-' makes it between 0 and 1.
            //Exactly 1 means moving directly away from existing momentum.
            //Thus, we'll get the full 'reverseMomentumMultiplier' only when it's 1.
            multiplier += -dot * reverseMomentumMulitplier;
        //Calculate the new velocity by adding movement velocity to the current velocity:
        Vector3 newVelocity = worldVelocity + worldMovementDirection * VelocityGainPerSecond * multiplier * Time.deltaTime;
        //If world velocity is already moving more than 'movespeed' per second:
        if (worldVelocity.magnitude > movespeed)
            //Clamp the magnitude at that of our world velocity:
            worldVelocity = Vector3.ClampMagnitude(newVelocity,worldVelocity.magnitude);
        //If we aren't moving over 'movespeed' units per second yet,
        else
            //Clamp the magnitude at a maximum of 'movespeed':
            worldVelocity = Vector3.ClampMagnitude(newVelocity,movespeed);
    }
}

As we previously discussed, we’ll use the WASD keys to get a “local movement direction” that simply points in the direction the player is holding with the WASD keys. The X and Z axes are all we’re using, and they’ll either be 0, 1, or –1. This is the direction the player is attempting to move in, local to the facing of the Model Holder.

We convert this from local to world space by calling “modelHolder.TransformDirection”, storing the result in the variable named “worldMovementDirection”. With this, we know the direction we want our velocity to be influenced toward by our movement, and it’s in world space so we can use it to add to our “worldVelocity”.

You might wonder why we normalize our “localMovementDirection” when we pass it into the TransformDirection method. Technically, the magnitude is not 1 for our world direction vector if two movement keys are being held. For example, the magnitude of a vector like (1, 0, 1) is not 1, it’s a little higher because it traverses a little more distance than a vector that’s just (0, 0, 1) or (1, 0, 0). Thus, we actually get a little more movement when we move diagonally, unless we normalize it. This just makes it so that diagonal movement is not “more effective” than moving directly forward, backward, left, or right.

Before we apply the change in velocity using that world direction, we calculate the “multiplier” variable, which is how we apply the reverse momentum influence. This uses a new Vector3 method, “Dot”. It returns what is known as the “dot product” of two Vectors. It should be given two normalized vectors – which means two vectors with a magnitude of 1. As the comments describe, the dot product will be 1 if vector A points in the same direction as vector B, 0 if it points perpendicular (a 90-degree angle away), and –1 if it points in the exact opposite direction. It’s not just one of those three values, though – it’s a fraction anywhere between them. So if A points in almost the same direction as B, but not exactly, it might return something a little lower than 1, like .9.

We’ll use the “dot” to determine how much of our “reverseMomentumMultiplier” gets added to the “multiplier” we declared. First, we check if “dot” is less than 0. If it’s greater than 0, it’s traveling in a direction no more than 90 degrees off of the direction the world velocity is taking us. Thus, it’s not really reversing momentum, so we don’t apply any extra multiplier. Since we declare “multiplier” with a default value of 1, this means it’s not going to affect the movement at all.

However, if it’s less than 0, we add to the multiplier, using “dot” as a fraction for how much of the reverseMomentumMultiplier is used. We flip “dot” so that it’s anywhere between 0 and 1, not –1 and 0. If we don’t do that, it would decrease the value of “multiplier,” since we’d be adding a negative value. Of course, you could also just subtract the value without flipping “dot” if you changed the line to this instead:
multiplier -= dot * reverseMomentumMulitplier;

Both versions do the same thing in slightly different ways. Use whichever makes more sense to you, if you want!

With that, we have our multiplier. It will be anywhere from 1 to 1.6, if “reverseMomentumMultiplier” is at its default value of .6.

After that, we perform a little vector trickery to apply the velocity. We first calculate the new velocity in a separate vector. This is done by starting with the existing worldVelocity and adding the velocity we want to add on this frame. This equation is simple enough. We use the world movement direction we calculated earlier and multiply that by the velocity we want to gain per second; plus, we apply the “multiplier” we just calculated, and of course, Time.deltaTime is part of the equation, since it is “velocity gained per second.”

When we apply the new velocity, we have to make sure we aren’t increasing the velocity above the magnitude it should be allowed to have. Since we aren’t handling each individual axis (X, Y, and Z) separately as we were in our first project, we have to do this differently. The simple solution is to use the Vector3.ClampMagnitude static method. It takes a Vector3 and a float for the maximum magnitude we want that vector to be allowed. It returns back the same vector; but, if the magnitude was greater than the float value, it will be scaled down to the float value. If the magnitude was not greater, the vector is returned as is.

We clamp the magnitude in two different ways. If it’s already at something greater than “movespeed”, then that means some external force may have given us a shove. We don’t want to constantly clamp our magnitude at “movespeed” because then this isn’t possible anymore. External forces which push us harder than we are able to move on our own will immediately be negated if we constantly clamp our world velocity to “movespeed” magnitude.

But if we aren’t moving any faster than “movespeed”, we clamp it to a maximum of “movespeed”.

This allows us to apply the velocity so that our momentum is adjusted in the direction our movement takes us, but without ever allowing our ongoing momentum to be greater than “movespeed”. The same concept applies when the magnitude is greater than “movespeed”. Say we’re shoved by something, like an enemy striking us or a force field pushing us. We have 60 movespeed, but our world velocity magnitude is now 90 due to the external force. If we move against the momentum, then the clamping of the magnitude doesn’t matter. We’ll be decreasing the world velocity magnitude because we are losing velocity: moving directly against it, we’re simply decreasing its magnitude. But if we move in the same direction, our movement won’t give us more speed, since the magnitude is prevented from raising over its current value.

Applying Movement

Let’s apply the movement to our player so we can see it in action. We’ll add this code to the last method we call in Update, the ApplyVelocity method:
void ApplyVelocity()
{
    //While grounded, apply slight downward velocity to keep our grounded state correct:
    if (grounded)
        yVelocity = -1;
    //Calculate the movement we'll receive this frame:
    Vector3 movementThisFrame = (worldVelocity + (Vector3.up * yVelocity)) * Time.deltaTime;
    //Calculate where we expect to be after moving if we don't hit anything:
    Vector3 predictedPosition = trans.position + movementThisFrame;
    //Only call Move if we have a minimum of .03 velocity:
    if (movementThisFrame.magnitude > .03f)
        charController.Move(movementThisFrame);
    //Checking grounded state:
    if (!grounded && charController.collisionFlags.HasFlag(CollisionFlags.Below))
        grounded = true;
    else if (grounded && !charController.collisionFlags.HasFlag(CollisionFlags.Below))
        grounded = false;
    //Bounce off of walls when we hit our sides while midair:
    if (!grounded && charController.collisionFlags.HasFlag(CollisionFlags.Sides))
        worldVelocity = (trans.position - predictedPosition).normalized * (worldVelocity.magnitude * bounciness);
    //Lose Y velocity if we're going up and collided with something above us:
    if (yVelocity > 0 && charController.collisionFlags.HasFlag(CollisionFlags.Above))
        yVelocity = 0;
}

Because we’ll be asking our CharacterController “Did we hit something below us the last time we called Move()?” to determine if we are grounded or not, we apply a constant, negligible amount of downward velocity while we are grounded. This way, if we move while grounded, we’ll go down a little bit, causing us to touch the floor beneath us. If we didn’t do this, we’d move directly outward, and the CharacterController would not think we were grounded because our bottom didn’t touch anything.

We store the vector we will be moving on this frame in a variable and later pass that into the Move call with our CharacterController. The velocity is simple enough: worldVelocity is our X and Z velocity, and we add (0, yVelocity, 0) to that. Remember, Vector3.up is just a shorthand way of typing “new Vector3(0, 1, 0)”. Multiplying a float by Vector3.up is just saying “go up by this amount.”

We also store a vector for the position we expect to have after moving, if nothing gets in our way. This is used to calculate bouncing direction.

To prevent calling Move when there’s barely any velocity, we only call it when the distance we are going to move is greater than .03. This will help us prevent an issue down the road with platforms. It’ll also cut out Move calls that aren’t really moving us anywhere noticeable, which can save a little on performance.

After we move, we can then use the CharacterController.collisionFlags member to check which parts of the capsule making up our controller had collisions during the last move call.

This is a bit mask, which behaves like a layer mask. Remember how layer masks are essentially a list of “checkboxes” for each entry? Each individual layer can be true or false. This is how the collision flags work, except instead of layers, we have collision directions: Below, Sides, and Above. We can use the “HasFlag” method to return true if a collision occurred Below, at the Sides, or Above. Of course, it will return false if a collision did not occur there.

We check if we are currently not grounded and hit something below us. If so, we become grounded.

After that, we check if we are grounded, but did not hit anything below us. In that case, we must become midair (not grounded).

We also perform our “bouncing” here. When we hit something from our side while midair, we adjust our velocity. This is done by redirecting it from the predicted position to the actual position we ended up at. We then multiply that direction by the magnitude, which is affected by “bounciness.” If the bounciness is 1, we get the full magnitude redirected. If it were .5 instead, we’d only get 50% of the magnitude, causing some of our momentum to be lost when we hit the wall.

After that, we check also for collisions at our top. If we struck something above us, we lose all positive yVelocity. If we didn’t implement this, we would keep rising up against anything above us until gravity dropped our yVelocity below 0. This way, it immediately drops to 0 once we bump our head, causing us to start falling.

With that, you can test movement by using the WASD keys. Of course, we still have to actually make the movement stop once we let go of the WASD keys; otherwise, we’ll just keep moving.

Losing Velocity

Let’s implement the VelocityLoss method:

void VelocityLoss()
{
    //Lose velocity as long as we are grounded, and we either are not holding movement keys, or are moving faster than 'movespeed':
    if (grounded && (localMovementDirection == Vector3.zero || worldVelocity.magnitude > movespeed))
    {
        //Calculate velocity we'll be losing this frame:
        float velocityLoss = VelocityLossPerSecond * Time.deltaTime;
        //If we're losing more velocity than the world velocity magnitude:
        if (velocityLoss > worldVelocity.magnitude)
            //Zero out velocity so we're totally still:
            worldVelocity = Vector3.zero;
        //Otherwise if we're losing less velocity:
        else
            //Apply velocity loss in the opposite direction of the world velocity:
            worldVelocity -= worldVelocity.normalized * velocityLoss;
    }
}
This isn’t terribly complicated. We first supply the situation when velocity loss should occur:
  • We must be grounded, not midair.

  • We must not be holding any of the WASD keys. To determine this, we use the “localMovementDirection” vector which we set in the Movement method every frame.

  • Alternatively, if we are holding any of the WASD keys, we will still lose velocity if our world velocity magnitude is greater than “movespeed”.

To apply the velocity loss, we first calculate how much magnitude we should lose on this frame in a quick variable. Then, we must apply it one of two ways. If the magnitude we are losing on this frame is greater than the magnitude of our world velocity, then applying it should just end all momentum, so we set worldVelocity to “zero.”

Otherwise, if we aren’t going to lose all velocity magnitude on this frame, we apply the velocity loss as momentum in the opposite direction that the worldVelocity is currently traveling in. Seeing this, it should become a bit clearer why we must differentiate between the two methods of applying the velocity. If we just did the latter method every frame, then we would never actually stop moving completely. We would apply velocity in the opposite direction until our momentum reversed; then we’d do it again and again, constantly reversing the direction because we’re constantly adding some amount of velocity every frame.

That should now allow us to move around in-game and, once we let go of the WASD keys, lose all of our velocity over time.

Gravity and Jumping

Now all that’s left is the vertical axis. First, we’ll fill in the Gravity method , which is a simple few lines of code:

void Gravity()
{
    //While not grounded,
    if (!grounded && yVelocity > -maxGravity)
        //Decrease Y velocity by GravityPerSecond, but don't go under -maxGravity:
        yVelocity = Mathf.Max(yVelocity - GravityPerSecond * Time.deltaTime,-maxGravity);
}

Since maxGravity is set as a positive value, depicting the “maximum downward momentum we can have due to gravity,” we have to do a little flipping when we apply it to yVelocity. If yVelocity is positive, we’ll go up. If it’s negative, we’ll go down. Thus, the gravity needs to subtract from our yVelocity. We use Max to ensure that should it drop below “–maxGravity”, it instead is set to “–maxGravity”.

We only do this if our yVelocity is not already less than “–maxGravity”. This makes sure that external forces can drive us downward harder than gravity can, but should that happen, gravity will not keep applying.

With that, you can add a cube to your scene to walk on, position your player on top of it, and then play and walk off the edge. You should start falling as soon as your figurative feet leave the cube.

Let’s give ourselves a way to get back up onto the cube, though, and implement jumping:
void Jumping()
{
    if (grounded && Input.GetKeyDown(KeyCode.Space))
    {
        //Start traveling 'jumpPower' upwards per second:
        yVelocity = jumpPower;
        //Stop counting ourselves as grounded since we know we just jumped:
        grounded = false;
    }
}

Again, not too complicated. We only allow jumping while grounded, and it occurs when you press Space. Since we know we’re grounded and will have no downward velocity (except the default –1 to keep ground detection functioning correctly), our yVelocity can simply be set to the “jumpPower” with an “=” rather than adding to it with a “+=”.

You might wonder why it’s necessary to bother setting “grounded” to false when a jump occurs. You’ll recall that, while grounded, we constantly set our “yVelocity” to –1 in the ApplyVelocity method, which is called just after the Jumping method. This would still occur immediately after a jump if we didn’t set “grounded” to “false” here, which would make jumping do nothing.

With that, you can test again and try jumping with Space. You’ll rise and fall based on the gravity settings and the jump power. How high the jump takes you is dependent upon a combination of all those variables: the maximum gravity, the time taken to apply maximum gravity, and the jump power.

Summary

In this chapter, we learned some more advanced tricks for working with vectors to implement player movement, jumping, and gravity in a mouse-aimed setup. Some key things to remember are as follows:
  • The magnitude (also called length) of a vector is the amount of distance it traverses.

  • A normalized vector is a vector with a magnitude of 1. This can be looked at as a “direction.” Multiply it by float “X” to go X units in the given direction.

  • After calling Move with a CharacterController, you can test where collisions occurred on the collider using the “collisionFlags” member.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.17.150.89