Wednesday, June 18, 2014

Unity3d parkour, wall Running and you! Part 2: Intermediate Movement

So where we left off last time was with this code:
public float TurnSpeed = 90f;
public float MouseSensitivity = 2.5f;
private float cameraRotX = 0f;

private Vector3 moveDirection;
public float BaseSpeed = 4.0f;
public float JumpSpeed = 8.0f;
public float Gravity = 20.0f;

void Update() {
     transform.Rotate (0f, (Input.GetAxis("Mouse X") * MouseSensitivity) * TurnSpeed * Time.deltaTime, 0f);
     camera.transform.forward = transform.forward;

     cameraRotX -= Input.GetAxis("Mouse Y") * MouseSensitivity;
     camera.transform.Rotate(cameraRotX, 0f, 0f);

     if (controller.isGrounded) {
          moveDirection = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
          moveDirection = transform.TransformDirection(moveDirection);
          moveDirection.Normalize();

          moveDirection *= BaseSpeed;

          if (Input.GetButton("Jump")){
               moveDirection.y = JumpSpeed;
          }
     }

     moveDirection.y -= Gravity * Time.deltaTime;

     controller.Move(moveDirection * Time.deltaTime);
}

So what we want to work on next is...still not the wall running. Not just yet. I'm sorry, we'll get to that. Lets do some house keeping though to keep things clean.

Remember how I said we're not applying movement when we're grounded? Well, there's a chance we don't want camera updates happening at certain times either. Lets take the camera code out, and put it in a new function:

StandardCameraUpdate() {
     transform.Rotate (0f, (Input.GetAxis("Mouse X") * MouseSensitivity) * TurnSpeed * Time.deltaTime, 0f);
     camera.transform.forward = transform.forward;

     cameraRotX -= Input.GetAxis("Mouse Y") * MouseSensitivity;
     camera.transform.Rotate(cameraRotX, 0f, 0f);
}

