300行代码实现canvas贪吃蛇

400 阅读4分钟

前言

上周末写了篇canvas实现贪吃蛇,今天再来个贪吃蛇!

技术选型

我们的贪吃蛇游戏将由Canvas渲染。下面是我们需要完成的步骤:

  1. 创建画布(Canvas)和游戏区域
  2. 绘制蛇和食物
  3. 监听键盘事件,以控制蛇的移动
  4. 判断输赢(当蛇吃到食物时,蛇会变长,游戏继续;当蛇碰到自身或边界时,游戏结束)

实现

第一步:创建画布和游戏区域

<!-- html只需提供一个canvas标签 -->
<canvas id="canvas"></canvas>
class Snake {
  constructor(canvas, { width = 400, height = 400 } = {}) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.width = width;
    this.height = height;
    // 格子宽度
    this.gap = 10; 
    // 初始化
    this.init();
  }
  init() {
    const { canvas, width, height } = this;
    canvas.width = width;
    canvas.height = height; 
    // 画格子
    this.drawGrid();
  }
  // 画格子
  drawGrid() {
    const { ctx, width, height, gap } = this;
    // 青绿色背景
    ctx.fillStyle = "#96c93d";
    ctx.fillRect(00, width, height);
    // 白色线条
    ctx.lineWidth = 0.5;
    ctx.strokeStyle = "#fff";
    for (let i = 0; i < width; i += gap) {
      ctx.beginPath();
      ctx.moveTo(i, 0);
      ctx.lineTo(i, height);
      ctx.stroke();
      ctx.closePath();
    }
    for (let i = 0; i < height; i += gap) {
      ctx.beginPath();
      ctx.moveTo(0, i);
      ctx.lineTo(width, i);
      ctx.stroke();
      ctx.closePath();
    }
  }
}

此时效果如下

第二步:绘制蛇和食物

要先生成蛇和食物的坐标,默认蛇的位置是固定的,食物的位置是随机的,改写一下init方法

init() {
  const { canvas, width, height } = this;
  canvas.width = width;
  canvas.height = height;
  // 画格子
  this.drawGrid();
  // 初始化蛇的坐标,位置固定
  this.snake = [
    { x10y100 },
    { x20y100 },
    { x30y100 },
    { x40y100 },
    { x50y100 },
  ];
  // 初始化食物
  this.food = this.randomFood();
  // 绘制蛇
  this.drawSnake();
  // 绘制食物
  this.drawFood();
}

写个方法随机生成食物坐标,后面还要用。

randomFood() {
  const { width, height } = this;
  // 生成0-最大宽度之间的10倍的整数
  const x = Math.floor((Math.random() * width) / 10) * 10;
  const y = Math.floor((Math.random() * height) / 10) * 10;

  return { x, y };
}
// 绘制蛇
drawSnake() {
  const { ctx, snake, gap, width, height } = this;
  snake.forEach((item, index) => {
    // 蛇头部画个红色
    // 身体画黑色
    if (index === snake.length - 1) {
      ctx.fillStyle = "red";
    } else {
      ctx.fillStyle = "black";
    }
    ctx.fillRect(item.x + 1, item.y + 1, gap - 1, gap - 1);
  });
}
// 绘制食物
drawFood() {
  const { ctx, food, gap } = this;
  const { x, y } = food;
  ctx.fillStyle = "#035c03";
  ctx.fillRect(x, y, gap, gap);
}

此时效果如下

第三步:控制蛇的移动

每隔多长时间蛇前进一步,即,尾部减少一格,头部增加一格,增加的这一格的坐标应该怎么计算?这个要**「根据蛇当前的移动方向来计算」**。

实现核心的运动函数,在init中调用一下

init() {
  // ... 前面的省略了
  this.move()
}
move() {
  const { width, height, ctx, snake, gap } = this;
  // 每200ms更新一次蛇的坐标,
  // 并重新渲染Canvas,🐍就动起来了
  setInterval(() => {
    // 更新蛇的坐标
    // 获取增加的头部节点坐标
    // 因为默认往右的,头部其实是在数组的最后一项
    let { x, y } = snake.at(-1);
    // 头部x轴增加一格,y轴不变
    x += gap;
    // 尾巴去掉一格
    snake.shift();
    // 头部新增一格
    snake.push({ x, y });
    // 清空画布
    ctx.clearRect(00, width, height);
    // 重新画格子
    this.drawGrid();
    // 重新画蛇
    this.drawSnake();
  }, 200);
}

这里更新了蛇的坐标,重新调用ctx.clearRect清空蛇尾,重新调用ctx.fillRect画蛇头,这样应该会更好,就不用清空整个画布重新绘制了。先实现功能吧~

此时效果如下(这里gif帧率过低了,跟实际效果有区别)

