使用面向对象风格开发一个简单的贪吃蛇HTML5小游戏

基于面向对象编码风格的贪吃蛇 HTML5 小游戏的开发教程。这里实现的贪吃蛇可以穿墙。

代码

源码地址:https://github.com/F-star/html5-game

示例链接:https://f-star.github.io/html5-game/snake/

模块设计

之所以使用了面向对象的风格去开发这个游戏,主要是为了让代码更加可扩展。相比面向过程自上而下的符合人类直觉的写法,面向对象则是把一个流程进行了拆分,分成很多小的模块,然后将它们组合成一个大模块然后运行,是自下而上的写法。

当然很残念的是,我使用的是 javaScript 的原生语法,并没有类型检测功能,也没有提供原生的封装特性(即可以设置私有变量)。但还好这个项目不是很大,可以靠程序员(指我自己)的自觉来保证不直接使用成员属性。

代码中涉及到有几个大类,分别是 Snake、Apple、Layer。Snake 类主要负责计算贪吃蛇的位置和绘制方式。Apple 其实就是贪吃蛇要吃的东西,其中比较重要的方法是计算在画布上且不在贪吃蛇上的位置。Layer 类就是负责操作 Snake 和 Apple 对象,在 canvas 画布上绘制出每一帧,之所以叫做 Layer(图层),主要是考虑以后可能还要进行分层,将不经常重绘的图案放到其他一些 canvas 上,比如背景层。当然这个小游戏最终还是只有一个图层,不过以后如果要扩展,就会比较方便。

此外还有一些小类:Vector 和 DataViewer。Vector 就是一个简单实现的向量类,目前只有一个求点积的方法。DataViewer 负责简单地将一些游戏数据可视化,比如游戏的 FPS(每秒帧数)和得分(吃掉的苹果数量)。

Snake 类

首先我们需要一个 snake 类。主要是贪吃蛇的一些相关方法,如计算贪吃蛇的下一帧的位置,贪吃蛇的绘制等方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Snake {
  constructor(grid_w, points, dirStr, width, height) {
    this.grid_w = grid_w;
    this.points = points; // 方向为:头到尾
    this.row = height / grid_w;
    this.col = width / grid_w;
    this.dir = new Vector(dirStr);
    this.next_dir = null;
    this.speed_ = 0; // 每秒移动几个单位。
  }
  // ...
}

首先我们要确定 Snake 类要用到的属性,这里简单进行说明:

  • grid_w:格子宽度,贪吃蛇其实是由一个个正方形的块(grip)组成的。
  • points:构成贪吃蛇的所有点的位置。
  • row:行数
  • col:列数
  • dir:贪吃蛇当前方向(只有上下左右方向)的单位向量对象,其实是一个有 x 和 y 属性且它们的平方和为 1 的对象,属于高中数学的知识点。
  • next_dir:贪吃蛇的期望方向,同样是个向量。
  • speed:贪吃蛇的速度,每秒贪吃蛇移动多少个单位。

1601969092-snake.jpeg

Apple 类

贪吃蛇吃的是苹果(也有其他说法说是豆子)。苹果会在游戏初始化的时候和被贪吃蛇吃掉的时候,计算新的位置并绘制出来。这个位置要求在画布内,且不在贪吃蛇占据的位置上。

这个没什么好说的,不过这个计算新位置的算法实现起来比较麻烦。(另外我这里的实现有一点 bug,有空修复后我会更新这篇文章)

Layer 类

这个类用于控制 Snake 对象和 Apple 对象之间的交互以及绘制。比如贪吃蛇移动后如果吃到苹果后的逻辑,贪吃蛇咬到自己的逻辑等。

核心逻辑

