With the arrival of H5, a number of new features have been introduced. Among them, Canvas is a very useful feature, based on which we can complete the implementation of charts, games and other tools. I haven’t looked at Canvas for so long. Here I intend to learn it formally. This is my first stop: look at the basics and finish a snake.

“Snake” is a simple example. After reading the basic syntax, it is still not easy to write it. Let’s look at the example:

The online demo

basis

Usually with canvas, you need to have at least one Canvas tag in your HTML to get the Canvas object and create a canvas on which you can implement your idea.

Create a canvas

Using the getContext API, we can get the context object of a canvas, which is the canvas we want to operate on.

const canvas = document.getElementById('#canvas')  // canvas id
const ctx = canvas.getContext('2d') // Get a 2D canvas context, which is a 2D canvas
Copy the code

Before we get the context object, we can also define the width and height of the canvas by setting width and height to the canvas.

The width and height set for canvas through CSS is different from the width and height set for canvas object; The valid width and height should be set for the Canvas object. Setting the width and height through CSS may blur the display.

Tip: You can see that the context object we got is 2D, so is there 3d? Ha ha, there isn’t. Perhaps because of WebGL, Canvas may not launch 3D mode.

Basic method

After obtaining the canvas object, we need to understand the basic API if we need to do operations inside the canvas. Here I will introduce the basic API usage:

  1. beginPath: Start or reset the path, this oneAPIIt’s very useful, and we have to use this when we want to draw parallel lines, because we can’t draw parallel lines in one stroke, so we have tobeginPathTo start a new path.
ctx.beginPath()
ctx.lineTo(10.10)
ctx.lineTo(100.100)
ctx.lineTo(60.10)
ctx.lineTo(150.100)
ctx.stroke()
Copy the code

Take a look at the results above without beginPath:

As you can see, the lines are strangely connected. This is because we didn’t start a new drawing when we used lineTo.

ctx.beginPath()
ctx.lineTo(10.10)
ctx.lineTo(100.100)
ctx.stroke()
ctx.beginPath()
ctx.lineTo(60.10)
ctx.lineTo(150.100)
ctx.stroke()
Copy the code

The above two demos are very good to see the use of beginPath, this method is very common.

  1. closePath: Don’t compare this method withbeginPathConfused? They look like each other, but they’re not related,closePathIt’s used to close paths, so let’s look at a simple example.
ctx.beginPath()
ctx.lineTo(10.10)
ctx.lineTo(100.100)
ctx.lineTo(100.10)
ctx.closePath()
ctx.stroke()
Copy the code

closePathClosed the top two points, forming a right triangle. NoclosePathIf I did, I wouldn’t have an edge to close it off. 3.moveTo: Place the brush on a point to start from there; 4.lineToMove the brush to a certain point. It is not much different from moveTo. 5.strokeStyle: Fill in the path style to make the lines appear different styles.

ctx.beginPath()
ctx.strokeStyle = 'red'
ctx.lineTo(10.10)
ctx.lineTo(100.100)
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = 'blue'
ctx.lineTo(60.10)
ctx.lineTo(150.100)
ctx.stroke()
Copy the code

As you can see, the parallel lines are no longer just black, but red and blue parallel lines. It should be noted here that the strokeStyle will affect the subsequent drawing, so we will see if we need to reset the strokeStyle when drawing different paths.

  1. stroke: Complete the path drawing by drawing a line, without adjusting this method, whateverlineToI’m still not going to draw a line.
  2. fillStyle: The style of the fill area.
  3. fill: Complete the path drawing by filling,fillYou can fill it with figures following the path of the drawn line.
ctx.beginPath()
ctx.strokeStyle = 'red'
ctx.lineTo(10.10)
ctx.lineTo(100.100)
ctx.lineTo(200.10)
ctx.fillStyle = 'red'
ctx.fill()
Copy the code

  1. drawImage: Draw a picture on the canvas. The parameters are:Image.x.y.width.height.

An Image is an instance object obtained by new Image:

const img = new Image()
img.src = '... '
ctx.drawImage(img, 0.0.100.100)
Copy the code
  1. clearRect: a range of clear canvas, parameters are:x.y.width.height.

The above 10 very simple APIS are enough for us to complete many small demos. The key is how to implement them. Next, let’s think about how to draw a snake on canvas.

Drawing graphics

With the above basic API, we can draw a lot of graphics, and can encapsulate some of the common graphics library. However, Canvas already encapsulates some common shapes into methods, so you can use them directly. For example, fillRect can directly draw a square shape.

