yliu

时来天地皆同力,运去英雄不自由

使用 canvas 实现贪吃蛇


贪吃蛇

童年使用的诺基亚基本上都会搭载这款小游戏,最近心血来潮想用 canvas 来实现这个游戏就有了这篇文章,下面讲解一下实现的思路,本文的最终代码已经放到了codesandbox上。

当然实现的方式并不止 canvas 这一种,还可以使用 html + css 的形式,这里不讨论优劣取舍,让我们快速开始吧。

游戏规则

在开发之前先要设计游戏的具体规则是怎么样的,就像是实现polyfill也要对照规范一样,贪吃蛇游戏规则还是蛮简单下面概括一下:

  • 初始的时候会被四周墙体包围着,贪吃蛇这时候很虚弱只有一节,墙的周围随机分布着一个鸡蛋(奖励品)
  • 按下方向键游戏开始,吃到鸡蛋身体会边长,同时会生成新的鸡蛋
  • 撞到墙体和撞到身体会结束游戏

上面规则概括成代码就是我们需要给一个界面范围、绘制蛇和鸡蛋、让蛇动起来。

基本概念

canvashtml5 出现的元素,它可以用于数据可视化、动画、游戏、图像操作、视频等方面,它以 JavaScript 的方式来进行交互。

为了方便下面的理解介绍几个基本概念

  • var ctx = canvas.getContext(contextType);

getContext 返回 canvas 的上下文,contextType 指定了何种上下文,它有 2dwebglwebgl2bitmaprenderer这几种选项,这里只需要 2d 界面,所以填写 2d 即可。

可以根据 canvas.getContext 存在判断浏览器支不支持 canvas 元素

  • CanvasRenderingContext2D.clearRect

指定矩形区域的像素都变成透明,注意这个方法不等于完全清除画布,对于一些 path 的方法记得手动 closePath()

  • 动画的组成原理

后续我们需要让贪吃蛇动起来,但是动这个概念是一个伪命题,想一下电视和电影是怎么让我们感觉到动的呢?就是通过一帧帧的画面快速播放得到的视觉效果。

后面让蛇动起来也是通过定时器来让 canvas 不断重绘达到动画的效果。

实现思路

坐标

如文章首页的示例图片,贪吃蛇可以想象成一个坐标,根据这个坐标很容易得到下面启示:

  • 蛇和鸡蛋都会在坐标中出现,但不会超出
  • 撞到坐标的边界即是撞墙
  • 每次贪吃蛇移动都需要擦拭移动的路径
  • 判断有没有撞到身体和吃不是吃到鸡蛋,判断移动的坐标值是什么即可

也就是说,贪吃蛇这个游戏最核心的就是坐标这个概念,为了快速得到坐标的信息,我们期待以下面的形式来储存数据。

interface CoordinateAll {
  [x: number]: {
    [y: number]: 'block' | 'egg' | 'sanke';
  };
}
// 转化成JavaScript表示
{
  1: {1: 'block', 2: 'egg', 'block'},
  2: {1: 'block', 2: 'block', 'block'},
  // ...
}

为了后续的使用方便,提前定义两个文件

// utils.js
// 传递一个整数,来循环它
export const eachNumber = (n, fn) => {
  for (let i = 0; i < n; i++) {
    fn(i);
  }
};
// 返回随机整数
export const randomNumber = (min, max) => {
  return Math.floor(Math.random() * (max - min + 1)) + min;
};
// const.js
// canvas宽度
export const WIDTH = 400;
// 高度
export const HEIGHT = 400;
// 像素x
export const PIXEL_X = 20;
// 像素y
export const PIXEL_Y = 20;
// 行
export const ROW = HEIGHT / PIXEL_Y;
// 列
export const COLUMN = WIDTH / PIXEL_X;
// 半径
export const RADIUS = 10;
// 定时器间隔
export const INTERVAL = 150;
// 鸡蛋颜色
export const EGG_COLOR = '#767803';
// 蛇颜色
export const SANKE_COLOR = '#6f5f06';
// 边框颜色
export const BORDER_COLOR = '#796e09';

后面就开始绘制初始的界面。

初始化界面

class Sanke {
  constructor() {
    const canvas = document.createElement('canvas');
    canvas.width = WIDTH;
    canvas.height = HEIGHT;
    this.canvas = canvas;
    document.body.appendChild(canvas);
    const ctx = canvas.getContext('2d');
    this.ctx = ctx;

    // 蛇身
    this.currentSanke = [];
    // 出现的egg位置
    this.currentEgg = null;
    // 坐标系
    this.coordinateAll = {};
    this.init();
  }
  init() {
    // 创建坐标系
    this.coordinateAll = {};
    eachNumber(ROW, (x) => {
      this.coordinateAll[x] = {};
      eachNumber(COLUMN, (y) => {
        this.coordinateAll[x][y] = 'block';
      });
    });
    const sanke = this.randomCoordinate();
    this.setCoordinate(sanke, 'sanke');
    this.currentSanke = [sanke];
    this.currentEgg = this.randomCoordinate();
    this.setCoordinate(this.currentEgg, 'egg');
    this.repaint();
  }
  repaint() {
    const { ctx, canvas, currentSanke } = this;
    // 绘制egg和绘制sanke
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.strokeStyle = BORDER_COLOR;
    ctx.strokeRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = SANKE_COLOR;
    currentSanke.forEach((item) => {
      ctx.fillRect(item.x * PIXEL_X, item.y * PIXEL_Y, PIXEL_X, PIXEL_Y);
    });
    // 绘制egg
    ctx.beginPath();
    ctx.fillStyle = EGG_COLOR;
    if (!this.currentEgg) {
      this.currentEgg = this.randomCoordinate();
      this.setCoordinate(this.currentEgg, 'egg');
    }
    const { currentEgg } = this;
    ctx.arc(
      0 + RADIUS + currentEgg.x * PIXEL_X,
      0 + RADIUS + currentEgg.y * PIXEL_Y,
      RADIUS,
      0,
      Math.PI * 2,
      true
    );
    ctx.fill();
    ctx.closePath();
  }

