Building Games with HTML5 and Dart

0
1276
19 min read

In this article written by Ivo Balbaert, author of the book Learning Dart – Second Edition, you will learn to create a well-known memory game. Also, you will design a model first and work up your way from a modest beginning to a completely functional game, step by step. You will also learn how to enhance the attractiveness of web games with audio and video techniques. The following topics will be covered in this article:

  • The model for the memory game
  • Spiral 1—drawing the board
  • Spiral 2—drawing cells
  • Spiral 3—coloring the cells
  • Spiral 4—implementing the rules
  • Spiral 5—game logic (bringing in the time element)
  • Spiral 6—some finishing touches
  • Spiral 7—using images

(For more resources related to this topic, see here.)

The model for the memory game

When started, the game presents a board with square cells. Every cell hides an image that can be seen by clicking on the cell, but this disappears quickly. You must remember where the images are, because they come in pairs. If you quickly click on two cells that hide the same picture, the cells will “flip over” and the pictures will stay visible. The objective of the game is to turn over all the pairs of matching images in a very short time.

After some thinking we came up with the following model, which describes the data handled by the application. In our game, we have a number of pictures, which could belong to a Catalog. For example, a travel catalog with a collection of photos from our trips or something similar. Furthermore, we have a collection of cells and each cell is hiding a picture. Also, we have a structure that we will call memory, and this contains the cells in a grid of rows and columns. We could draw it up as shown in the following figure. You can import the model from the game_memory_json.txt file that contains its JSON representation:


Learning Dart - Second Edition

A conceptual model of the memory game

The Catalog ID is its name, which is mandatory, but the description is optional. The Picture ID consists of the sequence number within the Catalog. The imageUri field stores the location of the image file. width and height are optional properties, since they may be derived from the image file. The size may be small, medium, or large to help select an image. The ID of a Memory is its name within the Catalog, the collection of cells is determined by the memory length, for example, 4 cells per side. Each cell is of the same length cellLength, which is a property of the memory. A memory is recalled when all the image pairs are discovered. Some statistics must be kept, such as recall count, the best recall time in seconds, and the number of cell clicks to recover the whole image (minTryCount). The Cell has the row and column coordinates and also the coordinates of its twin with the same image. Once the model is discussed and improved, model views may be created: a Board would be a view of the Memory concept and a Box would be a view of the Cell concept. The application would be based on the Catalog concept. If there is no need to browse photos of a catalog and display them within a page, there would not be a corresponding view. Now, we can start developing this game from scratch.

Spiral 1 – drawing the board

The app starts with main() in educ_memory_game.dart:

library memory;

import 'dart:html';

part 'board.dart';

void main() {
  // Get a reference to the canvas.
  CanvasElement canvas = querySelector('#canvas');          (1)
  new Board(canvas);                                        (2)
}

We’ll draw a board on a canvas element. So, we need a reference that is given in line (1). The Board view is represented in code as its own Board class in the board.dart file. Since everything happens on this board, we construct its object with canvas as an argument (line (2)). Our game board will be periodically drawn as a rectangle in line (4) by using the animationFrame method from the Window class in line (3):

part of memory;

class Board {

  CanvasElement canvas;
  CanvasRenderingContext2D context;
  num width, height;

  Board(this.canvas) {
    context = canvas.getContext('2d');
    width = canvas.width;
    height = canvas.height;
    window.animationFrame.then(gameLoop);                   (3)
  }

  void gameLoop(num delta) {
    draw();
    window.animationFrame.then(gameLoop);
  }

  void draw() {
    clear();
    border();
  }

  void clear() {
    context.clearRect(0, 0, width, height);
  }

  void border() {
    context..rect(0, 0, width, height)..stroke();             (4)
  }
}

This is our first result:

 Learning Dart - Second Edition

The game board

Spiral 2 – drawing cells

In this spiral, we will give our app code some structure: Board is a view, so board.dart is moved to the view folder. We will also introduce here the Memory class from our model in its own code memory.dart file in the model folder. So, we will have to change the part statements to the following:

part 'model/memory.dart';
part 'view/board.dart';

The Board view needs to know about Memory. So, we will include it in the Board class and make its object in the Board constructor:

new Board(canvas, new Memory(4));

The Memory class is still very rudimentary with only its length property:

class Memory {
  num length;
  Memory(this.length);
}

Our Board class now also needs a method to draw the lines, which we decided to make private because it is specific to Board, as well as the clear() and border()methods:

void draw() {
    _clear();
    _border();
    _lines();
}

The lines method is quite straightforward; first draw it on a piece of paper and translate it to code using moveTo and lineTo. Remember that x goes from top-left to right and y goes from top-left to bottom:

void _lines() {
    var gap = height / memory.length;
    var x, y;
    for (var i = 1; i < memory.length; i++) {
      x = gap * i;
      y = x;
      context
        ..moveTo(x, 0)
        ..lineTo(x, height)
        ..moveTo(0, y)
        ..lineTo(width, y);    
    }
}

The result is a nice grid:

Learning Dart - Second Edition

Board with cells

Spiral 3 – coloring the cells

To simplify, we will start using colors instead of pictures to be shown in the grid. Up until now, we didn’t implement the cell from the model. Let’s do that in modelcell.dart. We start simple by saying that the Cell class has the row, column, and color properties, and it belongs to a Memory object passed in its constructor:

class Cell {
  int row, column;
  String color;
  Memory memory;
  Cell(this.memory, this.row, this.column);
}

Because we need a collection of cells, it is a good idea to make a Cells class, which contains List. We give it an add method and also an iterator so that we are able to use a for…in statement to loop over the collection:

class Cells {
  List _list;

  Cells() {
    _list = new List();
  }

  void add(Cell cell) {
    _list.add(cell);
  }

  Iterator get iterator => _list.iterator;
}

We will need colors that are randomly assigned to the cells. We will also need some utility variables and methods that do not specifically belong to the model and don’t need a class. Hence, we will code them in a folder called util. To specify the colors for the cells, we will use two utility variables: a List variable of colors (colorList), which has the name colors, and a colorMap variable that maps the names to their RGB values. Refer to utilcolor.dart; later on, we can choose some fancier colors:

var colorList = ['black', 'blue', //other colors  ];
var colorMap = {'black': '#000000', 'blue': '#0000ff', //... };

To generate (pseudo) random values (ints, doubles, or Booleans), Dart has the Random class from dart:math. We will use the nextInt method, which takes an integer (the maximum value) and returns a positive random integer in the range from 0 (inclusive) to max (exclusive). We will build upon this in utilrandom.dart to make methods that give us a random color:

int randomInt(int max) => new Random().nextInt(max);
randomListElement(List list) => list[randomInt(list.length - 1)];
String randomColor() => randomListElement(colorList);
String randomColorCode() => colorMap[randomColor()];

Our Memory class now contains an instance of the Cells class:

Cells cells;

We build this in the Memory constructor in a nested for loop, where each cell is successively instantiated with a row and column, given a random color, and added to cells:

Memory(this.length) {
    cells = new Cells();
    var cell;
    for (var x = 0; x < length; x++) {
      for (var y = 0; y < length; y++) {
        cell = new Cell(this, x, y);
        cell.color = randomColor();
        cells.add(cell);
      }
    }
  }

We can draw a rectangle and fill it with a color at the same time. So, we realize that we don’t need to draw lines as we did in the previous spiral! The _boxes method is called from the draw animation: with a for…in statement, we loop over the collection of cells and call the _colorBox method that will draw and color the cell for each cell:

void _boxes() {
    for (Cell cell in memory.cells) {
      _colorBox(cell);
    }
}

void _colorBox(Cell cell) {
    var gap = height / memory.length;
    var x = cell.row * gap;
    var y = cell.column * gap;
    context
      ..beginPath()
      ..fillStyle = colorMap[cell.color]
      ..rect(x, y, gap, gap)
      ..fill()
      ..stroke()
      ..closePath();
}

Learning Dart - Second Edition

Spiral 4 – implementing the rules

However, wait! Our game can only work if the same color appears in only two cells: a cell and its twin cell. Moreover, a cell can be hidden or not: the color can be seen or not? To take care of this, the Cell class gets two new attributes:

Cell twin;
  bool hidden = true;

The _colorBox method in the Board class can now show the color of the cell when hidden is false (line (2)); when hidden = true (the default state), a neutral gray color will be used for the cell (line (1)):

static const String COLOR_CODE = '#f0f0f0';

We also gave the gap variable a better name, boxSize:

void _colorBox(Cell cell) {
    var x = cell.column * boxSize;
    var y = cell.row * boxSize;
    context.beginPath();
    if (cell.hidden) {
      context.fillStyle = COLOR_CODE;                     (1)
    } else {
      context.fillStyle = colorMap[cell.color];           (2)
    }
// same code as in Spiral 3
}

The lines (1) and (2) can also be stated more succinctly with the ? ternary operator. Remember that the drawing changes because the _colorBox method is called via draw at 60 frames per second and the board can react to a mouse click. In this spiral, we will show a cell when it is clicked together with its twin cell and then they will stay visible. Attaching an event handler for this is easy. We add the following line to the Board constructor:

querySelector('#canvas').onMouseDown.listen(onMouseDown);

The onMouseDown event handler has to know on which cell the click occurred. The mouse event e contains the coordinates of the click in its e.offset.x and e.offset.y properties (lines (3) and (4)). We will obtain the cell’s row and column by using a truncating division ~/ operator dividing the x (which gives the column) and y (which gives the row) values by boxSize:

void onMouseDown(MouseEvent e) {
    int row = e.offset.y ~/ boxSize;                  (3)
    int column = e.offset.x ~/ boxSize;               (4)
    Cell cell = memory.getCell(row, column);          (5)
    cell.hidden = false;                              (6)
    cell.twin.hidden = false;                         (7)
}

Memory has a collection of cells. To get the cell with a specified row and column value, we will add a getCell method to memory and call it in line (5). When we have the cell, we will set its hidden property and that of its twin cell to false (lines (6) to (7)). The getCell method must return the cell at the given row and column. It loops through all the cells in line (8) and checks each cell, whether it is positioned at that row and column (line (9)). If yes, it will return that cell:

Cell getCell(int row, int column) {
    for (Cell cell in cells) {                        (8)
      if (cell.intersects(row, column)) {             (9)
        return cell;
      }
    }
}

For this purpose, we will add an intersects method to the Cell class. This checks whether its row and column match the given row and column for the current cell (see line (10)):

bool intersects(int row, int column) {
    if (this.row == row && this.column == column) {    (10)
      return true;
    }
    return false;
}

Now, we have already added a lot of functionality, but the drawing of the board will need some more thinking:

  • How to give a cell (and its twin cell) a random color that is not yet used?
  • How to attach a cell randomly to a twin cell that is not yet used?

To end this, we will have to make the constructor of Memory a lot more intelligent:

Memory(this.length) {
    if (length.isOdd) {                                   (1)
      throw new Exception(
          'Memory length must be an even integer: $length.');
    }
    cells = new Cells();
    var cell, twinCell;
    for (var x = 0; x < length; x++) {
      for (var y = 0; y < length; y++) {
        cell = getCell(y, x);                             (2)
        if (cell == null) {                               (3)
          cell = new Cell(this, y, x);
          cell.color = _getFreeRandomColor();             (4)
          cells.add(cell);
          twinCell = _getFreeRandomCell();                (5)
          cell.twin = twinCell;                           (6)
          twinCell.twin = cell;
          twinCell.color = cell.color;
          cells.add(twinCell);
        }
      }
    }
}

The number of pairs given by ((length * length) / 2) must be even. This is only true if the length parameter of Memory itself is even, so we checked it in line (1). Again, we coded a nested loop and got the cell at that row and column. However, as the cell at that position has not yet been made (line (3)), we continued to construct it and assign its color and twin. In line (4), we called _getFreeRandomColor to get a color that is not yet used:

String _getFreeRandomColor() {
    var color;
    do {
      color = randomColor();
    } while (usedColors.any((c) => c == color));             (7)
    usedColors.add(color);                                   (8)
    return color;
}

The do…while loop continues as long as the color is already in a list of usedColors. On exiting from the loop, we found an unused color, which is added to usedColors in line (8) and also returned. We then had to set everything for the twin cell. We searched for a free one with the _getFreeRandomCell method in line (5). Here, the do…while loop continues until a (row, column) position is found where cell == null is, meaning that we haven’t yet created a cell there (line (9)). We will promptly do this in line (10):

Cell _getFreeRandomCell() {
    var row, column;
    Cell cell;
    do {
      row = randomInt(length);
      column = randomInt(length);
      cell = getCell(row, column);
    } while (cell != null);                               (9)
    return new Cell(this, row, column);                  (10)
}

From line (6) onwards, the properties of the twin cell are set and added to the list. This is all we need to produce the following result:

Learning Dart - Second Edition

Paired colored cells

Spiral 5 – game logic (bringing in the time element)

Our app isn’t playable yet:

  • When a cell is clicked, its color must only show for a short period of time (say one second)
  • When a cell and its twin cell are clicked within a certain time interval, they must remain visible

All of this is coded in the mouseDown event handler and we also need a lastCellClicked variable of the Cell type in the Board class. Of course, this is exactly the cell we get in the mouseDown event handler. So, we will set it in line (5) in the following code snippet:

void onMouseDown(MouseEvent e) {
  // same code as in Spiral 4 - 
 if (cell.twin == lastCellClicked && lastCellClicked.shown) { (1)
   lastCellClicked.hidden = false;                            (2)
     if (memory.recalled)   memory.hide();                    (3)
   } else {
     new Timer(const Duration(milliseconds: 1000), () =>     
                        cell.hidden = true);                  (4)
   }
   lastCellClicked = cell;                                    (5)
 }

In line (1), we checked whether the last clicked cell was the twin cell and whether this is still shown. Then, we made sure in (2) that it stays visible. shown is a new getter in the Cell class to make the code more readable: bool get shown => !hidden;. If at that moment all the cells were shown (the memory is recalled), we again hid them in line (3). If the last clicked cell was not the twin cell, we hid the current cell after one second in line (4). recalled is a simple getter (read-only property) in the Memory class and it makes use of a Boolean variable in Memory that is initialized to false (_recalled = false;):

bool get recalled {
    if (!_recalled) {
      if (cells.every((c) => c.shown)) {                      (6)
        _recalled = true;
      }
    }
    return _recalled;
}

In line (6), we tested that if every cell is shown, then this variable is set to true (the game is over). every is a new method in the Cells List and a nice functional way to write this is given as follows:

bool every(Function f) => list.every(f);

The hide method is straightforward: hide every cell and reset the _recalled variable to false:

hide() {
    for (final cell in cells) cell.hidden = true;
    _recalled = false;
}

This is it, our game works!

Spiral 6 – some finishing touches

A working program always gives its developer a sense of joy, and rightfully so. However, this doesn’t that mean you can leave the code as it is. On the contrary, carefully review your code for some time to see whether there is room for improvement or optimization. For example, are the names you used clear enough? The color of a hidden cell is now named simply COLOR_CODE in board.dart, renaming it to HIDDEN_CELL_COLOR_CODE makes its meaning explicit. The List object used in the Cells class can indicate that it is List<Cell>, by applying the fact that Dart lists are generic. The parameter of the every method in the Cell class is more precise—it is a function that accepts a cell and returns bool. Our onMouseDown event handler contains our game logic, so it is very important to tune it if possible. After some thought, we see that the code from the previous spiral can be improved; in the following line, the second condition after && is, in fact, unnecessary:

if (cell.twin == lastCellClicked && lastCellClicked.shown) {...}

When the player has guessed everything correctly, showing the completed screen for a few seconds will be more satisfactory (line (2)). So, this portion of our event handler code will change to:

if (cell.twin == lastCellClicked) {                          (1)
    lastCellClicked.hidden = false;
    if (memory.recalled) { // game over
        new Timer(const Duration(milliseconds: 5000), () =>    
                  memory.hide());                            (2)
    }
} else if (cell.twin.hidden) {
      new Timer(const Duration(milliseconds: 800), () =>  
                cell.hidden = true);
  }

Why don’t we show a “YOU HAVE WON!” banner. We will do this by drawing the text on the canvas (line (3)), so we must do it in the draw() method (otherwise, it would disappear after INTERVAL milliseconds):

void draw() {
  _clear();
  _boxes();
  if (memory.recalled) { // game over
   context.font = "bold 25px sans-serif";
   context.fillStyle = "red";
   context.fillText("YOU HAVE WON !", boxSize, boxSize * 2);  (3)
  }
}

Then, the same game with the same configuration can be played again.

We could make it more obvious that a cell is hidden by decorating it with a small circle in the _colorBox method (line (4)):

  if (cell.hidden) {
    context.fillStyle = HIDDEN_CELL_COLOR_CODE;
    var centerX = cell.column * boxSize + boxSize / 2;
    var centerY = cell.row * boxSize + boxSize / 2;
    var radius = 4;
    context.arc(centerX, centerY, radius, 0, 2 * PI, false);   (4)
  }

We do want to give our player a chance to start over by supplying a Play again button. The easiest way will be to simply refresh the screen (line (5)) by adding this code to the startup script:

void main() {
  canvas = querySelector('#canvas');
  ButtonElement play = querySelector('#play');
  play.onClick.listen(playAgain);
  new Board(canvas, new Memory(4));
}

playAgain(Event e) { 
  window.location.reload();                                   (5)
}

Spiral 7 – using images

One improvement that certainly comes to mind is the use of pictures instead of colors as shown in the Using images screenshot. How difficult would that be? It turns out that this is surprisingly easy, because we already have the game logic firmly in place!

In the images folder, we supply a number of game pictures. Instead of the color property, we give the cell a String property (image), which will contain the name of the picture file. We then replace utilcolor.dart with utilimages.dart, which contains a imageList variable with the image filenames. In utilrandom.dart, we will replace the color methods with the following code:

String randomImage() => randomListElement(imageList);

The changes to memory.dart are also straightforward: replace the usedColor list with List usedImages = []; and the _getFreeRandomColor method with _getFreeRandomImage, which will use the new list and method:

List usedImages = [];

String _getFreeRandomImage() {
    var image;
    do {
      image = randomImage();
    } while (usedImages.any((i) => i == image));
    usedImages.add(image);
    return image;
}

In board.dart, we replace _colorBox(cell) with _imageBox(cell). The only new thing is how to draw the image on canvas. For this, we need ImageElement objects. Here, we have to be careful to create these objects only once and not over and over again in every draw cycle, because this produces a flickering screen. We will store the ImageElements object in a Map:

var imageMap = new Map<String, ImageElement>();

Then, we populate this in the Board constructor with a for…in loop over memory.cells:

for (var cell in memory.cells) {
      ImageElement image = new Element.tag('img');      (1)
      image.src = 'images/${cell.image}';               (2)
      imageMap[cell.image] = image;                     (3)
    }

We create a new ImageElement object in line (1), giving it the complete file path to the image file as a src property in line (2) and store it in imageMap in line (3). The image file will then be loaded into memory only once. We don’t do any unnecessary network access to effectively cache the images. In the draw cycle, we will load the image from imageMap and draw it in the current cell with the drawImage method in line (4):

if (cell.hidden) {
      // see previous code
  } else {
      ImageElement image = imageMap[cell.image];
      context.drawImage(image, x, y); // resize to cell size (4)
  }

Learning Dart - Second Edition

Perhaps, you can think of other improvements? Why not let the player specify the game difficulty by asking the number of boxes. It is 16 now. Check whether the input is a square of an even number. Do you have enough colors to choose from? Perhaps, dynamically building a list with enough random colors would be a better idea. Calculating and storing the statistics discussed in the model would also make the game more attractive. Another enhancement from the model is to support different catalogs of pictures. Go ahead and exercise your Dart skills!

Summary

By thoroughly investigating two games applying all of Dart we have already covered, your Dart star begins to shine. For other Dart games, visit http://www.builtwithdart.com/projects/games/.

You can find more information at http://www.dartgamedevs.org/ on building games.

Resources for Article:


Further resources on this subject:


LEAVE A REPLY

Please enter your comment!
Please enter your name here