For those of you just tuning it, I have been working on a project to implement a tile based gamed called Bananagrams using HTML5, specifically using the canvas element and JavaScript. In my previous two posts (here and here) we discussed how to drag and drop a square tile on the canvas, how to make the tiles line up automatically so the user doesn’t have to use pinpoint accuracy, and how to make sure tiles don’t get stacked on top of one another. We have come pretty far in our development of the UI but if you try playing the game, you will find it is a bit tedious to have to drag and drop every single tile into place one at a time. In this post, we want to give the user the ability to select multiple tiles and drag and drop them all at once, as shown below.
Drawing a Dynamic Rectangle
Selecting multiple tiles can be done in a few different ways but the most intuitive way to me is to let the user draw a rectangle around the tiles they want to drag. We will assume that if the user doesn’t click directly on a tile, they are attempting to draw a rectangle and if they click on a tile, they are attempting to drag that tile or group of tiles to another location. Using this methodology we will need to add some more logic to our mouseDown, mouseMove, mouseUp, and draw functions. Lets deal with the mouseDown function first.
mouseDown Function
If we look at our current mouseDown function, the first part of the code below, as defined in part 2 we see that it loops through the tiles currently on the canvas and determines whether or not the user has clicked on one of them. We see on line 28 below that if we detect that a tile was clicked, we return and exit the function. On the other hand, if no tiles were selected, we exit the loop and continue on in the function. If we get to line 33 in the code below we know that the user has not clicked on a tile and we can assume they want to draw a rectangle.
function mouseDown(e) { // Get the current mouse coordinates getMouse(e); // Indicate that the user is not dragging any tiles isDragging = false; // Check to see if the user as clicked a tile for (var i = 0; i < tilesInPlay.length; i++) { var tile = tilesInPlay[i]; // Calculate the left, right, top and bottom // bounds of the current tile var left = tile.x; var right = tile.x + TILE_TOTAL_WIDTH; var top = tile.y; var bottom = tile.y + TILE_TOTAL_HEIGHT; // Determine if the tile was clicked if (mouseX >= left && mouseX <= right && mouseY >= top && mouseY <= bottom) { // Indicate that the current tile is selected tilesInPlay[i].selected = true; isDragging = true; // Wire up the onmousemove event to handle the dragging document.onmousemove = mouseMove; needsRedraw(); return; } } // No tiles were clicked, make sure all tiles are not selected clearSelectedTiles(); }
The first thing that we need to do is setup some global variables to help us when drawing the rectangle
var isSelecting = false; // Indicates whether or not the user is drawing a selection rectangle var selectionStartX; // Stores the x coordinate of where the user started drawing the selection rectangle var selectionStartY; // Stores the y coordinate of where the user started drawing the selection rectangle var selectionEndX; // Stores the x coordinate of the end of the current selection rectangle var selectionEndY; // Stores the y coordinate of the end of the current selection rectangle
The first is a boolean variable that will let us know in the draw function whether or not the user is drawing a selection rectangle. The next four variables will store the starting and ending coordinates of the user drawn rectangle. Now lets update our mouseDown event to use the above variables.
function mouseDown(e) { // Code to determine if a tile has been clicked as defined in part 2 ... // ***** START NEW CODE ***** // Indicate that the user is drawing a selection rectangle and // update the selection rectangle start and edit coordinates isSelecting = true; selectionStartX = mouseX; selectionStartY = mouseY; selectionEndX = mouseX; selectionEndY = mouseY; // Wire up the onmousemove event so we can dynamically draw the rectangle document.onmousemove = mouseMove; needsRedraw(); // ***** END NEW CODE ***** }
Add the above code to the end of the mouseDown event. If we get to this point, as mentioned before, the user didn’t click a tile and we are going to indicate that the user is drawing a selection rectangle and set the starting and ending coordinates of the rectangle to the current mouse coordinates.
mouseMove Function
In the mouseMove function we now need to update the ending coordinates of our selection rectangle if one is being created.
function mouseMove(e) { // Code to update the position of the selected tiles as defined in part two here ... // Update the end coordinates of the selection rectangle if (isSelecting) { getMouse(e); selectionEndX = mouseX; selectionEndY = mouseY; needsRedraw(); } }
Add the above code to the end of the mouseMove event after the code we already defined to update the coordinates of the selected tiles.
draw Function
In the draw function, we now not only need to draw the tiles on the canvas but as well as a rectangle when the user is trying to select multiple tiles. Add the code on lines 20 – 23 to the draw function as shown below. I have included the entire draw function to see where to place the code.
// Draw the various objects on the canvas function draw() { // Only draw the canvas if it is not valid if (redrawCanvas) { clear(ctx); // draw the unselected tiles first so they appear under the selected tiles for (var i = 0; i < tilesInPlay.length; i++) { if (!tilesInPlay[i].selected) drawTile(ctx, tilesInPlay[i]); } // now draw the selected tiles so they appear on top of the unselected tiles for (var i = 0; i < tilesInPlay.length; i++) { if (tilesInPlay[i].selected) drawTile(ctx, tilesInPlay[i]); } // ***** START NEW CODE ***** // If the user is drawing a selection rectangle, draw it if (isSelecting) { drawSelectionRectangle(ctx); } // ***** END NEW CODE ***** // Indicate that the canvas no longer needs to be redrawn redrawCanvas = false; } }
As you can see on lines 20 – 23 we are testing to see if the user is drawing a selection rectangle and if they are, we are calling the drawSelectionRectangle function to draw it. Below is the drawSelectionRectangle function.
// Draws the selection rectangle function drawSelectionRectangle(context) { context.strokeStyle = TILE_STROKE; // Figure out the top left corner of the rectangle var x = Math.min(selectionStartX, selectionEndX); var y = Math.min(selectionStartY, selectionEndY); // Calculate the width and height of the rectangle var width = Math.abs(selectionEndX - selectionStartX); var height = Math.abs(selectionEndY - selectionStartY); // Draw the rectangle context.strokeRect(x, y, width, height); }
To draw a rectangle on an HTML5 canvas element, you first specify the border color by setting the context.strokeStyle property. If you wanted a fill color you could set the context.fillStyle as well but for our case, we only want to draw the border so we won’t set this property. Then, we call the strokeRect function on the 2d context passing in the top left x and y coordinates of the rectangle and the desired width and height.
My initial code just set the variables x and y to selectionStartX and selectionStartY respectively and that works great if the user always draws their selection rectangle by dragging their mouse down and right. But, if they draw their rectangle starting at the bottom right corner of the canvas and drag up and left, your top left coordinates will be incorrect. To remedy this we take the minimum x and y coordinates between the stored start and end values. This will guarantee that our x and y are at the top left of the rectangle.
For the same reason mentioned above, we need to take the absolute value of the difference between the start and end coordinates to figure out the width and the height. We then call context.strokeRect passing in the calculated values.
mouseUp Function
The last part of drawing the rectangle is to get it to disappear when the user lets go of the mouse. Here is our updated mouseUp function to do this.
function mouseUp(e) { // Code to drop the tile in the closest slot as defined in part two ... // Deselect all tiles clearSelectedTiles(); // ***** START NEW CODE ***** if (isSelecting) { // Reset the selection rectangle isSelecting = false; selectionStartX = 0; selectionStartY = 0; selectionEndX = 0; selectionEndY = 0; } // ***** END NEW CODE ***** needsRedraw(); }
Add the code on lines 9 – 16 to the end of the mouseUp event. Here we set the isSelecting variable to false and reset the selection rectangle coordinates if the user was drawing a selection rectangle. On the next call to the draw function, isSelecting will be false and thus the rectangle will not be drawn.
With the added code, the user can now drag a rectangle on the canvas that will resize according to the current position of the mouse. The next step is to actually select the tiles found within the rectangle after the user draws it.
Selecting the Tiles in the Rectangle
If the user is drawing a selection rectangle, when they release their mouse, we need to implement some logic to select all the tiles within the drawn rectangle. Lets add a function called selectTilesInRectangle that will carry out this logic.
// Selects all the tiles is in the user dragged rectangle function selectTilesInRectangle() { // Get the bounds of the drawn rectangle var selectionTop = Math.min(selectionStartY, selectionEndY); var selectionBottom = Math.max(selectionStartY, selectionEndY); var selectionLeft = Math.min(selectionStartX, selectionEndX); var selectionRight = Math.max(selectionStartX, selectionEndX); // Loop through all the tiles and select the tile if it lies within the // bounds of the rectangle for (var i = 0; i < tilesInPlay.length; i++) { var tile = tilesInPlay[i]; var tileTop = tile.y; var tileBottom = tile.y + TILE_TOTAL_HEIGHT; var tileLeft = tile.x; var tileRight = tile.x + TILE_TOTAL_WIDTH; tile.selected = (tileTop >= selectionTop && tileBottom <= selectionBottom && tileLeft >= selectionLeft && tileRight <= selectionRight); } }
In the first part of the function we use the same logic we used in the mouseMove function to get the bounds of the rectangle. Once we have those, we loop through each of the tiles on the canvas and if the tile is completely within the bounds of the rectangle, we set the selected property to true.
Now all we have to do is make a call to this selectTilesInRectangle in the mouseUp function if the user was drawing a selection rectangle.
function mouseUp(e) { // Code to drop the tile into the closest slot as defined in part 2 ... if (isSelecting) { // ***** START NEW CODE ***** // Mark the tiles in the drawn rectangle as selected selectTilesInRectangle(); // ***** END NEW CODE ***** // Reset the selection rectangle isSelecting = false; selectionStartX = 0; selectionStartY = 0; selectionEndX = 0; selectionEndY = 0; } needsRedraw(); }
Since we implemented our mouseMove and mouseUp functions to loop through all the tiles and perform actions on any tile that we selected, our code for selecting and dragging multiple tiles is done! The functionality as shown in the picture at the beginning of this post is now completed.
New Overlapping Problem
Unfortunately, if you select multiple tiles and drag your mouse off of the canvas, you will see a problem that we have created. Depending on which tile you click on to drag, they might begin to overlap as you mouse moves off the canvas as shown in the picture below.
In the example above, we clicked on the ‘B’ tile to drag the selected group. Dragging is fine until we reach the right side of the canvas. When the ‘K’ tile hits the right side of the canvas, it stops as that is how we have written the code. But, the ‘B’ and ‘O’ tiles do not stop as they have not hit the side of the canvas and thus they get pushed under the ‘K’. In order to remedy this problem, we need to figure out the extremes of the selected group, the top, right, left, and bottommost tiles, and if any of those extreme tiles hit their corresponding borders of the canvas, we need to stop the entire group from moving further.
getExtremeTiles Function
First we need to add a few global variables to store the extreme tiles of the selected group.
var topmostTile; // Stores the topmost tile in the selected group var bottommostTile; // Stores the bottommost tile in the selected group var leftmostTile; // Stores the leftmost tile in the selected group var rightmostTile; // Stores the rightmost tile in the selected group
Then we need to add the getExtremeTiles function.
// Finds the top, bottom, left, and rightmost tiles of the selected group function getExtremeTiles() { for (var i = 0; i < tilesInPlay.length; i++) { var tile = tilesInPlay[i]; if (tile.selected) { if (topmostTile == null || tile.y < topmostTile.y) topmostTile = tile; if (bottommostTile == null || tile.y > bottommostTile.y) bottommostTile = tile; if (leftmostTile == null || tile.x < leftmostTile.x) leftmostTile = tile; if (rightmostTile == null || tile.x > rightmostTile.x) rightmostTile = tile; } } }
This function loops through all the tiles and if the tile is selected, it checks to see if it is the top, bottom, left, or rightmost tile that has been seen. By the end of the loop, we will have found the extreme tiles of the selected group.
Now we need to add a call to this function at the end of the selectTilesInRectangle function.
// Selects all the tiles is in the user dragged rectangle function selectTilesInRectangle() { // Same code as defined above ... // Get the top, bottom, left, and rightmost tiles getExtremeTiles(); }
Modified mouseMove Function
Now that we have references to the top, bottom, left, and rightmost tiles of the selected group, we can modify the mouseMove function to ensure that no overlapping occurs when one of the extreme tiles reaches the border of the canvas. If you followed along with part two of this project you may remember that when determining whether or not to move the tile in the mouseMove function we checked to see if the tile was within the bounds of the canvas and if it was we move it, otherwise we didn’t. Now, instead of just checking the current tile, we will use our extreme tiles so that if one of the extreme tiles hits the bounds of the canvas, all selected tiles will no longer be moved in that direction.
function mouseMove(e) { // If the user is dragging a tile if (isDragging) { getMouse(e); for (var i = 0; i < tilesInPlay.length; i++) { var tile = tilesInPlay[i]; // Only if the tile is selected do we want to drag it if (tile.selected) { // Only move tiles to the right or left if the mouse is between the left and // right bounds of the canvas if (mouseX < CANVAS_RIGHT && mouseX > CANVAS_LEFT) { // Move the tile if the rightmost or leftmost tile of the group is not off the canvas // or if the mouse was off the canvas on the left or right side before but has now // come back onto the canvas if ((rightmostTile.x + TILE_TOTAL_WIDTH <= WIDTH && leftmostTile.x >= 0) || offX) { tile.x = tile.x + changeInX; } } // Only move tiles up or down if the mouse is between the top and bottom // bounds of the canvas if (mouseY < CANVAS_BOTTOM && mouseY > CANVAS_TOP) { // Move the tile if the topmost or bottommost tile of the group is not off the canvas and the // or if the mouse was off the canvas on the top or bottom side before but has now // come back onto the canvas if ((topmostTile.y >= 0 && bottommostTile.y + TILE_TOTAL_HEIGHT <= HEIGHT) || offY) { tile.y = tile.y + changeInY; } } } } // Update the variables indicating whether or not the mouse in on the canvas offX = (mouseX > CANVAS_RIGHT || mouseX < CANVAS_LEFT) offY = (mouseY > CANVAS_BOTTOM || mouseY < CANVAS_TOP) needsRedraw(); } // Update the end coordinates of the selection rectangle if (isSelecting) { getMouse(e); selectionEndX = mouseX; selectionEndY = mouseY; needsRedraw(); } }
I have shown the entire function here but the only lines we changed were lines 19 and 31 where we replaced the tile variable with the rightmostTile, lefmostTile, topmostTile, and bottommostTile variables.
With the above code there is an occasional bug that shows up when you select multiple tiles and attempt to drag them off of the canvas. Due to the fact that the canvas element isn’t redrawing every time the mouse moves one pixel, depending on how fast you move the mouse while dragging the tiles sometimes the tiles will get stuck on the border of the canvas.
Another problem with the above implementation is that if you are dragging a line of say four tiles and you click on the farthest left tile and drag your mouse off the right border of the canvas, the moment your mouse enters the canvas again, it starts to drag the tiles instead of waiting till you get to the tile you clicked on originally as shown in the figure below (note the first image the mouse is moving to the right and the second the mouse is moving to the left).
What we want is if the user drags their mouse off of the canvas, when their mouse re-enters the canvas, the tiles won’t move until it reaches the tile the user clicked on to drag the entire group. The figure below describes the desired behavior (note the mouse is moving to the right.
In the picture, the ‘T’ tile was selected when dragging the group and when the sequence hits the right border, we no longer want to move the group to the right. Further, when the mouse comes back onto the canvas, we don’t want the group to move until the mouse reaches the x coordinate it was at when the group hit the border. The blue dashed line shows the x coordinate of the mouse when the group hits the right border and the green line marks the area we no longer want to move the group. To accomplish this we need to store the coordinates of the mouse when the group hits any of the borders of the canvas. To do this, we will create four global variables, one for each border.
var offRightX; // Stores the x cooridnate of the mouse when a tile hits the right border var offLeftX; // Stores the x cooridnate of the mouse when a tile hits the left border var offTopY; // Stores the y cooridnate of the mouse when a tile hits the top border var offBottomY; // Stores the y cooridnate of the mouse when a tile hits the bottom border
Then we will initialize the variables in our init function to be coordinates that lie off of the canvas so as to not interfere with the moving of the tiles when the canvas is first rendered.
function init() { // Setup the global variables TILE_TOTAL_WIDTH = TILE_WIDTH + TILE_RADIUS; TILE_TOTAL_HEIGHT = TILE_HEIGHT + TILE_RADIUS; canvas = document.getElementById('canvas'); HEIGHT = canvas.height; WIDTH = canvas.width; ctx = canvas.getContext('2d'); // Set the values of the x and y mouse coordinates used when // a tile hits the border to values outside of the canvas offRightX = -1; offLeftX = WIDTH + 1; offTopY = HEIGHT + 1; offBottomY = -1; ... // The rest of the init function code as defined in part 2 }
Now we need to update our mouseMove function to take into account the stored coordinates when the selected tiles hit the borders of the canvas.
function mouseMove(e) { // If the user is dragging a tile if (isDragging) { getMouse(e); for (var i = 0; i < tilesInPlay.length; i++) { var tile = tilesInPlay[i]; // Only if the tile is selected do we want to drag it if (tile.selected) { // Only move tiles to the right or left if the mouse is between the left and // right bounds of the canvas if (mouseX < CANVAS_RIGHT && mouseX > CANVAS_LEFT) { // Move the tile if the rightmost or leftmost tile of the group is not off the canvas // or if the the right or leftmost tiles hit the one of the borders previously and the // mouse X coordinate has now passed the stored X coordinate when the tile hit the border if ((rightmostTile.x + TILE_TOTAL_WIDTH <= WIDTH && leftmostTile.x >= 0) || (mouseX <= offRightX) || (mouseX >= offLeftX)) { tile.x = tile.x + changeInX; } } // Only move tiles up or down if the mouse is between the top and bottom // bounds of the canvas if (mouseY < CANVAS_BOTTOM && mouseY > CANVAS_TOP) { // Move the tile if the topmost or bottommost tile of the group is not off the canvas and the // or if the the top or bottommost tiles hit the one of the borders previously and the // mouse Y coordinate has now passed the stored Y coordinate when the tile hit the border if ((topmostTile.y >= 0 && bottommostTile.y + TILE_TOTAL_HEIGHT <= HEIGHT) || (mouseY <= offBottomY) || (mouseY >= offTopY)) { tile.y = tile.y + changeInY; } } } } // If offRightX is less than zero, meaning that the rightmostTile has not // hit the right border of the canvas since our last mouseMove call, and the // now the rightmostTile has hit the right border, set the offRightX variable // to the current X coordinate of the mouse. This will be used on the next // call to mouseMove to ensure the tiles are not dragged off the canvas. // Otherwise set offRightX to -1 indicating that the tiles are not being // dragged off the canvas. if (offRightX < 0 && (rightmostTile.x + TILE_TOTAL_WIDTH) >= WIDTH) offRightX = mouseX; else if (mouseX <= offRightX) offRightX = -1; // Same as above but for left border if (offLeftX > WIDTH && (leftmostTile.x <= 0)) offLeftX = mouseX; else if (mouseX >= offLeftX) offLeftX = WIDTH + 1; // Same as above but for bottom border if (offBottomY < 0 && (bottommostTile.y + TILE_TOTAL_HEIGHT) >= HEIGHT) offBottomY = mouseY; else if (mouseY <= offBottomY) offBottomY = -1; // Same as above but for top border if (offTopY > HEIGHT && (topmostTile.y <= 0)) offTopY = mouseY; else if (mouseY >= offTopY) offTopY = HEIGHT + 1; needsRedraw(); } // Update the end coordinates of the selection rectangle if (isSelecting) { getMouse(e); selectionEndX = mouseX; selectionEndY = mouseY; needsRedraw(); } }
The changes are found on lines 19 and 31 again. If the program gets to these lines of code then it means the mouse is on the canvas. The extra conditions we added just say that if the current X or Y mouse coordinate have moved past the X or Y coordinate we stored when the tiles hit the border of the canvas, then start moving the tiles. If the mouse hasn’t hit that coordinate yet, or it is in the area marked by the green line in the image above, then don’t move the tiles.
In lines 45 to 66 we update the coordinates we store if the tiles have hit one of the borders.
Unfortunately it requires quite a bit of code to remedy this problem but I don’t see any other way to do it as you have to store the coordinates for each border. If the described problems are not something that bothers you then you can remove the above added code from the mouseMove function and just stick with what we had before.
Modified mouseDown Function
Now that we are using the extreme tile references in our mouseMove function, if the user selects and drags a single tile, we need to set that tile to be the top, bottom, left, and rightmost tiles. To do this, add lines 8 – 11 in the code below to the mouseDown function
function mouseDown(e) { ... // Determine if the tile was clicked if (mouseX >= left && mouseX <= right && mouseY >= top && mouseY <= bottom) { // ***** START NEW CODE // If the user selected a tile that was not selected before, // clear all selected tiles if (!tilesInPlay[i].selected) { clearSelectedTiles(); topmostTile = bottommostTile = leftmostTile = rightmostTile = tilesInPlay[i]; } // ***** END NEW CODE ***** // Indicate that the current tile is selected tilesInPlay[i].selected = true; isDragging = true; ... } ... }
Lastly, we need to clear the extreme tile variables whenever we clear the selected tiles. Add the following code to the clearSelectedTiles function.
// Sets the tile.selected property to false for // all tiles in play and clears all extreme tiles function clearSelectedTiles() { for (var i = 0; i < tilesInPlay.length; i++) { tilesInPlay[i].selected = false; } // ***** START NEW CODE ***** // Clear the exterme tiles topmostTile = null; bottommostTile = null; leftmostTile = null; rightmostTile = null; // ***** END NEW CODE ***** }
To recap, we have now added the ability for the user to select multiple tiles by dragging a dynamically drawn rectangle around the desired tiles and we have also prevented the tiles from stacking on top of each other when dragging multiple tiles.
he entire source code for the project up to this point can be downloaded here.