核心代码是写在 Layer 类的 update() 方法中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Layer {
  update() {
    window.requestAnimationFrame(ts => {
      // dt 为当前帧和上一帧的时间差。单位为 秒/帧,fps = 1/dt
      if (this.last_ts === 0) this.last_ts = ts;
      const dt = ts - this.last_ts; // 两帧的间隔时间
      const CANVAS_FPS = 60;
      const interval_frame = Math.round(CANVAS_FPS / this.snake.speed());
      if (this.frame_count++ % interval_frame == 0) {
        this.snake.updateDir();
        const next_head = this.snake.getNextHeadPos();
        
        // 吃到苹果
        if (next_head.x === apple.x && next_head.y === apple.y){
          this.score++;
          this.snake.update(next_head, true);
          this.apple.setPosExcept(this.snake.row, this.snake.col, this.snake.points);
        } else {
          this.snake.update(next_head, false);
        }
        
        if (this.snake.isHitSelf()) {
          return this.stop();
        }

        // 渲染
        this.clear();
        this.snake.draw(this.ctx, this.grid_w);
        this.apple.draw(this.ctx);
        // 数据
        this.dataViewer.fps( (1 / dt * 1000).toFixed(2) );
        this.dataViewer.score(this.score);
      }
      this.last_ts = ts;
      this.update();
    });
  
}

这里我把里面的贪吃蛇正常运动的代码提取出来(去掉了一下判断逻辑),以便读者更好地聚焦贪吃蛇的行为逻辑:

1
2
3
4
5
6
7
8
// 更新贪吃蛇的方向:将 next_dir 的值赋给 dir,并将 next_dir 置于 null
this.snake.updateDir();
// 计算贪吃蛇的新位置
const next_head = this.snake.getNextHeadPos();
// 更新贪吃蛇的位置信息,grow 表示贪吃蛇是否变长
this.snake.update(next_head, grow);
// 调用 canvas 的方法进行绘制
this.snake.draw(this.ctx, this.grid_w);

下面我们接着详细叙述一下 upate() 方法做了什么事情。

首先我们要计算贪吃蛇的头部的下一帧的位置。要计算头部的新位置,首先要知道贪吃蛇的期望方向。不过在讨论这个之前,我希望读者能够明白为什么这里有个期望方向(next_dir)的属性。读者可能会觉得当键盘按下时,直接就修改贪吃蛇的 dir,不是更简单吗?

为什么要引入 next_dir(期望方向) 属性,而不是直接修改贪吃蛇的 dir(当前方向) ?

之所以引入 next_dir 是有原因的。首先贪吃蛇的方向的修改是有限制的:下一个方向不能和当前贪吃蛇方向反向。假设我们使用了直接修改 dir 的方案。假设贪吃蛇的方向为右,我们按下左方向键时,修改失败。但如果我们快速地依次按下上方向键和左方向键,贪吃蛇的方向就会改为和当前方向相反的左方向了(只要这两个操作可以在下一帧之前执行)。中间的方向变更为向上方向其实被忽略掉了的(下一帧没有进行这个操作),这样会解除贪吃蛇不能与当前方向反向的限制。这显然不是我们想要的效果。

于是我们引入了 next_dir 这一属性,缓存了玩家希望贪吃蛇下一帧改变的方向,直到下一帧执行的时候,才进行对修改方向,并将 next_dir 重置为 null。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Snake {
  // ...
  // 设置期望方向
  setNextDir(dirStr) {
    const next_dir = this.getDirVectorByStr(dirStr); // 将字符串(如 left)转为向量对象的方法
    this.next_dir = next_dir;
  }
  updateDir() {
    if (this.next_dir && this.next_dir.dotProduct(this.dir) >= 0) { // 向量点积为负数表示方向
      this.dir = this.next_dir;
      this.next_dir = null;
    }
  }
}

此外,之所以方向都用向量来表示,主要是可以通过运算得知两个向量是否方向。当然你也可以使用字符串来记录(如’left', ‘right’),不过这样判断两个方向是否反向,条件判断会多一些。此外,如果方向使用的是单位向量,计算下一个贪吃蛇头部位置会方便很多(向量相加)。但使用字符串的表达,可读性更好一些。

每个新的一帧我们会先更新贪吃蛇的方向,然后计算得到贪吃蛇的头部的新位置。

然后我们要更新贪吃蛇的位置:如果头部的新位置不与苹果的位置重合,往 snake 的 points 数组最前面添加这个头部位置,并删除最后一个数组元素,并给苹果一个新的随机位置;如果和苹果的位置重合,points 数组只需要添加头部位置,不删除数组的最后一个元素,这样贪吃蛇的长度就加一了。

更新完贪吃蛇的位置后,判断贪吃蛇的头部是否和其他点重合,重合说明咬到自己了,游戏结束;如果没有咬到自己,就渲染贪吃蛇和苹果。

结尾

在贪吃蛇开发的过程中,遇到和解决了很多问题,也学到了很多的东西。

在开发一个比较复杂的系统的时候,我们不可能一开始就想好所有的功能模块,因为其中的细节实在是太多。而是应该先做一个最小的原型。