canvas 300行代码实现一个贪吃蛇

3,221 阅读2分钟

突然心血来潮,花了一个小时写了个贪吃蛇 ^_^ !

试玩地址

规则

  • 整图为一个 64 * 40 大小的矩形
  • 随机在空白坐标点生成食物
  • 蛇不能撞墙和吃自己
  • 每吃一个食物身体长一格
  • 初始速度为 150ms 移动一次,随着食物越多,速度越快,最快 50ms 移动一次
  • 方向的改变用键盘的方向键控制,不能直接往反方向转向

实现思路

整体思路为:

  • 使用 canvas 作为基础实现方式
  • 蛇的主体采用一个二维数组来实现绘制
  • 蛇的移动去掉蛇数据结构的最后一位,根据方向和蛇头的数据做相应更改再添加到蛇主体头部,同时判断是否结束
  • 蛇将要移动的坐标点是否是食物的位置,如果是则不删除蛇的最后一位数据
  • 如果蛇的长度为 64 * 40 大小,占满整个矩形,则游戏通关完成

html + css

<!-- css -->
<style>
  body {
    background-color: #eee;
  }
  .container {
    text-align: center;
  }
  .top {
    margin: 20px auto;
    width: 640px;
  }
  #score {
    float: left;
  }
  .main {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    width: 642px;
    height: 402px;
  }
  #snake {
    border: 1px solid #000;
    width: 640px;
    height: 400px;
    display: inline-block;
    z-index: 99;
    background-color: rgba(0, 0, 0, .1);
  }
  #mask {
    background-color: rgba(0, 0, 0, .5);
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    z-index: 100;
    display: block;
    color: #fff;
    line-height: 400px;
    text-align: center;
    font-size: 30px;
    cursor: pointer;
  }
</style>

<!-- DOM -->
<div class="container">
  <div class="top">
    <span id="score">Score: 0</span>
    <button id="restart">重新开始</button>
    <button id="stop">暂停</button>
    <button id="continue">继续</button>
  </div>
  <div class="main">
    <canvas id="snake" width="640" height="400"></canvas>
    <div id="mask">开始</div>
  </div>
</div>

构造规则

先定义所有行为的方法,然后再进行组合。

初始构造方法

<script>
  let greedySnake = null
  let score = document.querySelector('#score')
  let restart = document.querySelector('#restart')
  let stop = document.querySelector('#stop')
  let conti = document.querySelector('#continue')
  let mask = document.querySelector('#mask')
  
  class GreedySnake {
    constructor() {
      this.canvas = document.querySelector('#snake')
      this.ctx = this.canvas.getContext('2d')
      this.maxX = 64          // 最大行
      this.maxY = 40          // 最大列
      this.itemWidth = 10     // 每个点的大小
      this.direction = 'right'// up down right left 方向
      this.speed = 150        // ms 速度
      this.isStop = false     // 是否暂停
      this.isOver = false     // 是否结束
      this.isStart = false    // 是否开始
      this.score = 0          // 分数
      this.timer = null       // 移动定时器
      this.j = 1              // 食物闪烁辅助变量
      this.canChange = true   // 是否能改变防线
      
      this.grid = new Array() // 计算得到所有坐标点

      for (let i = 0; i < this.maxX; i++) {
        for (let j = 0; j < this.maxY; j++) {
          this.grid.push([i, j])
        } 
      }
    }
  }
  // 初始实例化
  greedySnake = new GreedySnake()
</script>

初始化蛇数据

在中间位置取一组数据定义为蛇的初始位置

// 创建蛇主体
createSnake() {
  this.snake = [
    [4, 25],
    [3, 25],
    [2, 25],
    [1, 25],
    [0, 25]
  ]
}

食物生成

// 取坐标点
createPos() {
  let [x, y] = this.grid[(Math.random() * this.grid.length) | 0]

  // 取的位置不能是蛇数据内的坐标
  for (let i = 0; i < this.snake.length; i++) {
    if (this.snake[i][0] == x && this.snake[i][1] == y) {
      return this.createPos()
    }
  }

  return [x, y]
}
// 生成食物
createFood() {
  this.food = this.createPos()

  // 每一次食物生成,表明已被吃掉一个,则更新分数
  score.innerHTML = 'Score: '+ this.score++
  
  // 更新速度,最大速度为 50ms
  if (this.speed > 50) {
    this.speed--
  }
}