Ah, that feels better. Right? And you know something, we're already moving and jumping. We're also going to be wall climbing, ledge grabbing, wall jumping, muscling up ledges, which will all be controlled via the Update()...So before it becomes a mess (and conveniently I've already been through and cleaned up that mess for you guys), lets take the default movement code, and give it its own function instead:

UpdateDefault(){

     StandardCameraUpdate();

     if (controller.isGrounded) {
          moveDirection = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
          moveDirection = transform.TransformDirection(moveDirection);
          moveDirection.Normalize();

          moveDirection *= BaseSpeed;

          if (Input.GetButton("Jump")){
               moveDirection.y = JumpSpeed;
          }
     }

     moveDirection.y -= Gravity * Time.deltaTime;
}

Please note, the first thing called again is the StandardCameraUpdate(). Now, whenever we're doing default movement, we need to aim the camera. Of course, there may be other times when we're not doing default movement that we want to look around (like when we're jumping), so we'll want to call StandardCameraUpdate() then too. This is why it isn't folded into our default movement, and the camera update has its own function.

This means our Update() code is now cut down to this:

void Update(){

     UpdateDefault();

     controller.Move(moveDirection * Time.deltaTime);
}

Now that looks simple! But this encapsulation is pointless if we don't create other updates. We will, but lets perfect our UpdateDefault() for our default movement first. And by, "perfect", I mean, "get you guys to basically where I'm at," because nothing is ever really perfect.

Now, an important part in my design has been the organic feeling. When you start running, you usually don't immediately hit top speed. You kind of work up to it. Also if you're running at full tilt, you probably take a couple steps to stop, even when you're trying hard. It's subtle touches like this that are important, I think. Lets try adding this in!

First, this type of stuff is going to require information from frame to frame. We also need a maximum speed that we're going to hit, and we need to decide how long it's going to take of continuous running before we hit that speed. (Also, if you haven't already, I recommend taking some time on your scene in Unity to add boxes, ramps, or walls, preferably of different colors, to give your platform some visual reference for what you're doing, where you are, and how fast you're going while you test it.)

So lets add some variables:

public float RunSpeedIncrease = 4.0f;

public float RampUpTime = 0.75f;
private bool moveKeyDown = false;
private float moveDownTime = 0f;
private float friction = 15.0f;

Now, these guys give us some important ways to keep track of what's going on. RampUpTime is the total amount of time it takes to get to top speed (my RampUpTime here is geared toward hitting top speed after running about 1 unit), RunSpeedIncrease is the total increase to the run speed, moveKeyDown is whether or not our move key is held down, and moveDownTime is how long that key has been held down. Friction is how fast we stop which we'll use when we get there.

Our very first step is an addition to UpdateDefault(). I've put this at the beginning, before anything else just to get it out of the way:

moveKeyDown = Input.GetKey(KeyCode.W);
if(moveKeyDown && moveDownTime < RampUpTime) {
 moveDownTime += Time.deltaTime;
 if (moveDownTime > RampUpTime){
  moveDownTime = RampUpTime;
 }
}

Now, what are we doing here? We are setting our moveKeyDown from the Input class. In the future, it should be more fluid to user control layout, but for now, we can hardcode it to 'W', or anything else we want. Fun fact: if you anticipate or mean for this to be played with joystick controllers, you should think about using the analog values to help determine how fast a person should go too!

Now, if the move key is down, and our move down time is less than our ramp up time, we need to adjust our moveDownTime to reflect that, so we add the deltaTime. If adding that delta time has made it greater than the total RampUpTime possible, we just set the moveDownTime to the RampUpTime.

If the user lets go of the key, however, then we've stopped pushing forward. We need to kill all that ramping up we've done. We should only do this if the user is grounded. Just after the isGrounded check, add this code:

if (controller.isGrounded){

 // Stop  momentum only if grounded. Can't slow down while airborne.
 if (!moveKeyDown){
  moveDownTime = 0f;
 }

That will make sure that as long as the player is holding down the move key when they land, they can continue at their heightened run speed.

Now we actually need to apply this heightened run speed. We're already appropriately getting our movement, so lets just update the line where we multiply the moveDirection by the base speed. We need to add the product of the total run speed increase mutiplied by the quotient of the run time over the total ramp up time. Or, in a better equation and code form:

moveDirection *= BaseSpeed + (RunSpeedIncrease * (moveDownTime / RampUpTime));

There we go! If you test it now, your character should run faster over time. If you don't notice, I recommend making the ramp up time just a bit longer, somewhere around 1-2 seconds instead of 3/4's of a second like I defaulted to. You can also make your RunSpeedIncrease a lot higher too.

What we need to do to add a more gradual slowdown is keep track of if we were moving before, and gradually reduce that amount. You'll want to add a new class level variable for this since it will have to persist from frame to frame (I suppose it doesn't have to, but it's easier this way to me.) We'll also want to assign it after we've done movement every frame. So add the variable, and then a final line before the end of your Update():

private Vector3 lastDirection;

void Update(){

     UpdateDefault();

     controller.Move(moveDirection * Time.deltaTime);
     lastDirection = moveDirection;
}

Now, this is one of those moments where I'd like to remind you that I am no genius. If you know a better way to do this, please do that (and let me know what it is if this has helped you out so far.) But if you don't, here's a new function you can add that's going to help us update our default movement when we slow down. We're going to pass in vector axis for our movement and reduce them based on our friction over time:

float DoSlowDown(float lastVelocity){
 if (lastVelocity > 0){
  lastVelocity -= friction * Time.deltaTime;
  if (lastVelocity < 0)
   lastVelocity = 0;
 }
 else{
  lastVelocity += friction * Time.deltaTime;
  if (lastVelocity > 0)
   lastVelocity = 0;
 }
 return lastVelocity;
}

I know what you're thinking. "Wait! What if lastVelocity is equal to 0?" Well, I don't have it that way. The code is only going to get as far as calling this if the axis in question is not equal to 0.

If we're not moving and need to slow down, it's because no keys are being pressed. Now, granted this is different if we suddenly started going backwards, but I'm going to pretend this isn't a problem yet (and if you figure it out before I eventually update this, I'd be happy to know!) Right now I don't want to figure out inverse vectors and all that. So, basically, if our poll for moveDirection has left it equal to Vector3.zero, we need to start slowing down. For each horizontal axis (X and Z is what I'm saying) that is a non-zero value, we're going to start slowing them down using DoSlowDown(). So lets add this new code just after we've gotten the final verdict on our movement:

// slow to a stop if no input
if ((moveDirection == Vector3.zero && lastDirection != Vector3.zero)) {
 if (lastDirection.x != 0){
  moveDirection.x = DoSlowDown(lastDirection.x);
 }
    
 if (lastDirection.z != 0){
  moveDirection.z = DoSlowDown(lastDirection.z);
 }
}

That should do it! Test your code, and see how your movement goes. I've noticed a slight lean to one side with this slow down code, but it's been minor enough that I haven't freaked out about it yet. This is also a learning experience.

To give a summary, your final code should look something like this:

public float TurnSpeed = 90f;
public float MouseSensitivity = 2.5f;
private float cameraRotX = 0f;

private Vector3 moveDirection;
public float BaseSpeed = 4.0f;
public float JumpSpeed = 8.0f;
public float Gravity = 20.0f;

public float RampUpTime = 0.75f;
private bool moveKeyDown = false;
private float moveDownTime = 0f;
private float friction = 15.0f;

private Vector3 lastDirection;

void Update(){

     UpdateDefault();

     controller.Move(moveDirection * Time.deltaTime);
     lastDirection = moveDirection;
}

StandardCameraUpdate() {
     transform.Rotate (0f, (Input.GetAxis("Mouse X") * MouseSensitivity) * TurnSpeed * Time.deltaTime, 0f);
     camera.transform.forward = transform.forward;

     cameraRotX -= Input.GetAxis("Mouse Y") * MouseSensitivity;
     camera.transform.Rotate(cameraRotX, 0f, 0f);
}

// Default movement update for when someone's just on the ground, running and such.
void UpdateDefault() {

 // Update camera and general house keeping.
 StandardCameraUpdate();

 // Update momentum amount based on continuous run time.
 moveKeyDown = Input.GetKey(KeyCode.W);
 if(moveKeyDown && moveDownTime <= RampUpTime) {
  moveDownTime += Time.deltaTime;
  if (moveDownTime > RampUpTime){
   moveDownTime = RampUpTime;
  }
 }

 if (controller.isGrounded){

  // Stop  momentum only if grounded. Can't slow down while airborne.
  if (!moveKeyDown){
   moveDownTime = 0f;
  }

  // Update move direction with standard forward, back, and strafe controls.
  moveDirection = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
  moveDirection = transform.TransformDirection(moveDirection);
  moveDirection.Normalize();
   
  moveDirection *= BaseSpeed + (RunSpeedIncrease * (moveDownTime / RampUpTime));

  // slow to a stop if no input
  if ((moveDirection == Vector3.zero && lastDirection != Vector3.zero)) {
   if (lastDirection.x != 0){
    moveDirection.x = DoSlowDown(lastDirection.x);
   }
   
   if (lastDirection.z != 0){
    moveDirection.z = DoSlowDown(lastDirection.z);
   }
  }

  if (Input.GetButton("Jump")){
   moveDirection.y = JumpSpeed;
  }
 }

 moveDirection.y -= Gravity * Time.deltaTime;
}

float DoSlowDown(float lastVelocity){
 if (lastVelocity > 0){
  lastVelocity -= friction * Time.deltaTime;
  if (lastVelocity < 0)
   lastVelocity = 0;
 }
 else{
  lastVelocity += friction * Time.deltaTime;
  if (lastVelocity > 0)
   lastVelocity = 0;
 }
 return lastVelocity;
}

Here's this lesson's fun fact: DoSlowDown() was invented on the spot while writing this tutorial. I came back to explain that code, realized I had written the same thing twice just switching out "x" and "z", and realized I could make it its own function. I pushed it to Git just now. Yay!

Okay, now that we've gotten this out of the way, next time we'll get to the wall run. It's going to be kind of a big deal, I think, so get ready! We're also going to use a switch statement in the Update() to turn this into a simple state machine, making the motor more flexible and able to negotiate what it needs to do on its own.

No comments:

Post a Comment