Bite off more than you can chew, so I’m going to use a very simple API here. If you are interested, check out the Canvas API first.

There are more, temporarily do not repeat, here is just the introduction, we can complete many small functions through these simple API, such as the implementation of a simple snake game.

Snake thoughts

To implement a snake game, we need to consider these:

  • To be drawn:
    • Map: it’s a simple square that the snake has to move on.
    • Snakes: Snakes are made up of squares, which can be lengthened by eating fruit;
    • Fruit: Fruit is randomly generated on the map and can be eaten by snakes.
  • To be tested:
    • Snakes: When a snake touches a fruit, it eats it to increase its length. Die when touching yourself or the border of the map;
    • Fruit: Eaten when touched by a snake.

start

To make it easier to write code, we can draw the map first, so that we can see each square, more intuitive, and later we can remove the squares:

function drawBoard () {
 for (let i = 1 ; i < boxNum ; i ++) {
   ctx.beginPath()
   ctx.moveTo(0, i * boxSize)
   ctx.lineTo(800, i * boxSize)
   ctx.stroke()
   ctx.beginPath()
   ctx.moveTo(i * boxSize, 0)
   ctx.lineTo(i * boxSize, 800)
   ctx.stroke()
 }
}
Copy the code

This makes it convenient for us to write about the movement of the snake and the formation of the fruit.

The state of the small snake

The snake is made up of little squares, and the state is stored in an array of objects, each of which records the position of x and y.

let snake = [{ x: 3.y: 5}, { x: 4.y: 5}, { x: 5.y: 5 }]
function drawSnake (snake) {
 snake.forEach((item, idx) = > {
   ctx.beginPath()
   const x = (item.x - 1) * boxSize
   const y = (item.y - 1) * boxSize
   const w = boxSize
   const h = boxSize
   if (snake.length - 1 === idx) {
     ctx.fillStyle = 'red'
     ctx.fillRect(x, y, w, h)
   } else {
     ctx.fillStyle = 'green'
     ctx.fillRect(x, y, w, h)
   }
 })
}
Copy the code

Now that we can draw an outline of the snake on the canvas, it’s time to consider how the snake will move its body.

The movement of the snake

How do you get the snake to move? The snake we drew was generated from the preserved state of the snake, so we had to consider how to modify the snake as it moved.

Hypothesis, the snake’s head is the final object of the array, then the body is other objects, the movement of the small snake in addition to the head is variable (operation with the up and down or so), the other part is the body of the first to move to the head, the second to move to the first case, so we can know little snake moving rule, by law, producing new small snake object.

function getSnake() {
  const _snake = []
  for (let i = 1 ; i < snake.length ; i ++) {
    _snake.push(snake[i])
  }
  const head = snake[snake.length - 1]
  newHead = { x: head.x + 1.y: head.y } // Suppose the snake moves to the right
  _snake.push(newHead)

  return _snake
}
Copy the code

Snake new object generated, so how to achieve the snake moving animation effect?

Animation effect

The snake can’t move without a timer, of course, there are other better methods, I used setTimeout here. The idea is to redraw it every once in a while, and then recursively call the method again, and animate it by constantly drawing the results of each frame in the method.

let timer = null
move ()
function move () {
  timer = setTimeout(() = > {
    ctx.clearRect(0.0.600.600)
    drawBoard()
    snake = getSnake()
    drawSnake(snake)
    move()
  }, 100)}Copy the code

Every time we call the move method, we first clear the canvas, so we use the clearRect method; Then call the method of drawing a map and draw a small snake method, draw the map again, and draw the latest state of the small snake; Since we recursively call to draw again every 100ms, it will look like a move:

Change direction

Changing the direction of the snake is easy. You can’t just listen for keyboard events and change the values of x and y, so there must be a state that saves the current direction:

const type = 'right'
function listenerKeyboard() {
    document.addEventListener('keyup'.e= > {
        switch(e.key) {
        case 'ArrowLeft': this.type ! = ='right' && this.type = 'left'; break;
        case 'ArrowUp': this.type ! = ='down' && this.type = 'up'; break;
        case 'ArrowRight': this.type ! = ='left' && this.type = 'right'; break;
        case 'ArrowDown': this.type ! = ='up' && this.type = 'down'; break; }})}Copy the code

