Monday, October 14, 2013

The Difference Between Doing Something Quickly and Doing It Well

I'd like to talk, likely at length, about the difference between doing something quickly and doing it well.
I feel that they each have their place. Up until this point, nearly everything in this project has been done quickly. That kind of quick and dirty (actually incredibly sloppy) code has gotten us where we are, which is a very good place to be. We are 'ahead,' and more importantly, our game is fun. (This isn't nominally a competition, but it almost definitely is in practice.)

However, doing something well and doing it quickly are different things.

For instance, I redid our movement a while back.

It used to work by a fairly simple process. The code read the horizontal value of an analog stick, multiplied that by a constant and the time that has passed since the last frame, and moved the character that distance. When the player jumped, it applied a constant vertical force as long as the player held the jump button and hadn't run out of remaining jump time. The player got their jump time back when they hit another object while falling. When the player wasn't jumping, they fell at a constant speed.

It looked (in short) like this:

      // Update is called once per frame  
      void Update()  
      {  
           Vector3 stickPos = Vector3.zero;  
           stickPos.x = Input.GetAxis(playerNumber + "Horizontal");  
           stickPos.y = Input.GetAxis(playerNumber + "Vertical");  
           Vector3 moveDir = Vector3.zero;  
           if (!Input.GetButton(playerNumber + "Root"))  
           {  
                moveDir.x = stickPos.x * speed * Time.deltaTime;  
                moveDir.y = 0;  
                //if (Input.GetButton(playerNumber + "Jump") || (stickPos.y >= tapJumpThreshold && lastStickPos.y < tapJumpThreshold))  
                if (Input.GetButton(playerNumber + "Jump") || (stickPos.y >= tapJumpThreshold))  
                {  
                     if (remainingJumpGas > Time.deltaTime)  
                     {  
                          remainingJumpGas -= Time.deltaTime;  
                          moveDir.y = jump * Time.deltaTime;  
                     }  
                     else  
                     {  
                          moveDir.y = jump * remainingJumpGas;  
                          remainingJumpGas = 0;  
                     }  
                }  
           }  
           moveDir.y += gravity * Time.deltaTime;  
           controller.Move(moveDir);  
           if (stickPos.x < -switchFacingThreshold)  
                facing = false;  
           if (stickPos.x > switchFacingThreshold)  
                facing = true;  
           lastStickPos = stickPos;  
      }  


      void OnControllerColliderHit(ControllerColliderHit hit)  
      {  
           if(hit.moveDirection == Vector3.down)  
                remainingJumpGas = jumpGas;  
      }  

This code worked fine, but it didn't feel very good. The movement felt slow, stiff, and unnatural. Moving while jumping was particularly painful, and jumps felt very floaty.

The reworked process is a little more complicated.

The new horizontal movement code generates three numbers every frame.
First, a target velocity, defined by the horizontal position of the movement stick.
Second, a maximum horizontal acceleration, defined in terms of how many seconds it takes the player to accelerate from a stop to full speed, that differs when the player is in the air. The sign of this value is determined by the horizontal position of the movement stick, and if the stick is within the dead zone, this value is set to zero.
Third, a damping value that represents how quickly the player will stop if they are allowed to glide (that is to say "they receive no input") that also differs when the player is in the air. The sign of this value is always opposite the sign of the current velocity (it always accelerates toward zero, so to speak) and if the current velocity is zero, this value is also set to zero.

From the target velocity and the current velocity, it generates a target velocity change. Then, the horizontal movement code adds either the horizontal acceleration value, or the damping value, or neither (if the desired velocity change is zero) or both (if the player is trying to change direction) to the current velocity. It does not always apply the entire velocity change from applicable values, if the change would exceed the desired change it applies only the desired change in velocity.

The new jump process is equally complex.

Gravity is handled as an acceleration force that will always accelerate the player downward. When the player presses the jump button, if they are grounded, it resets their vertical velocity to a constant "standing jump" upward value. If they are not grounded and still have air jumps, it resets their vertical velocity to a constant "air jump" upward value, and drops their remaining air jumps by one. Air Jumping also immediately sets the player's horizontal velocity to their target velocity, which helps compensate for low aerial control, and makes air jumps feel very impactful. At any point during this process, if the player releases the jump button while moving upward, they will end the jump early (reset their vertical velocity to zero) which allows for short hops on the ground to dodge projectiles without having to float for too long. There is also an experimental mechanic where an air jump aimed downward will cause the player to "Ground Pound" and fall very quickly. This is probably going to be removed, or reworked into a draftable dash move, it doesn't seem to play very well right now. I've never seen someone do it because they wanted to, but people do it by accident and get mad sometimes.

(Design aside: Low aerial control means that jumping is a risk. It makes your movements more predictable in the air, which is otherwise a very powerful place to be. You move faster when jumping or falling, and are harder to hit with horizontal attacks, which are by far the most prevalent kind of projectile in the game right now. A limited number of air jumps mean that you can still do a physical dodge (that is, maneuver out of the way of a projectile, rather than use your dodge move to negate its damage.) However, air jumping to dodge a projectile usually means that you won't be going where you want to go. You need your air jumps to land where you want to land most of the time, or at least to maintain your air time and vertical position, so using them for other things often results in other consequences. Positioning is fairly important, and it's pretty easy for a good player to land at least one or two free hits on a player who is landing too close to them. On a whole, I think this system feels very good for both players involved, every action involved in it feels organic, every jump represents a test of skill for one player, and a chance to outplay that person for the other player.)

This code looks  more like this:

(I apologize for it still not being in a particularly consistent style. Stylistic consistency is nice, but I'm the only programmer on this project and it's not a priority for me right now.)

     // Update is called once per frame  
     void Update()  
     {  
          float linearDamping, acceleration;  
          if (grounded)  
          {  
               linearDamping = maxVelocity / timeToStop;  
               acceleration = maxVelocity / timeToMaxSpeed;  
          }  
          else  
          {  
               linearDamping = maxVelocity / airTimeToStop;  
               acceleration = maxVelocity / airTimeToMaxSpeed;  
          }  
          float targetVelocity = maxVelocity * Input.GetAxis(playerNumber + "Horizontal");  
          if (Input.GetButton(playerNumber + "Root"))  
               targetVelocity = 0;  
          float horizontalDirection = 0;  
          if (Input.GetAxis(playerNumber + "Horizontal") <= -horizontalDeadZone)  
          {  
               horizontalDirection = -1;  
               _facing = false;  
          }  
          if (Input.GetAxis(playerNumber + "Horizontal") >= horizontalDeadZone)  
          {  
               horizontalDirection = 1;  
               _facing = true;  
          }  
          float accelerationValue = acceleration * horizontalDirection * Time.deltaTime;  
          float linearDampingValue = linearDamping * -1 * Mathf.Sign(currentVelocity.x) * Time.deltaTime;  
          float desiredVelocityChange = targetVelocity - currentVelocity.x;  
          if (desiredVelocityChange < 0)  
          {  
               if (linearDampingValue < 0)  
               {  
                    if (linearDampingValue < desiredVelocityChange)  
                         linearDampingValue = desiredVelocityChange; //Not allowed to damp more than the desired velocity change  
                    if (currentVelocity.x > 0 && linearDampingValue < currentVelocity.x * -1)  
                         linearDampingValue = currentVelocity.x * -1; //Not allowed to damp past zero  
                    currentVelocity.x += linearDampingValue;  
                    desiredVelocityChange = targetVelocity - currentVelocity.x; //We have to recalculate this every time we change the current velocity, because reasons, but it will never change sign so we don't have to break out of the loop.  
               }  
               if (accelerationValue < 0)  
               {  
                    if (accelerationValue < desiredVelocityChange)  
                         accelerationValue = desiredVelocityChange; //Not allowed to accelerate past the desired change  
                    currentVelocity.x += accelerationValue;  
               }  
          }  
          else if (desiredVelocityChange > 0)  
          {  
               if (linearDampingValue > 0)  
               {  
                    if (linearDampingValue > desiredVelocityChange)  
                         linearDampingValue = desiredVelocityChange; //Not allowed to damp more than the desired velocity change  
                    if (currentVelocity.x < 0 && linearDampingValue > currentVelocity.x * -1)  
                         linearDampingValue = currentVelocity.x * -1; //Not allowed to damp past zero  
                    currentVelocity.x += linearDampingValue;  
                    desiredVelocityChange = targetVelocity - currentVelocity.x; //We have to recalculate this every time we change the current velocity, because reasons, but it will never change sign so we don't have to break out of the loop.  
               }  
               if (accelerationValue > 0)  
               {  
                    if (accelerationValue > desiredVelocityChange)  
                         accelerationValue = desiredVelocityChange; //Not allowed to accelerate past the desired change  
                    currentVelocity.x += accelerationValue;  
               }  
          }  
          else  
          {  
               //Velocity is right where it needs to be.  
          }  
          if (currentVelocity.x > maxVelocity)  
               currentVelocity.x = maxVelocity;  
          if (currentVelocity.x < -maxVelocity)  
               currentVelocity.x = -maxVelocity;  
          if (!specialFall)  
          {  
               currentVelocity.y += -gravity * Time.deltaTime;  
               if (currentVelocity.y < -terminalVelocity)  
               currentVelocity.y = -terminalVelocity;  
          }  
          if (grounded)  
          {  
               if (Input.GetButtonDown(playerNumber + "Jump") || (Input.GetAxis(playerNumber + "Vertical") >= tapJumpThreshold && lastStickPos.y < tapJumpThreshold && !Input.GetButton(playerNumber + "Root")))  
               {  
                    currentVelocity.y = jumpStrength;  
                    grounded = false;  
               }  
          }  
          else  
          {  
               if (airJumpsRemaining == airJumpCount)  
               {  
                    if (Input.GetButtonUp(playerNumber + "Jump"))  
                         if (currentVelocity.y > 0)  
                              currentVelocity.y = 0;  
               }  
               //Air Jump Code  
               if (airJumpsRemaining > 0)  
               {  
                    if (Input.GetButtonDown(playerNumber + "Jump"))  
                    {  
                         airJumpsRemaining--;  
                         if (Input.GetAxis(playerNumber + "Vertical") > crashThreshold)  
                         {  
                              currentVelocity.y = airJumpStrength;  
                              specialFall = false;  
                         }  
                         else  
                         {  
                              currentVelocity.y = -specialFallVelocity;  
                              specialFall = true;  
                         }  
                         currentVelocity.x = targetVelocity;  
                    }  
                    if (Input.GetAxis(playerNumber + "Vertical") >= tapJumpThreshold && lastStickPos.y < tapJumpThreshold && !Input.GetButton(playerNumber + "Root"))  
                    {  
                         airJumpsRemaining--;  
                         currentVelocity.y = airJumpStrength;  
                    }  
               }  
          }  
          controller.Move(new Vector3(currentVelocity.x, currentVelocity.y, 0) * Time.deltaTime);  
          lastStickPos = new Vector2(Input.GetAxis(playerNumber + "Horizontal"), Input.GetAxis(playerNumber + "Vertical"));  
     }  


     void OnControllerColliderHit(ControllerColliderHit hit)  
     {  
          if (hit.moveDirection == Vector3.down)  
          {  
               airJumpsRemaining = airJumpCount;  
               grounded = true;  
               specialFall = false;  
          }  
     }  

As you can see, it's about four times as long, and the process is many times more complicated. In addition, the first implementation took about ninety minutes counting the time I spent looking up how character controllers in Unity are supposed to work. The second one took two or three days of overtime work to experiment with, discover how to do, implement, and debug. So, what do we get for that effort?
  • Super snappy jumps
  • Jumps with many uses, from short hops over projectiles to dodges to rapid direction changes
  • Horizontal movement that feels natural and responsive
  • Horizontal movement that still allows very precise control of speed
  • A very configurable system that can be easily tuned by designer.
  • Movement that is fun to play with and feels great

Which is really quite a lot, and it was definitely worth the effort to do again correctly.

Going forward, I'm doing something similar with our attack editing systems. Right now, adding a new attack takes me about thirty-five minutes, and I'm the only one on the team who can do it. It involves manually creating a prefab and two scripts, adding the new attack to the player prefab, manually adding the attack to the player attack script in four different places, and manually adding the attack to the draft screen (usually making the draft screen larger to fit it, editing that prefab too.) It's a nightmare.

I'm slowly taking on the process of making a new GUI-based editor to simplify the scripting of new attacks. In my dreams, we could integrate this editor into the game as a mod tool and ship with it.


Right now this tool only exists in my head, all I have is this mock-up that I spent far too long on. But it's one of my major goals going forward. I'll talk about it in more detail in the future, I have some things to say about tools programming and tools design.

And as always, you can play at fighting.loulessing.com.

No comments:

Post a Comment