ShiftEleven

Mouse Clicks for HTML5 Cavas Entities via Paths

The canvas element in HTML5 is exciting because it really opens the doors to games, drawing, and animation. This is especially exciting as browsers hardware accelerate canvas and improve its performance. What isn't so exciting though is that you do have to do a lot more for yourself.

So if you draw something into the canvas, you don't get to attach event listeners on the objects you create within the canvas, just on the canvas itself. This means that you have to build your own event system. This isn't that difficult if you organize your entities you're drawing and rely on isPointInPath to do the searching for you.

Overview

When you use path methods, like arc, bezierCurveTo, or lineTo, the 2D context keeps track of things really nicely for you. After you add your path, you can ask the context whether or not a point is within the area of said path via the isPointInPath method.

The way to find the object is to render each entity in reverse order, draw the path, and then check to see isPointInPath. The reason to go in reverse order is because the most visible, unobstructed object is at the last index in the rendering order.

Paths are great because they are fast, but they are pretty plain. What you if you want to draw images? Well there's no rule that you can only use one canvas. This means that you can use one canvas to draw your main stage with images, and then use the light-weight paths to draw the hit detection area.

The demo will draw the earth and the moon and will print a the name of the selected planet.

The Planet Entity Class

Since we want to manage two different rendering passes, our entity class will have two methods. One to draw the visible image and the other to render the hit detection path.

/**
 * Planet.
 *
 * A planet entity to draw on a canvas.
 */
Planet = function(image, opt_x, opt_y) {
  this.image_ = image;
  this.x_ = opt_x || 0;
  this.y_ = opt_y || 0;
};
/**
 * Draw the planet.
 */
Planet.prototype.draw = function(ctx) {
  ctx.drawImage(this.image_, this.x_, this.y_);
};
/**
 * Draw the hit detection path.
 */
Planet.prototype.drawDetectionPath = function(ctx) {
  ctx.beginPath();
  var radius = this.image_.width / 2;
  ctx.arc(this.x_+radius, this.y_+radius, radius, 0, Math.PI*2, true);
  ctx.closePath();
};

The drawDetectionPath method draws a circle, which is a pretty good hit detection area for a planet. One thing to note, the path has no fill style nor does it call fill. This is because we're not making it visible, we just want the path information.

The Entity Manager

The entity manager is pretty simple too. The entity manager keeps a list of all the planets to draw. It also performs the logic of trying to identify the entity that exists at an X,Y position in the canvas.

/**
 * Entities Manager.
 *
 * Manage a set of entities to draw them to the screen and to
 * detect mouse hits.
 */
EntitiesManager = function(entities, rendererCtx, detectionCtx) {
  this.entities_ = entities;
  this.rendererCtx_ = rendererCtx;
  this.detectionCtx_ = detectionCtx;
};
EntitiesManager.prototype.draw = function() {
  this.rendererCtx_.clearRect(0, 0, this.rendererCtx_.canvas.width,
                              this.rendererCtx_.canvas.height);
  for (var i = 0; i < this.entities_.length; i++) {
    this.entities_[i].draw(this.rendererCtx_);
  }
};
EntitiesManager.prototype.findEntity = function(x, y) {
  // Clear out the detection canvas.
  this.detectionCtx_.clearRect(0, 0, this.detectionCtx_.canvas.width,
                               this.detectionCtx_.canvas.height);
  // Loop backwards though each entity. After drawing it onto
  // the detection canvas, see if X,Y coordinates from the mouse are
  // within the path drawn onto the canvas.
  for (var i = this.entities_.length-1; i >= 0; i--) {
    var entity = this.entities_[i];
    entity.drawDetectionPath(this.detectionCtx_);
    if (this.detectionCtx_.isPointInPath(x, y)) {
      return entity;
    }
  }
  return null;
};

To use this class, simply make a new instance of this and pass along an array of planets and the 2 2D canvas context objects. To draw or refresh the display, simply call draw. After you receive a click event on the render canvas, you can pass in the X,Y coordinates into findEntity to see if there are any objects underneath that position. That being said, there's a little massaging that needs to happen on the coordinates from the mouse click.

Getting the Mouse Position

When you listen for the click event on the canvas object, the callback will receive an Event object which will have the X,Y position of the coordinates of where the click happened. The problem with just using those X,Y coordinates are that they aren't in the same coordinate space as the coordinate space of the canvas context. Awesomely enough, it's easy to rectify. You need to get the coordinates of the mouse click and then subtract the coordinates of top,left point of the canvas element.

canvasElement.addEventListener('click', function(evt) {
  var rect = renderer.getBoundingClientRect();
  var x = evt.clientX - rect.left;
  var y = evt.clientY - rect.top;

  var entity = entitiesManager.findEntity(x, y);
  // ...
});

About The Demo

The basics of this code are viewable at shifteleven.com/demo/canvas-hit-detection/. While it is pretty much the same code that's in this blog post, there are a couple of interesting bits to discus.

Image Manager

I'm using images for the earth and the moon. So before I can start drawing with those images, I need to make sure that they are downloaded first. To that end, I use an ImageManager class to download everything and fire a callback when it's finished. It's not terribly robust, but it gets the job done for the demo.

Transform Decorator

To show the performance impact of how many planets are drawn onto the canvas, I needed to draw them in random places and to scale them at different sizes. Rather than adding the transform properties into the Planet entity class, I used a decorator to do that dirty work for me. I also only end up making 2 planet instances (one for the earth, the other the moon).

Performance

Take it easy before trying to render 100,000 planets onto the canvas as this could take a while. That being said, once you do render all of the planets, notice how fast it is for it to actually find the planet! Paths are pretty fast!

Comments

comments powered by Disqus