网格线绘制

// 网格线
drawGridLine() {
  for (let i = 1; i < this.maxY; i++) {
    this.ctx.moveTo(0, i * this.itemWidth)
    this.ctx.lineTo(this.canvas.width, i * this.itemWidth)
  }
  
  for (let i = 1; i < this.maxX; i++) {
    this.ctx.moveTo(i * this.itemWidth, 0)
    this.ctx.lineTo(i * this.itemWidth, this.canvas.height)
  }
  this.ctx.lineWidth = 1
  this.ctx.strokeStyle = '#ddd'
  this.ctx.stroke()
}

绘制蛇和食物

// 绘制
draw() {
  // 清空画布
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

  // 绘制网格
  this.drawGridLine()

  // 绘制食物
  this.ctx.fillStyle="#000"
  this.ctx.fillRect(
    this.food[0] * this.itemWidth + this.j,
    this.food[1] * this.itemWidth + this.j,
    this.itemWidth - this.j * 2,
    this.itemWidth -  + this.j * 2
  )
  this.j ^= 1

  // 绘制蛇头
  this.ctx.fillStyle="green"
  this.ctx.fillRect(
    this.snake[0][0] * this.itemWidth + 0.5,
    this.snake[0][1] * this.itemWidth + 0.5,
    this.itemWidth - 1,
    this.itemWidth - 1
  )
  // 绘制蛇身
  this.ctx.fillStyle="red"
  for (let i = 1; i < this.snake.length; i++) {
    this.ctx.fillRect(
      this.snake[i][0] * this.itemWidth + 0.5,
      this.snake[i][1] * this.itemWidth + 0.5,
      this.itemWidth - 1,
      this.itemWidth - 1
    )
  }
}

暂停与继续

// 暂停游戏
stop() {
  if (this.isOver) return
  this.isStop = true
  mask.style.display = 'block'
  mask.innerHTML = '暂停'
}

// 继续游戏
continue() {
  if (this.isOver) return
  this.isStop = false
  this.move()
  mask.style.display = 'none'
}

蛇的方向控制

监听方向键的按压,来改变方向

getDirection() {
  // 上38 下40 左37 右39 不能往相反的方向走
  document.onkeydown = (e) => {
    // 在贪吃蛇移动的间隔内不能连续改变两次方向
    if (!this.canChange) return
    switch(e.keyCode) {
      case 37:
        if (this.direction !== 'right') {
          this.direction = 'left'
          this.canChange = false
        }
        break
      case 38:
        if (this.direction !== 'down') {
          this.direction = 'up'
          this.canChange = false
        }
        break
      case 39:
        if (this.direction !== 'left') {
          this.direction = 'right'
          this.canChange = false
        }
        break
      case 40:
        if (this.direction !== 'up') {
          this.direction = 'down'
          this.canChange = false
        }
        break
      case 32:
        // 空格暂停与继续
        if (!this.isStop) {
          this.stop()
        } else {
          this.continue()
        }
        break
    }
  }
}

是否违规结束

// 结束
over([x, y]) {
  if (x < 0 || x >= this.maxX || y < 0 || y >= this.maxY) {
    return true
  }
  
  if (this.snake.some(v => v[0] === x && v[1] === y)) {
    return true
  }
}

是否通关完成

// 完成
completed() {
  if (this.snake.length == this.maxX * this.maxY) {
    return true
  }
}

蛇的移动

蛇移动是整个贪吃蛇游戏的逻辑实现,内部需要组合其它的各种方法来完成。

// 移动
move() {
  if (this.isStop) return

  let [x, y] = this.snake[0]
  switch(this.direction) {
    case 'left':
      x--
      break
    case 'right':
      x++
      break
    case 'up':
      y--
      break
    case 'down':
      y++
      break
  }
  
  // 如果下一步不是食物的位置,则删掉最后一位数据
  if (x !== this.food[0] || y !== this.food[1]) {
    this.snake.pop()
  } else { // 如果是食物则不删掉最后一个并再生成一个食物
    this.createFood()
  }

  // 判断是否结束
  if (this.over([x, y])) {
    this.isOver = true
    mask.style.display = 'block'
    mask.innerHTML = '结束'
    return
  }
  // 判断是否完成
  if (this.completed()) {
    mask.style.display = 'block'
    mask.innerHTML = '恭喜您,游戏通关'
    return
  }
  
  // 将坐标点放进蛇头部
  this.snake.unshift([x, y])
  
  this.draw() // 绘制
  this.canChange = true // 可以更改方向
  
  // 递归绘制,实现蛇的一直爬行
  this.timer = setTimeout(() => this.move(), this.speed)
}

