HTML5 2D game development: Manipulating time, Part 1

Implement jumping with linear motion

In this series, HTML5 maven David Geary shows you how to implement an HTML5 2D video game one step at a time. In the first of two consecutive installments, you'll implement the runner sprite's jumping behavior.

David Geary, Author and speaker, Clarity Training, Inc.

David GearyThe author of Core HTML5 Canvas, David Geary is also the co-founder of the HTML5 Denver User's Group and the author of eight Java books, including the best-selling books on Swing and JavaServer Faces. David is a frequent speaker at conferences, including JavaOne, Devoxx, Strange Loop, NDC, and OSCON, and he is a three-time JavaOne Rock Star. He wrote the JSF 2 fu and GWT fu article series for developerWorks. You can follow David on Twitter at @davidgeary.



05 February 2013

Also available in Chinese Russian Japanese Spanish

In the last article in this series, I discussed how to encapsulate actions that sprites undertake — such as running, falling, pacing, or blowing up — in pluggable objects known as behaviors. At run time, you can easily adorn any sprite with any set of behaviors you desire. Among its many benefits, that flexibility encourages the exploration of game aspects that might otherwise lie dormant.

In this article I continue to discuss sprite behaviors, with a couple of twists. First, this is the first of two consecutive articles in the series devoted to a single sprite behavior: the runner's jump behavior. By the end of "Manipulating time, Part 2," Snail Bait will ultimately arrive at the natural jump sequence depicted in Figure 1:

Figure 1. A natural jump sequence
Screen capture of Snail Bait showing the runner's natural jump sequence

Collision-detection deferral

I'm deferring coverage of Snail Bait's collision detection to concentrate on the runner's motion while she is jumping. With collision detection implemented, the runner lands on platforms, thereby short-circuiting the jump; without collision detection, jumps progress until they are completed. To get the full effect of jumping, download the article's code and try it for yourself.

Second, the jump behavior, unlike the behaviors I discussed in the preceding article, doesn't repeat indefinitely. Because of that simple difference, Snail Bait must keep track of time as jumps progress. That requirement begets the need for something akin to a stopwatch, so I will implement a JavaScript stopwatch and use it to time the runner's ascent and descent as she jumps.

Runner tracks and platform tops

Snail Bait's platforms move horizontally on three tracks, as shown in Figure 2:

Figure 2. Platform tracks
Screen capture showing Snail Bait's three platform tracks

The space between tracks is 100 pixels. That gives the runner, whose height is 60 pixels, more than enough room to maneuver.

Listing 1 shows how Snail Bait sets the runner's height and the platforms' vertical positions. It also lists a convenience method — calculatePlatformTop()— that, given a track (either 1, 2, or 3), returns the track's corresponding baseline.

Listing 1. Calculating platform tops from track baselines
var SnailBait = function () {
   // Height of the runner's animation cells:

   this.RUNNER_CELLS_HEIGHT = 60, // pixels

   // Track baselines:

   this.TRACK_1_BASELINE = 323, // pixels
   this.TRACK_2_BASELINE = 223,
   this.TRACK_3_BASELINE = 123,
   ...
};
...