Determine the key value of the key and then change the type. Here, I’ve decided that I can’t press left when the button is right, and I can’t press down when the button is up, and vice versa. Of course, there are still bugs, even when combining keys.

Judgment of death

Determining death is as simple as determining whether the head of the snake coincides with a critical value greater or less than the boundary, or whether the coordinates of the head coincide with those of the body:

check() {
    const head = this.snake[this.snake.length - 1]
    if (
      head.x > this.boxNum ||
      head.x < 1 ||
      head.y > this.boxNum ||
      head.y < 1 ||
      this.isEatSelf(head)
    ) {
      clearTimeout(this.timer)
      this.timer = null
      alert('Game Over.')
      return true
    } {
      return false}}Copy the code

When the snake reaches the condition of death, it is necessary to clear the timer and return a Boolean value as a judgment.

Produce fruit

It is very simple to generate fruit, just two random numbers. The problem is that when the generated fruit overlaps with the snake, it should be regenerated (or not allowed to generate overlapping fruit). For simplicity, I implemented it randomly:

generate() {
    if (this.box.x) {
      const x = (this.box.x - 1) * this.boxSize
      const y = (this.box.y - 1) * this.boxSize
      this.ctx.beginPath()
      this.ctx.fillStyle = 'green'
      this.ctx.fillRect(x, y, this.boxSize, this.boxSize)
      return
    }
    let result = { x: Math.floor(Math.random() * 19 + 1), y: Math.floor(Math.random() * 19) + 1}
    const isRepeat = this.snake.some(item= > item.x === result.x && item.y === result.y)
    if (isRepeat) {
      result = this.generate()
      return
    }
    this.ctx.fillStyle = 'green'
    const x = (result.x - 1) * this.boxSize
    const y = (result.y - 1) * this.boxSize
    this.ctx.fillRect(x, y, this.boxSize, this.boxSize)
    this.box = { x: result.x, y: result.y }
}
Copy the code

After the fruit is generated, we need an object to preserve the state of the fruit, because the snake should always be redrawn in the current state if it doesn’t eat the fruit.

Eat the fruit

When the head of the snake is equal to the coordinate of the fruit, the snake should eat the fruit, and the body has to be added, which means an object is added to the array. Here, we use the unshift method to add elements, because the last element in the Snake array is the header:

handleEat() {
    const last = this.snake[this.snake.length - 1]
    if (last.x === this.box.x && last.y === this.box.y) {
      this.box = {}
      this.eatNum ++
      this.speed -= this.eatNum
      this.snake.unshift(this.first)
    }
}
Copy the code

First here is actually the first element of Snake that should be saved when getSnake is above.

Load the image and beautify the effect

Through the above steps, you can basically achieve a simple snake. However, the snake now looks like a pixel game, so instead of fillRect, we can draw the snake using the drawImage method.

However, there is still a problem: what if the image of the snake doesn’t appear when the game loads? So, we have to load the game image first, and then follow the logic:

Promise.all([
  this.loadImg('headImgLeft'.'./head-left.png'),
  this.loadImg('headImgUp'.'./head-up.png'),
  this.loadImg('headImgRight'.'./head-right.png'),
  this.loadImg('headImgDown'.'./head-down.png'),
  this.loadImg('bodyImg'.'./body.webp'),
]).then(() = > {
  this.headImg = this.headImgRight
  this.box = {}
  this.type = 'right'
  this.speed = 120
  this.eatNum = 0
  this.ctx = canvas.getContext('2d')
  this.snake = [{ x: 3.y: 5}, { x: 4.y: 5}, { x: 5.y: 5 }]
  this.init()
})

loadImg(name, src) {
    return new Promise((resolve) = > {
      this[name] = new Image()
      this[name].src = src
      this[name].onload = () = > resolve()
    })
}
Copy the code

With promises or async/await, we can execute code after the resource has been loaded. If the resource is too large, we can add a progress bar as a prompt.

conclusion

Basically, the simplest basic snake is done.

The online demo

Of course, we can add points, acceleration, pause, replay and other functions, which can be completed using the most basic API of Canvas. What functions to add is based on your idea.

conclusion

This is the first small step. I feel that Canvas is very powerful and can achieve an unimaginable effect. However, this is a prerequisite for API proficiency and, for complex applications, may require a solid knowledge of mathematics. Of course, you can also implement very useful tools, such as HTML2Canvas and Echarts charts and so on.

However, there is still a long way to go. We need to learn slowly before we can skillfully use Canvas.