但是只实现了往一个方向移动,并且可以看到蛇跑到游戏区域外了,下面来解决这个问题。

定义一个方向变量,并且绑定键盘事件来改变方向。

init() {
  // ... 前面的省略了
  // 方向,默认往右
  this.direction = "right"; 
  // 绑定操作
  this.bindEvent();
}
bindEvent() {
  // 键盘上的方向按键
  const keys = ['ArrowUp''ArrowDown''ArrowLeft''ArrowRight']
  document.addEventListener("keydown"(event) => {
    // 规定只有按方向按键才起作用
    if (!keys.includes(event.key)) return
    // 不允许往反方向移动
    if (event.key === "ArrowUp" && this.direction !== "bottom") {
      this.direction = "top";
    } else if (event.key === "ArrowDown" && this.direction !== "top") {
      this.direction = "bottom";
    } else if (event.key === "ArrowLeft" && this.direction !== "right") {
      this.direction = "left";
    } else if (event.key === "ArrowRight" && this.direction !== "left") {
      this.direction = "right";
    }
    
    // 改变方向后要重新调用一下move函数
    this.move()
  });
}

move函数也要改写一下,增加根据方向控制运动的代码:

move() {
  const { width, height, ctx, snake } = this;
  // 因为要多次调用,每次调用前要清空定时器
  clearInterval(this.timer);
  this.timer = setInterval(() => {
    // 更新蛇的坐标
    // 获取增加的节点坐标
    const { x, y } = this.updateSnake()
    // 尾巴去掉一格
    snake.shift();
    // 头部新增一格
    snake.push({ x, y });
    // 清空画布
    ctx.clearRect(00, width, height);
    // 画格子
    this.drawGrid();
    // 重新画蛇
    this.drawSnake();
  }, 200);
}
updateSnake() {
  const { snake, direction, gap } = this
  let { x, y } = snake.at(-1);
  switch (direction) {
    case "right":
      x += gap;
      break;
    case "left":
      x -= gap;
      break;
    case "top":
      y -= gap;
      break;
    case "bottom":
      y += gap;
      break;
    default:
      break;
  }
  return { x, y }
}

现在就可以用方向键控制拐弯了

第四步:判断输赢

  • 蛇吃到食物的判断:蛇的头部坐标和食物坐标重合,即吃到了食物,在蛇的尾部增加一格
  • 蛇撞到边界或自身的判断:蛇的头部和四周边缘坐标重合、或者和自己的身体某一部分坐标重合,即游戏结束

改写move函数,判断是否吃到食物,重新绘制食物

move() {
  const { width, height, ctx, snake } = this;
  // 因为要多次调用,每次调用前要清空定时器
  clearInterval(this.timer);
  this.timer = setInterval(() => {
    // 更新蛇的坐标
    // 获取增加的节点坐标
    const { x, y } = this.updateSnake()
    // 吃的动作,把最新的头部坐标传进去
    this.handleEat(x, y)
    // 游戏结束
    if (this.isHitWall({ x, y }) || this.isEatSelf({ x, y })) {
      console.log('游戏结束')
      clearInterval(this.timer);
      return
    }
    // 尾巴去掉一格
    snake.shift();
    // 头部新增一格
    snake.push({ x, y });
    // 清空画布
    ctx.clearRect(00, width, height);
    // 画格子
    this.drawGrid();
    // 重新画蛇
    this.drawSnake();
    // 重新绘制食物
    this.drawFood();
  }, 200);
}
// 吃
handleEat(x, y) {
  const { snake, direction, gap, ctx } = this
  if (x === this.food.x && y === this.food.y) {
    // 吃到了食物
    let { x, y } = snake[0];
    switch (direction) {
      case "right":
        x -= gap;
        break;
      case "left":
        x += gap;
        break;
      case "top":
        y += gap;
        break;
      case "bottom":
        y -= gap;
        break;
      default:
        break;
    }
    // 尾部增加一节
    snake.unshift({ x, y });
    // 清空食物
    ctx.clearRect(this.food.xthis.food.y, gap, gap);
    // 重新生成食物坐标
    this.food = this.randomFood();
  }
}

判断是否撞到边界或自身

// 判断是否撞到边界
isHitWall(head) {
  const { width, height } = this;
  // x轴大于最大宽度或小于最小宽度
  // y轴大于最大高度或小于最小高度
  return head.x >= width || head.x < 0 || head.y >= height || head.y < 0
}

// 判断是否吃到了自己
isEatSelf(head) {
  const { snake } = this
  // 头部不可能和头部重合,只判断身体
  const body = snake.slice(1);
  // 头部和身体任何一个节点重合
  return body.some(item => item.x === head.x && item.y === head.y);
}

到这里基本功能就完成了,还可以加入计分逻辑手动控制开始暂停游戏兼容移动端等功能。

在线体验最新效果

github源码