SnailBait.prototype = {
   ...
   calculatePlatformTop: function (track) {
      var top;
   
      if      (track === 1) { top = this.TRACK_1_BASELINE; }
      else if (track === 2) { top = this.TRACK_2_BASELINE; }
      else if (track === 3) { top = this.TRACK_3_BASELINE; }

      return top;
   ...
};

Snail Bait uses calculatePlatformTop() to position nearly all of the game's sprites.


The initial jump implementation

As implemented at the end of the last article, Snail Bait has the most simplistic of algorithms for jumping, as shown in Listing 2:

Listing 2. Keyboard handling for jumps
window.onkeydown = function (e) {
   var key = e.keyCode;
   ...
   
   if (key === 74) { // 'j'
      if (snailBait.runner.track === 3) { // At the top; nowhere to go
         return;
      }

      snailBait.runner.track++;
      snailBait.runner.top = snailBait.calculatePlatformTop(snailBait.runner.track) -
                              snailBait.RUNNER_CELLS_HEIGHT;
   }
};
...

When the player presses the j key, Snail Bait immediately puts the runner's feet on the track above the runner (provided the runner is not on the top track already), as shown in Figure 3:

Figure 3. Jerky jump sequence: simple to implement, but unnatural
Screen capture of the Snail Bait runner's jerky jump sequence, implemented by the initial simple algorithm in Listing 2

The jumping implementation shown in Listing 2 has two serious drawbacks. First, the way the runner moves from one level to another — instantly — is far from the desired effect. Second, the jumping implementation is at the wrong level of abstraction. A window event handler has no business directly manipulating the runner's attributes; instead, the runner itself should be responsible for jumping.


Shifting responsibility for jumping to the runner

Listing 3 shows a refactored implementation of the window's onkeydown event handler. It's much simpler than the implementation in Listing 2, and it shifts the responsibility for jumping from the event handler to the runner.

Listing 3. The window's key handler, delegating to the runner
window.onkeydown = function (e) {
   var key = e.keyCode;
   ...
   
   if (key === 74) { // 'j'
      runner.jump();
   }
};

When the game starts, Snail Bait invokes a method named equipRunner(), as shown in Listing 4:

Listing 4. Equipping the runner at the start of the game
SnailBait.prototype = {
   ...
   start: function () {
      this.createSprites();
      this.initializeImages();
      this.equipRunner();
      this.splashToast('Good Luck!');
   },
};

The equipRunner() method, shown in Listing 5, adds attributes and a jump() method to the runner:

Listing 5. Equipping the runner: The runner's jump() method
SnailBait.prototype = {
   equipRunner: function () {
      // This function sets runner attributes:

      this.runner.jumping = false; // 'this' is snailBait
      this.runner.track = this.INITIAL_RUNNER_TRACK;

      ... // More runner attributes omitted for brevity

      // This function also implements the runner's jump() method:

      this.runner.jump = function () {
         if ( ! this.jumping) {    // 'this' is the runner.
            this.jumping = true; // Start the jump
         } 
      };
   },
},

Views and controllers

The runner's jump behavior and its corresponding jump() method resemble a view/controller pair. How Snail Bait draws the runner while she is jumping is implemented in the behavior, whereas the runner's jump() method acts as a simple controller that controls whether the runner is currently jumping or not.

The runner has attributes that represent, among other things, her current track and whether or not she is currently jumping.

If the runner is not currently jumping, the runner.jump() method merely sets the runner's jumping attribute to true. Snail Bait implements the act of jumping in a separate behavior object, as it does for all the runner's other behaviors such as running and falling — and indeed, for all sprite behaviors. When it creates the runner, Snail Bait adds that object to the runner's array of behaviors, as shown in Listing 6:

Listing 6. Creating the runner with its behaviors
var SnailBait = function () {
   ...
   this.jumpBehavior = {
      execute: function(sprite, time, fps) {

         // Implement jumping here

      },
      ...
   };
   ...

   this.runner = new Sprite('runner',          // type
                            this.runnerArtist,  // artist
                            [ this.runBehavior, // behaviors
                              this.jumpBehavior,
                              this.fallBehavior
                            ]); 
   ...
};

Now that the infrastructure is in place for initiating a jump, I can concentrate solely on the jump behavior.


The jump behavior

Listing 7, which shows an initial implementation of the runner's jump behavior, is functionally equivalent to the code in Listing 2. If the runner's jumping attribute — which is set by the runner's jump() method (see Listing 5) — is false, the behavior does nothing. The behavior also does nothing if the runner is on the top track.

Listing 7. An unrealistic jump behavior implementation
var SnailBait =  function () {
   ...

   this.jumpBehavior = {
      ...

      execute: function(sprite, time, fps) {
         if ( ! sprite.jumping || sprite.track === 3) {
            return;
         }

         sprite.track++;

         sprite.top = snailBait.calculatePlatformTop(sprite.track) -
                      snailBait.RUNNER_CELLS_HEIGHT;

         sprite.jumping = false;
      } 
   },
   ...
};

An endless loop

Recall that Snail Bait is essentially an endless loop that constantly executes all the behaviors of every visible sprite. The runner's jump() method initiates a jump by simply setting the runner's jumping attribute to true. The next time Snail Bait executes the runner's jump behavior, that setting causes the behavior to take action.

If the runner is jumping and she's not on the top track, the jump behavior implemented in Listing 7 moves her to the next track and completes the jump by setting her jumping attribute to false.

Just like the jumping implementation in Listing 2, the implementation in Listing 7 instantly moves the runner from one track to another. For a realistic jumping motion, you must gradually move the runner from one track to another over a specific period of time.


Timed animations: Stopwatches

All the motion that I've implemented so far in Snail Bait has been constant; for example, all the game's sprites, except for the runner, move continuously in the horizontal direction, and buttons and snails constantly pace back and forth on their platforms. (See the Scrolling the background section from the second article in this series to see how that motion is implemented.) Coins, sapphires, and rubies can also slowly bob up and down without ever stopping to take a break.

Jumping, however, is not constant; it has a definite start and end. To implement jumping, therefore, I need a way to constantly monitor how much time has elapsed since a jump began. What I need is a stopwatch.

Listing 8 shows the implementation of a Stopwatch JavaScript object:

Listing 8. A Stopwatch object
// Stopwatch..................................................................
//
// You can start and stop a stopwatch and you can find out the elapsed
// time the stopwatch has been running. After you stop a stopwatch,
// its getElapsedTime() method returns the elapsed time
// between the start and stop.

Stopwatch = function ()  {
   this.startTime = 0;
   this.running = false;
   this.elapsed = undefined;

   this.paused = false;
   this.startPause = 0;
   this.totalPausedTime = 0;
};

// You can get the elapsed time while the stopwatch is running, or after it's
// stopped.

Stopwatch.prototype = {
   start: function () {
      this.startTime = +new Date();
      this.running = true;
      this.totalPausedTime = 0;
      this.startPause = 0;
   },

   stop: function () {
      if (this.paused) {
         this.unpause();
      }
      
      this.elapsed = (+new Date()) - this.startTime -
                                     this.totalPausedTime;
      this.running = false;
   },

   pause: function () {
      this.startPause = +new Date(); 
      this.paused = true;
   },

   unpause: function () {
      if (!this.paused) {
         return;
      }

      this.totalPausedTime += (+new Date()) - this.startPause; 
      this.startPause = 0;
      this.paused = false;
   },
   
   getElapsedTime: function () {
      if (this.running) {
         return (+new Date()) - this.startTime - this.totalPausedTime;
      }
      else {
        return this.elapsed;

      }
   },


   isPaused: function() {
      return this.paused;
   },

   isRunning: function() {
      return this.running;
   },

   reset: function() {
     this.elapsed = 0;
     this.startTime = +new Date();
     this.running = false;
     this.totalPausedTime = 0;
     this.startPause = 0;
   }
};

You can start, stop, pause, unpause, and reset the stopwatch object in Listing 8. You can also get its elapsed time, and you can determine whether a stopwatch is running or paused.

In the Freezing the game section of the third article in this series, I discussed how to resume a paused game exactly where it left off by accounting for the amount of time the game was paused. Like the game itself, paused stopwatches must resume exactly where they left off, so they also account for the amount of time they've been paused.

The stopwatch implementation, though simple, is of great importance because it lets you implement behaviors that last for a finite amount of time — in this case, more-natural jumping.


Refining the jump behavior

Now that I have stopwatches, I'll use them to refine the jump behavior. First, I modify the equipRunner() method from Listing 5 as shown in Listing 9:

Listing 9. Revised equipRunner() method
SnailBait.prototype = {
   ...

   this.RUNNER_JUMP_HEIGHT = 120,    // pixels
   this.RUNNER_JUMP_DURATION = 1000, // milliseconds

   equipRunnerForJumping: function () {
      this.runner.JUMP_HEIGHT = this.RUNNER_JUMP_HEIGHT;
      this.runner.JUMP_DURATION = this.RUNNER_JUMP_DURATION;

      this.runner.jumping = false;


      this.runner.ascendStopwatch  = new Stopwatch(this.runner.JUMP_DURATION/2);
      this.runner.descendStopwatch = new Stopwatch(this.runner.JUMP_DURATION/2);

      this.runner.jump = function () {
         if (this.jumping) // 'this' is the runner
            return;

         this.jumping = true;
         this.runAnimationRate = 0; // Freeze the runner while jumping
         this.verticalLaunchPosition = this.top;
         this.ascendStopwatch.start();
      };
   },
  
   equipRunner: function () {
      ...

      this.equipRunnerForJumping();
   },
   ...
};

The revised implementation of equipRunner() invokes a new method: equipRunnerForJumping(). As its name implies, it equips the runner for jumping. That method creates two stopwatches: runner.ascendStopwatch for the jump's ascent and runner.descendStopwatch for its descent.

When the jump begins, the jump() method starts the runner's ascend stopwatch, as you can see from Listing 9. That method also sets the runner's run animation rate — which determines how quickly the runner progresses through its run animation — to zero to freeze the runner while she's in the air. The run() method also records the runner's vertical position so the runner can return to that position when the jump completes.

All the runner attributes set in Listing 9 are summarized in Table 1:

Table 1. The runner's jump-related attributes
AttributeDescription
JUMP_DURATIONA constant representing the jump's duration in milliseconds: 1000.
JUMP_HEIGHTA constant representing the jump's height in pixels: 120. At the apex of her jump, the runner is 20 pixels above the next level.
ascendStopwatchA stopwatch that times the runner's ascent during a jump.
descendStopwatchA stopwatch that times the runner's descent during a jump.
jumpApexThe highest point during the runner's jump; the jump behavior uses the apex to determine how far to drop the runner for each frame during a jump's descent.
jumpingA flag that's true while the runner is jumping.
verticalLaunchPositionThe runner's position (the upper-left corner of the runner sprite) when a jump starts. The runner returns to that position at the end of a complete jump.

Next, in Listing 10, I refactor the jump behavior originally implemented in Listing 7:

Listing 10. The jump behavior, revisited
var SnailBait =  function () {
   this.jumpBehavior = {
      ...

      execute: function(sprite, context, time, fps) {
         if ( ! sprite.jumping) {
            return;
         }

         if (this.isJumpOver(sprite)) {
            sprite.jumping = false;
            return;
         }

         if (this.isAscending(sprite)) {
            if ( ! this.isDoneAscending(sprite)) this.ascend(sprite);
            else                               this.finishAscent(sprite);
         }
         else if (this.isDescending(sprite)) {
            if ( ! this.isDoneDescending(sprite)) this.descend(sprite); 
            else                                this.finishDescent(sprite);
         }
      } 
   },
   ...

The jump behavior in Listing 10 is the implementation of a high-level abstraction that leaves jumping details to other methods such as ascend() and isDescending(). Now all that remains is to fill in the details by using the runner's ascend and descend stopwatches to implement the following methods:

  • isJumpOver()
  • ascend()
  • isAscending()
  • isDoneAscending()
  • finishAscent()
  • descend()
  • isDescending()
  • isDoneDescending()
  • finishDescent()

Linear motion

For now, the methods I list above produce linear motion, meaning that the runner ascends and descends at a constant rate of speed, as depicted in Figure 4:

Figure 4. Smooth linear jump sequence
Screen capture of the Snail Bait runner's smooth jump sequence

Linear motion results in an unnatural jumping motion, because gravity should be constantly accelerating or decelerating the runner when she's descending or ascending, respectively. In the next installment I'll reimplement those methods so they result in nonlinear motion, as depicted in Figure 1. For now, I'll stick to the simpler case of linear motion.

First, Listing 11 shows the implementation of the jump behavior's isJumpOver() method, which is the same whether the motion is linear or nonlinear: A jump is over if neither stopwatch is running.

Listing 11. Determining if a jump is over
SnailBait.prototype = {
   this.jumpBehavior = {
      isJumpOver: function (sprite) {
         return !sprite.ascendStopwatch.isRunning() &&
                !sprite.descendStopwatch.isRunning();
      },
      ...
   },
   ...
};

The jump behavior's methods dealing with ascending are shown in Listing 12:

Listing 12. Ascending
SnailBait.prototype = {
   ...

   this.jumpBehavior = {
      isAscending: function (sprite) {
         return sprite.ascendStopwatch.isRunning();
      },

      ascend: function (sprite) {
         var elapsed = sprite.ascendStopwatch.getElapsedTime(),
             deltaY  = elapsed / (sprite.JUMP_DURATION/2) * sprite.JUMP_HEIGHT;

         sprite.top = sprite.verticalLaunchPosition - deltaY; // Moving up
      },

      isDoneAscending: function (sprite) {
         return sprite.ascendStopwatch.getElapsedTime() > sprite.JUMP_DURATION/2;
      },
      
      finishAscent: function (sprite) {
         sprite.jumpApex = sprite.top;
         sprite.ascendStopwatch.stop();
         sprite.descendStopwatch.start();
      }
   },
   ...
};

The methods in Listing 12 are summarized in Table 2:

Table 2. jumpBehavior's ascend methods
MethodDescription
isAscending()Returns true if the runner's ascend stopwatch is running.
ascend()Moves the runner up based on the elapsed time of the last animation frame and the jump's duration and height.
isDoneAscending()Returns true if the elapsed time on the runner's ascend stopwatch is greater than half of the jump's duration.
finishAscent()

Finishes the ascent by stopping the runner's ascend stopwatch and starting its descend stopwatch.

The jumpBehavior calls this method when the runner is at the highest point in the jump, so finishAscent() stores runner's position in the runner's jumpApex attribute. The descend() method uses that attribute.

Recall that the runner's jump() method, shown in Listing 9, starts the runner's ascend stopwatch. Subsequently, that running stopwatch causes the jump behavior's isAscending() method to return true temporarily. Until the runner is done ascending — meaning the jump is halfway over — the runner's jump behavior repeatedly calls the ascend() method, as you can see from Listing 10.

Ascending and descending

The ascend() method incrementally moves the runner higher. It calculates the number of pixels to move the runner for each animation frame by dividing the stopwatch's elapsed time (milliseconds) by one half of the jump's duration (milliseconds) and multiplying that value by the height of the jump (pixels). The milliseconds cancel each other out, yielding pixels as the unit of measure for the deltaY value. That value, therefore, represents the number of pixels to move the runner in the vertical direction for the current animation frame.

When the runner finishes her ascent, the jump behavior's finishAscent() method records the sprite's position as the jump apex, stops the ascend stopwatch, and starts the descend stopwatch.

The jump behavior methods associated with descending are shown in Listing 13:

Listing 13. Descending
SnailBait.prototype = {
   this.jumpBehavior = {
      isDescending: function (sprite) {
         return sprite.descendStopwatch.isRunning();
      },

      descend: function (sprite, verticalVelocity, fps) {
         var elapsed = sprite.descendStopwatch.getElapsedTime(),
             deltaY  = elapsed / (sprite.JUMP_DURATION/2) * sprite.JUMP_HEIGHT;

         sprite.top = sprite.jumpApex + deltaY; // Moving down
      },
      
      isDoneDescending: function (sprite) {
         return sprite.descendStopwatch.getElapsedTime() > sprite.JUMP_DURATION/2;
      },

      finishDescent: function (sprite) {
         sprite.top = sprite.verticalLaunchPosition;
         sprite.descendStopwatch.stop();
         sprite.jumping = false;
         sprite.runAnimationRate = snailBait.RUN_ANIMATION_RATE;
      }
   },
   ...
};

The methods in Listing 13 are summarized in Table 3:

Table 3. jumpBehavior's descend methods
AttributeDescription
isDescending()Returns true if the runner's descend stopwatch is running.
descend()Moves the runner down based on the elapsed time of the last animation frame and the jump's duration and height.
isDoneDescending()Returns true if the runner has fallen below its prejump position.
finishDescent()

Stops the descent, and the jump, by stopping the runner's descend stopwatch and setting the runner's jumping flag to false, respectively.

After descending, the runner might not be at exactly the same height as it was when the jump began, so finishDescent() sets the runner's position to that prejump vertical location.

Finally, finishDescent() sets the runner's animation rate to its normal value, which causes the runner to commence running.

There's a lot of symmetry between the ascend methods in Listing 12 and the descend methods in Listing 13. Both ascend() and descend() calculate the number of pixels to move the runner in the vertical direction for the current frame in exactly the same manner. The descend() method, however, adds that value to the jump's apex, whereas ascend() subtracts it from the launch position. (Recall that the Canvas Y axis increases from top to bottom.)

When the jump's descent is finished, finishDescent() puts the runner back at the same vertical position where she began the jump and restarts her run animation.


Next time

In the next article in this series, I'll show you how to implement nonlinear motion to produce the realistic jumping motion shown in Figure 1. Along the way, I'll show you how to warp time itself so you can produce nonlinear effects for any other derivative of time, such as color change. See you next time.


Download

DescriptionNameSize
Sample codej-html5-game6.zip1.2MB

Resources

Learn

Get products and technologies

  • Replica Island: You can download the source for this popular open source platform video game for Android. Most of Snail Bait's sprites are from Replica Island (used with permission).

Discuss

  • Get involved in the developerWorks community. Connect with other developerWorks users while exploring the developer-driven blogs, forums, groups, and wikis.

Comments

developerWorks: Sign in

Required fields are indicated with an asterisk (*).


Need an IBM ID?
Forgot your IBM ID?


Forgot your password?
Change your password

By clicking Submit, you agree to the developerWorks terms of use.

 


The first time you sign into developerWorks, a profile is created for you. Information in your profile (your name, country/region, and company name) is displayed to the public and will accompany any content you post, unless you opt to hide your company name. You may update your IBM account at any time.

All information submitted is secure.

Choose your display name



The first time you sign in to developerWorks, a profile is created for you, so you need to choose a display name. Your display name accompanies the content you post on developerWorks.

Please choose a display name between 3-31 characters. Your display name must be unique in the developerWorks community and should not be your email address for privacy reasons.

Required fields are indicated with an asterisk (*).

(Must be between 3 – 31 characters.)

By clicking Submit, you agree to the developerWorks terms of use.

 


All information submitted is secure.

Dig deeper into Java technology on developerWorks


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=1
Zone=Java technology, Web development
ArticleID=856980
ArticleTitle=HTML5 2D game development: Manipulating time, Part 1
publish-date=02052013