最后外加几个按钮来实现交互

restart.onclick = () => {
  if (!greedySnake.isStart) return
  greedySnake.start()
}
stop.onclick = () => {
  if (greedySnake.isStop || !greedySnake.isStart) return
  greedySnake.stop()
}
conti.onclick = () => {
  if (!greedySnake.isStop || !greedySnake.isStart) return
  greedySnake.continue()
}
mask.onclick = () => {
  if (!greedySnake.isStart) {
    greedySnake.start()
  } else {
    greedySnake.continue()
  }
}

完整代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>贪吃蛇</title>
  <style>
    body {
      background-color: #eee;
    }
    .container {
      text-align: center;
    }
    .top {
      margin: 20px auto;
      width: 640px;
    }
    #score {
      float: left;
    }
    .main {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      width: 642px;
      height: 402px;
    }
    #snake {
      border: 1px solid #000;
      width: 640px;
      height: 400px;
      display: inline-block;
      z-index: 99;
      background-color: rgba(0, 0, 0, .1);
    }
    #mask {
      background-color: rgba(0, 0, 0, .5);
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      z-index: 100;
      display: block;
      color: #fff;
      line-height: 400px;
      text-align: center;
      font-size: 30px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="top">
      <span id="score">Score: 0</span>
      <button id="restart">重新开始</button>
      <button id="stop">暂停</button>
      <button id="continue">继续</button>
    </div>
    <div class="main">
      <canvas id="snake" width="640" height="400"></canvas>
      <div id="mask">开始</div>
    </div>
  </div>