  randomCoordinate() {
    const arr = [];
    eachNumber(ROW, (x) => {
      eachNumber(COLUMN, (y) => {
        const value = this.coordinateAll[x][y];
        if (value === 'block') {
          arr.push({ x, y });
        }
      });
    });
    // 说明无坐标可取
    if (!arr.length) {
      return null;
    }
    return arr[randomNumber(0, arr.length)];
  }
  setCoordinate({ x, y }, value) {
    this.coordinateAll[x][y] = value;
  }
  getCoordinate({ x, y }) {
    if (x < 0 || y < 0 || x > ROW - 1 || y > COLUMN - 1) {
      return null;
    }
    return this.coordinateAll[x][y];
  }
}

代码看起来还是蛮多的,不过之前已经介绍过实现的思路了,接下来一步步分析执行:

  • constructor

这里主要是创建 canvas 对象,在 this 上绑定一些必要的属性,例如坐标系egg 位置sanke 位置,这里 currentSanke array 是因为蛇存在多节,用数组管理比较方便的。

  • init

init 来完成组装,第一步是创建坐标系,这里给所有的初始元素打上 block,它一共三个值:'block' | 'egg' | 'sanke'。 后面随机获取蛋的位置和蛇的位置,储存到 coordinateAll 中,最后调用 repaint 完成绘制

  • repaint

这一步就是调用 canvas 完成界面绘制,主要用到了两个 api

ctx.arcctx.fillRect,前者绘制弧形后者填充矩形,先 clearRect 后绘制是因为这个方法后续会被重复调用,后面的一些randomCoordinatesetCoordinategetCoordinate都是跟坐标方法有关,比较简单看一下即可。

静态效果

到这里我们就可以得到静态的效果,后面就是让其动起来。

动起来

想让贪吃蛇动起来,我们需要做两步:

  • 监听方向键,也就是动的方向(也充当启动游戏作用)
  • 使用定时器不断的重绘蛇和鸡蛋的位置来造成视觉的移动

监听方向键很简单,我们直接在 body 上监听 keydown 即可,这个事件在键盘按下的时候触发

this.keydownFn = (e) => {
  const code = e.code;
  const result = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].indexOf(
    code
  );
  if (result <= -1) {
    return;
  }
  this.direction = code;
};
monitor() {
  document.body.addEventListener('keydown', this.keydownFn);
};
move() {
  if (this.timeId) {
    clearInterval(this.timeId);
  }
  this.timeId = setInterval(() => {
    const { direction, currentSanke, currentEgg } = this;
    // 如果方向键不存在就返回
    if (!direction) {
      return;
    }
    let nextCoordinate;
    // 蛇头始终是0
    const { x, y } = currentSanke[0];
    switch (direction) {
      case 'ArrowDown':
      case 'ArrowUp':
        nextCoordinate = { x, y: direction === 'ArrowUp' ? y - 1 : y + 1 };
        break;
      case 'ArrowLeft':
      case 'ArrowRight':
        nextCoordinate = { y, x: direction === 'ArrowLeft' ? x - 1 : x + 1 };
        break;
      default:
        break;
    }
    // nextCoordinate的作用是确认下一步是什么类型
    const value = this.getCoordinate(nextCoordinate);
    switch (value) {
      case 'block':
        this.setCoordinate(currentSanke.pop(), 'block');
        currentSanke.unshift(nextCoordinate);
        this.setCoordinate(nextCoordinate, 'sanke');
        break;
      case 'egg':
        this.setCoordinate(currentEgg, 'sanke');
        this.currentEgg = null;
        currentSanke.unshift(nextCoordinate);
        break;
      case 'sanke':
      case null:
        // 游戏结束
        break;
      default:
        break;
    }
    this.repaint();
  }, INTERVAL);
};

之后在 init 中插入这这两个方法

init() {
  // ...
  this.monitor();
  this.move();
}

示例

到这一步,贪吃蛇也实现了动起来的要求,在 swtich 中判断 value 的类型来决定坐标值如何变化,之后继续执行 repaint 重绘界面

结束游戏

上面 move 对游戏结束只进行了注释,下面就把 case null:下面语句替换成this.end

end() {
  if (this.timeId) {
    clearInterval(this.timeId);
  }
  this.timeId = null;
  document.body.removeEventListener('keydown', this.keydownFn);
  alert(`游戏结束,当前分数:${(this.currentSanke.length - 1) * 10}`);
};

最后

如果有什么错误请不吝指点,对你有帮助也可以 start 一下。