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; }
So here's where we're at now. We have neatly encapsulated code that handles our standard movement, and our look direction. I like using state machines perhaps a little too much as well. But they're great for games. Our state machine is going to handle switching from a finite number of states that the player character can be in. Is the player jumping? Default? Wall running? Climbing? Ledge grabbing? The machine we build will figure these things out and update itself for us.
Of course, technically most programs are state machines. The moment you write an if block, your machine is determining what to do because it's in one "state" or another. Most of them are not organized to run solely around the concept of different states, and only being one at a time.
First, we want to add an enumeration to our motor. We want to add it to the very bottom of the class file (not to the class itself, this will actually be outside the class.) It will only be accessible to the motor for now, there is currently no reason that anything else should have access to this enumeration. So scroll down, all the way down to the very final brace...Then go further. Add a couple empty lines, and then add:
public enum MotorStates { Climbing, Default, Falling, Jumping, Ledgegrabbing, MusclingUp, Wallrunning }
Whoa! That's more than we need, right? Heck, it's more than I am actually using at the moment (I have yet to implement Falling as a state in my own code, Default is working for that. Also, thanks to my friend Kyle, who has actually done Parkour and told me what "Muscling Up" was.) I figured I'd just give you all of them, we don't need to use every single one of these yet, and later on I won't have to tell you to add them one at a time. We will, however, want to set ourselves up nicely to switch between wall running, jumping, and default states. We're going to set a MotorStates variable, and then set up a simple switch statement in our Update(). I'm also going to ask you to do something that will make you think I am purposely defeating the nice switch statement, and you don't have to, but I have this canWallRun variable to make sure a player is eligible for a wall run. I have rules, you know! Also, some wall run time limits and such, similar to the run speed ramp up. Here's the things you shall want to add to the beginning of the file, since a lot of things will be referring to these:
private MotorStates motorState = MotorStates.Default; private bool canWallRun = true; private float wallRunMaxTime = 1.5f; private float wallRunTime = 0.0f; private RaycastHit wallHit;
Now, if you've been paying attention since the last post, I'm pretty sure you can figure out what wallRunMaxTime, and wallRunTime are for (they're for limiting how long you can wall run, if you weren't!) WallHit is something that I originally had inside a function, but I moved it to the class level to facilitate with some things, and really I think it can go either way. In fact, I may change this before I'm done writing this tutorial.
Now lets stub some things out, because we have a lot of code to add. This is stuff that I built up incrementally, so how it interweaves isn't going to make perfect sense at first most likely. We're going to need a few things. We'll have a jump state, and that will check if we can wall run, or wall climb (once we get there) so we're going to need an UpdateJump(). We're going to have the wall run state, so we'll need an UpdateWallRun(), and we'll need to check if we can wall run, so we'll need a DoWallRunCheck(). I'm also going to spoil the surprise for you, we're going to want to do a StopWallRun() method as well, so lets stub everything out, and put some stuff where we know it'll be useful. You can place it wherever in your class, but here's what all we'll have:
void UpdateJump(){ StandardCameraUpdate(); moveDirection.y -= Gravity * Time.deltaTime; } RaycastHit DoWallRunCheck(){ } void UpdateWallrun(){ } void StopWallRun(){ }
For the UpdateJump(), I added in two things that we definitely know it will do. It will do a camera update, so we can look around while flying (because it doesn't make sense not to), and it will apply gravity.
UpdateJump() is one of our most important additions here, as it's the bridge between default behavior and new, vertical movement behaviors (have you played Mirror's Edge? Because you should really, really play that. I mean, I can show you stuff, but seriously, it should be a staple, like Portal.)
So now we need to have a way to get to UpdateJump(). Lets add our switch statement to Update() to get the pathway opened up:
void Update(){ switch(motorState){ case(MotorStates.Jumping): UpdateJump(); break; case(MotorStates.Wallrunning): UpdateWallRun(); break; default: UpdateDefault(); break; } controller.Move(moveDirection * Time.deltaTime); lastDirection = moveDirection; }
With that simple addition we only have to set motorState to something different to entirely change the motor's locomotion. Lets do that now! We already have a clear spot! Go to the UpdateDefault() code, and add a simple line to the area where we start jumping:
if (Input.GetButton("Jump")){ motorState = MotorStates.Jumping; moveDirection.y = JumpSpeed; }
Now on the next Update(), we can see that it's going to go to UpdateJump(). We need to make sure UpdateJump() returns us to a default state if we hit the ground:
void UpdateJump() { StandardCameraUpdate(); moveDirection.y -= Gravity * Time.deltaTime; if (controller.isGrounded){ motorState = MotorStates.Default; } }
I recommend testing it right now. If it doesn't work, you should put a comment on my page, because I am re-making up this stuff as I go, I don't have test projects for you yet. In fact, you're probably getting an error because the stubbed out DoWallRunCheck() isn't returning anything.
Are you back? It works? Okay, great! Oh, it doesn't? Try commenting out DoWallRunCheck() and try again. Now lets go to the UpdateJump(), and get it started on honestly checking for a wall run. This is going to seem obtuse most likely, because I did the wall run check for the UpdateWallRun() long before I added UpdateJump() as a thing, but it will all come together, trust me. We'll fill out the DoWallRunCheck() in just a moment. For now, lets set up UpdateJump():
void UpdateJump() { StandardCameraUpdate(); // Do a wall run check and change state if successful. wallHit = DoWallRunCheck(); if (wallHit.collider != null) { motorState = MotorStates.Wallrunning; return; } moveDirection.y -= Gravity * Time.deltaTime; if (controller.isGrounded){ motorState = MotorStates.Default; } }
Now, as you may expect, DoWallRunCheck() is supposed to return a RaycastHit. This is used in the actual UpdateWallRun(), and since it already existed when I created UpdateJump(), I just checked to see if the collider isn't null.
DoWallRunCheck() does a ray cast to both the left and the right sides, then checks the angles if any impacts, and then returns what it views as the best impact available, though it could do that even better than it does. For our purposes, I'm going to show you the simple function as it is right now, and explain it in detail after. It should only fail in rare, weird circumstances that shouldn't happen. Naturally it means someone is going to break this immediately. Here it is:
// Does a raycast to check if a wall was hit on either side, then checks if the angle between // the forward vector and the wall's normal is appropriate for a wall run (ie: don't wall run // if facing away), then returns the closest, properly angled impact (if there are two somehow) RaycastHit DoWallRunCheck(){ Ray rayRight = new Ray(transform.position, transform.TransformDirection(Vector3.right)); Ray rayLeft = new Ray(transform.position, transform.TransformDirection(Vector3.left)); RaycastHit wallImpactRight; RaycastHit wallImpactLeft; bool rightImpact = Physics.Raycast(rayRight.origin, rayRight.direction, out wallImpactRight, 1f); bool leftImpact = Physics.Raycast(rayLeft.origin, rayLeft.direction, out wallImpactLeft, 1f); if (rightImpact && Vector3.Angle(transform.TransformDirection(Vector3.forward), wallImpactRight.normal) > 90) { return wallImpactRight; } else if (leftImpact && Vector3.Angle(transform.TransformDirection(Vector3.forward), wallImpactLeft.normal) > 90) { wallImpactLeft.normal *= -1; return wallImpactLeft; } else { // Just return something empty, because nothing is good for a wall run return new RaycastHit(); } }
So the very first thing this does is make two rays. One that shoots out from the game object's transform to the right, and one to the left. It makes two variables for the impacts, and then two booleans, which are then assigned to the results of the appropriate Physics.Raycasts of each ray.
The Physics.Raycast returns True or False depending on if the ray hit something. It will also fill a RaycastHit variable with info about the impact when called. The final float is the distance. This is very important, as otherwise the Raycast will do an infinite ray in the proper direction, and you don't want to know about hitting a wall a mile away. I had this problem elsewhere.
After doing these raycasts, the boolean values are tested in an if statement, which also has an AND that then checks if the Vector3.Angle is appropriate. This is where the HitInfo is important. We can compare our forward direction, and if the angle between the forward, and the surface's normal is greater than 90 degrees, we know that we're actually moving away from the wall. The surface normal is the vector if you drew a line straight out perpendicular to the surface impacted by the raycast.
Now, another trick is that if we're wall running on a wall to the left of the player, we need to multiply the HitInfo's normal vector by -1. There is a mathematical reason for it, but I've been writing these blogs all day to get through this point and my brain is getting a little fuzzy. Sorry! I'll update this if a way to explain it suddenly comes to me. The important thing to know is that the surface normal for the left side is the opposite of what we want it to be, so we just fix that by multiplying it by -1.
The final thing is that the HitInfo is returned for whichever one was appropriate. If neither are, an empty hit is returned. This is because RaycastHit is a struct, so we cannot return null. We return an empty struct, and then the UpdateJump() verifies that something that wouldn't be null if the hit were successful is. If the RaycastHit's collider is null, we know it didn't hit anything.
However, now we're at a point where DoWallRunCheck() will return a value that will evaluate to true in UpdateJump(), which means on our next Update(), the switch will call UpdateWallRun(), which is empty! This will cause the program to hang. So now we should discuss UpdateWallRun().
Lets just start with the initial check in UpdateWallRun() to make sure we should be wall running:
void UpdateWallRun(){ if (!controller.isGrounded && canWallRun && wallRunTime < wallRunMaxTime){ // Wall run stuff... } else { StopWallRun(); } }
So, the first if is a little convoluted even for my tastes, but there's a reason. Obviously, if any of the three parameters fails, we StopWallRun(). The first may seem silly, because we just got to the wall run by jumping, right? So we won't be grounded! But Update() is going to repeatedly UpdateWallRun(), and as time goes on, we're going to fall more and more even as we wall run. So eventually, we either hit max time, or we hit the ground that is hopefully there. If we hit the ground, we stop wall running, because it's better and safer to run on the ground than on a wall. Also, I didn't always have this, and it was very awkward to be stuck against a wall until you hit a time limit or the wall ends.
The next is if we can wall run. This is more optional and less necessary than making sure we can be grounded. As you will soon see, I have the wall run set up so once you stop wall running for any reason, you can't do it again. Even if you make it through the air to another wall, you don't get to wall run off a second wall. These are the rules I've established, I didn't want to allow for the temptation of any obstacles that are just timing to make sure you repeatedly wall run properly. This is where your game design can really vary. Maybe you want someone to be able to perpetually wall run as long as they keep jumping between walls, or maybe you only want them to be able to do it twice, or thrice! It's up to you, but that's why I put that check in.
The next check you can also change if you want. I put a time limit. Once the wall run time is no longer less than the max time, it is time to stop.
So now with that explained, lets do one final check, and then reset our state (this is more of an artifact, but this also means we can just get to the UpdateWallRun() from somewhere else too, and don't have to worry about changing it.) Inside our if block, where we actually do wall run stuff, add this real quick:
wallHit = DoWallRunCheck(); if (wallHit.collider == null){ StopWallRun(); return; } motorState = MotorStates.Wallrunning;
Here is where DoWallRunCheck() is actually setting us up for wallHit to do a lot of work. Before it was just telling us if we were eligible to wall run. But now wallHit's very important information is going to help us calculate the direction we need to go. What we are going to do is get the Cross Product between our character and the surface normal that was impacted. The cross product returns a vector that is at a right angle (perpendicular) to two vectors. We use the surface normal of the wallHit, and Vector3.up to get this cross product, and that becomes our movement vector.
We then Slerp the actual rotation over a short amount of time so it's a visually organic movement, even though our actual moveDirection changes at a sharp angle. Slerp is a gradual rotation over time using Quaternions. I'm not going to go into what Quaternions are, there are many other blogs about that, but just remember: you need to rotate over time, you can use Quaternion.Slerp to do it. Here is the code we shall add right after resetting the motorState in the UpdateWallRun function:
float previousJumpHeight = moveDirection.y; Vector3 crossProduct = Vector3.Cross(Vector3.up, wallHit.normal); Quaternion lookDirection = Quaternion.LookRotation(crossProduct); transform.rotation = Quaternion.Slerp(transform.rotation, lookDirection, 3.5f * Time.deltaTime); moveDirection = crossProduct; moveDirection.Normalize(); moveDirection *= BaseSpeed + (RunSpeedIncrease * (moveDownTime / RampUpTime));
See? Calculate the crossProduct, it then goes to helping find our lookDirection (a quaternion.) We Slerp towards that direction, and then the movement set up virtually exactly the same as before. One thing to note: do the way the rotation is set here, and the way it's set in the StandardCameraUpdate(), there will be a jerk in camera angle once you land. I'm working on how to make these things mesh together.
One thing to note, however. Calculating the crossProduct flatlines the y movement. The easiest way around it is to save the previous Y axis of the movement vector, and reset it later. Also, if the jump is just starting, the Y-axis should be set so there's some vertical movement to the jump. If it's not just starting, then we subtract a reduced amount of gravity from the Y-axis, since we have all this wall running stuff to help fight gravity. Add the following code just after the previous moveDirection code:
if (wallRunTime == 0.0f){ moveDirection.y = JumpSpeed / 4; } else { moveDirection.y = previousJumpHeight; moveDirection.y -= (Gravity / 4) * Time.deltaTime; }
Pretty familiar, right? If not, go back and read the previous two parts to this post series!
So now we've updated our forward movement. We've updated our vertical movement. The only thing left to do is update the timer! At this point, I don't think I need to explain all of the code, as it's similar to stuff you've seen previously. If we've gone over our time, I turn the canWallRun to false, and let the player drop on the next update:
wallRunTime += Time.deltaTime; if (wallRunTime > wallRunMaxTime){ canWallRun = false; }
This is it for our wall run. The only thing left is the StopWallRun(). We need to set this so it resets our variables and changes states properly. It's also pretty self explanatory at this point:
void StopWallRun(){ if (motorState == MotorStates.Wallrunning) canWallRun = false; wallRunTime = 0.0f; motorState = MotorStates.Default; }
I just do a little validation and make sure that canWallRun is set to false. Here's one game design choice: I set the motorState to Default. This means it goes back to UpdateDefault(), which allows the player to look, and also applies gravity. I could, for example, set it to UpdateJump(), and because canWallRun is false, they won't wall run again, but they could potentially wall climb (which I've added in the most recent, but will post in a later blog.) As it is, I feel that wall run is not enough of an upward, "jump up" movement to give the momentum to wall climb. Perhaps, when I add the wall jump, I may change my mind, but that would be a WallJump() function switching state back to Jumping.
I'm going to stop here for right now. My original goal was to talk about wall running in Unity, and create a tutorial for that since there wasn't one. This has come about by my personal parkour project called Jump! You can see the full code on my Github here: https://github.com/ScavengerHyena/Jump/blob/master/Assets/Scripts/jumpMotor.cs
Please, once again, this is my first blog, and first tutorial. Let me know how I might improve it and if anything has gone wrong where it shouldn't have, or if you're just entirely lost. I haven't been afraid to provide this information since everything is in a work-in-progress state, and this has been done as a personal project to get practice and keep busy in between professional contracts. If you directly use my classes, please attribute, or talk about it, or something. The gesture would be greatly appreciated, since the entire thing I hope for with this work is to create a fun parkour game, and also recognition.