<script>
  let greedySnake = null
  let score = document.querySelector('#score')
  let restart = document.querySelector('#restart')
  let stop = document.querySelector('#stop')
  let conti = document.querySelector('#continue')
  let mask = document.querySelector('#mask')

  restart.onclick = () => {
    if (!greedySnake.isStart) return
    greedySnake.start()
  }
  stop.onclick = () => {
    if (greedySnake.isStop || !greedySnake.isStart) return
    greedySnake.stop()
  }
  conti.onclick = () => {
    if (!greedySnake.isStop || !greedySnake.isStart) return
    greedySnake.continue()
  }
  mask.onclick = () => {
    if (!greedySnake.isStart) {
      greedySnake.start()
    } else {
      greedySnake.continue()
    }
  }

  // 大小为64 * 40
  class GreedySnake {
    constructor() {
      this.canvas = document.querySelector('#snake')
      this.ctx = this.canvas.getContext('2d')
      this.maxX = 64          // 最大行
      this.maxY = 40          // 最大列
      this.itemWidth = 10     // 每个点的大小
      this.direction = 'right'// up down right left 方向
      this.speed = 150        // ms 速度
      this.isStop = false     // 是否暂停
      this.isOver = false     // 是否结束
      this.isStart = false    // 是否开始
      this.score = 0          // 分数
      this.timer = null       // 移动定时器
      this.j = 1
      this.canChange = true
      
      this.grid = new Array()

      for (let i = 0; i < this.maxX; i++) {
        for (let j = 0; j < this.maxY; j++) {
          this.grid.push([i, j])
        } 
      }

      this.drawGridLine()
      this.getDirection()
    }

    // 开始
    start() {
      if (this.timer) {
        clearTimeout(this.timer)
      }
      if (!this.isStart) {
        this.isStart = true
      }
      this.score = 0
      this.speed = 150
      this.isStop = false
      this.isOver = false
      this.direction = 'right'
      this.createSnake()
      this.createFood()
      this.draw()
      this.move()
      mask.style.display = 'none'
    }

    // 创建蛇主体
    createSnake() {
      this.snake = [
        [4, 25],
        [3, 25],
        [2, 25],
        [1, 25],
        [0, 25]
      ]
    }

    // 移动
    move() {
      if (this.isStop) return

      let [x, y] = this.snake[0]
      switch(this.direction) {
        case 'left':
          x--
          break
        case 'right':
          x++
          break
        case 'up':
          y--
          break
        case 'down':
          y++
          break
      }
      
      // 如果下一步不是食物的位置
      if (x !== this.food[0] || y !== this.food[1]) {
        this.snake.pop()
      } else {
        this.createFood()
      }

      if (this.over([x, y])) {
        this.isOver = true
        mask.style.display = 'block'
        mask.innerHTML = '结束'
        return
      }
      if (this.completed()) {
        mask.style.display = 'block'
        mask.innerHTML = '恭喜您,游戏通关'
        return
      }

      this.snake.unshift([x, y])
      
      this.draw()
      this.canChange = true
      this.timer = setTimeout(() => this.move(), this.speed)
    }
    
    // 暂停游戏
    stop() {
      if (this.isOver) return
      this.isStop = true
      mask.style.display = 'block'
      mask.innerHTML = '暂停'
    }

    // 继续游戏
    continue() {
      if (this.isOver) return
      this.isStop = false
      this.move()
      mask.style.display = 'none'
    }

    getDirection() {
      // 上38 下40 左37 右39 不能往相反的方向走
      document.onkeydown = (e) => {
        // 在贪吃蛇移动的间隔内不能连续改变两次方向
        if (!this.canChange) return
        switch(e.keyCode) {
          case 37:
            if (this.direction !== 'right') {
              this.direction = 'left'
              this.canChange = false
            }
            break
          case 38:
            if (this.direction !== 'down') {
              this.direction = 'up'
              this.canChange = false
            }
            break
          case 39:
            if (this.direction !== 'left') {
              this.direction = 'right'
              this.canChange = false
            }
            break
          case 40:
            if (this.direction !== 'up') {
              this.direction = 'down'
              this.canChange = false
            }
            break
          case 32:
            // 空格暂停与继续
            if (!this.isStop) {
              this.stop()
            } else {
              this.continue()
            }
            break
        }
      }
    }
    createPos() {
      let [x, y] = this.grid[(Math.random() * this.grid.length) | 0]

      for (let i = 0; i < this.snake.length; i++) {
        if (this.snake[i][0] == x && this.snake[i][1] == y) {
          return this.createPos()
        }
      }

      return [x, y]
    }
    // 生成食物
    createFood() {
      this.food = this.createPos()

      // 更新分数
      score.innerHTML = 'Score: '+ this.score++
      
      if (this.speed > 50) {
        this.speed--
      }
    }

    // 结束
    over([x, y]) {
      if (x < 0 || x >= this.maxX || y < 0 || y >= this.maxY) {
        return true
      }
      
      if (this.snake.some(v => v[0] === x && v[1] === y)) {
        return true
      }
    }

    // 完成
    completed() {
      if (this.snake.length == this.maxX * this.maxY) {
        return true
      }
    }

    // 网格线
    drawGridLine() {
      for (let i = 1; i < this.maxY; i++) {
        this.ctx.moveTo(0, i * this.itemWidth)
        this.ctx.lineTo(this.canvas.width, i * this.itemWidth)
      }
      
      for (let i = 1; i < this.maxX; i++) {
        this.ctx.moveTo(i * this.itemWidth, 0)
        this.ctx.lineTo(i * this.itemWidth, this.canvas.height)
      }
      this.ctx.lineWidth = 1
      this.ctx.strokeStyle = '#ddd'
      this.ctx.stroke()
    }

    // 绘制
    draw() {
      // 清空画布
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

      this.drawGridLine()

      this.ctx.fillStyle="#000"
      this.ctx.fillRect(
        this.food[0] * this.itemWidth + this.j,
        this.food[1] * this.itemWidth + this.j,
        this.itemWidth - this.j * 2,
        this.itemWidth -  + this.j * 2
      )
      this.j ^= 1

      this.ctx.fillStyle="green"
      this.ctx.fillRect(
        this.snake[0][0] * this.itemWidth + 0.5,
        this.snake[0][1] * this.itemWidth + 0.5,
        this.itemWidth - 1,
        this.itemWidth - 1
      )
      this.ctx.fillStyle="red"
      for (let i = 1; i < this.snake.length; i++) {
        this.ctx.fillRect(
          this.snake[i][0] * this.itemWidth + 0.5,
          this.snake[i][1] * this.itemWidth + 0.5,
          this.itemWidth - 1,
          this.itemWidth - 1
        )
      }
    }
  }
  greedySnake = new GreedySnake()
</script>
</body>
</html>