# 如何写一个 2048 小游戏?

# 一、2048 是什么

2048 是一款滑块类游戏,由意大利程序员 Gabriele Cirulli 编写并在 GitHub 上开源。

游戏在一个 4 x 4 的网格上通过上下左右键来进行移动,方块在移动时会被边缘和已经有的方块所阻碍,但如果两个相同的方块在移动时碰撞,它们就会合并成一个新的方块,合并后的方块的值是原来的两倍。

# 二、为什么写 2048 小游戏

其实 2048 在我大二的那个暑假我就用原生的 JavaScript 写完了,这次是同事浏览我的 GitHub 库。

对话如下:

「你还写过 2048 ?」

「是啊」

「可以玩吗?」

「不能。。。」

为了能够让互联网的用户都能玩上,于是我花了几天的业余时间,将原生的 JavaScript 移植成 Vue3 版本,并且加上了排行榜。

大学时候想加排行榜,当时不会 NodeJs 也不会 Java,不想写 PHP,于是排行榜没做成,现在算是弥补某种遗憾了。

# 三、开发思路与步骤

游戏设计的核心是数值,其实 2048 游戏就是一个 4x4 二维数组上的数值变化。

# 3.1 布局

布局采用了绝对定位,每个方块会有各自的一个位置。

<div id="grid-container">
  <div>
    <div v-for="(row, rowIndex) in chessBoard" :key="rowIndex">
      <div
        v-for="(cell, columnIndex) in row"
        :key="rowIndex + columnIndex"
        class="grid-cell"
        :id="'grid-cell-' + rowIndex + '-' + columnIndex"></div>
    </div>

    <div v-for="(row, rowIndex) in chessBoard" :key="rowIndex">
      <div v-for="(cell, columnIndex) in row" :key="columnIndex">
        <span
          class="number-cell"
          :id="'number-cell-' + rowIndex + '-' + columnIndex"
          v-show="shouldShowCell(rowIndex, columnIndex)"
          v-text="cell"></span>
      </div>
    </div>
  </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 3.2 UI 设计

方块的值不同,背景颜色和字体颜色要改变,如果超过了 1000 要更改字体大小。

# 3.3 游戏逻辑

# 3.3.1 开始游戏

  1. 添加键盘事件
document.addEventListener('keyup', processKeyUp);
1

以便之后通过上下左右来移动滑块。

  1. 初始化网格

初始化网格位置,方块的值全部置为 0,模板上通过 v-show让方块值为 0 的不显示。

  1. 在网格上生成两个随机位置,值是 2 或者 4

条件:

  • 网格上还存在空间,没有空间就无法生成。
  • 随机位置不能是已经有值的位置,我们是生成,不是更新。
  • 对随机位置生成的方块添加背景颜色和字体颜色。
/**
 * 判断棋盘中还有空间吗
 *
 * @returns true->还有空间, false->没有
 */
function isChessBoardExistSpace() {
  for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      // 还有空间
      if (chessBoard[i][j] === 0) {
        return false;
      }
    }
  }
  return true;
}

/**
 * 生成数字
 */
function showNumberWithAnimation(i, j, randNumber) {
  let numberCell = document.getElementById('number-cell-' + i + '-' + j);
  if (numberCell) {
    numberCell.style.top = getPosTop(i, j) + 'px';
    numberCell.style.left = getPosLeft(i, j) + 'px';
    numberCell.style.width = '100px';
    numberCell.style.height = '100px';

    // 获取随机数值的背景颜色和字体颜色
    numberCell.style.backgroundColor = getNumberBackgroundColor(randNumber);
    numberCell.style.color = getNumberColor(randNumber);
    numberCell.textContent = randNumber;
  }
}

/**
 * 随机生成数字
 *
 * @returns
 */
function generateOneNumber() {
  // 棋盘中还有空间就生成数字
  if (isChessBoardExistSpace()) {
    // 没有空间返回 false
    return false;
  }

  // 随机生成 0-4 的位置不包括 4
  let randX = parseInt(Math.floor(Math.random() * 4));
  let randY = parseInt(Math.floor(Math.random() * 4));

  // 判断位置是否可用
  while (true) {
    if (chessBoard[randX][randY] === 0) {
      // 位置可用跳出死循环,不可用继续找
      break;
    }
    randX = parseInt(Math.floor(Math.random() * 4));
    randY = parseInt(Math.floor(Math.random() * 4));
  }

  // 随机生成 2 或 4,它们的概率相同
  var randNumber = Math.random() > 0.5 ? 2 : 4;

  // 在随机位置显示随机数字 2 或 4
  chessBoard[randX][randY] = randNumber;

  showNumberWithAnimation(randX, randY, randNumber);
}
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

# 3.3.2 移动方块

  1. 点击上下左右按键
/**
 * 点击上下左右按键
 */
function processKeyUp(e) {
  var keyCode = e.keyCode;
  // 点击上键
  if (keyCode === 38) {
    if (moveUp()) {
      afterMove();
    }
    return;
  }

  // 点击左键
  if (keyCode === 37) {
    if (moveLeft()) {
      afterMove();
    }
    return;
  }

  if (keyCode === 39) {
    if (moveRight()) {
      afterMove();
    }
    return;
  }

  if (keyCode === 40) {
    if (moveDown()) {
      afterMove();
    }
    return;
  }
}
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
  1. 判断是否可以向上移动

比如点击了上键,所有的方块都要向上移动,这时要判断可不可以向上移动,其他方向也是类似。

点击上键,第一行不变,因此从第二行开始,对数值不为0 的方块都去判断可不可以移动到它的上一个方块,有两种可能性:

  1. 上面那个方块格子的值为 0,表明为空,因此可以移动
  2. 上面那个方块格子的值与我当前的值相同,表示合并,因此可以移动。
/**
 * 判断是否能向上移动
 * 1. 上面那个格子的值为 0,表示是空的,因此可以移动过去
 * 2. 上面那个格子的值与当前值相同,表示可以合并,因此也可以移动过去
 *
 * @return 可以返回 true, 不能返回 false
 */
function canMoveUp() {
  for (let i = 1; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      if (chessBoard[i][j] !== 0) {
        if (
          chessBoard[i - 1][j] === 0 ||
          chessBoard[i - 1][j] === chessBoard[i][j]
        ) {
          // 可以向上移动
          return true;
        }
      }
    }
  }
  return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  1. 对方块移动进行处理

先说垂直移动时的障碍物判断,比如说从最下方上移到最上方,只要中间有一个障碍物,就不可以移动过去。

/**
 * 判断垂直障碍物是否存在
 * 在同一列的 startRow 和 endRow 中间只要有一个值为 0,说明垂直方向存在障碍物
 *
 * @return 存在 false, 不存在 true
 */
function noBlockVer(col, startRow, endRow) {
  for (let i = startRow + 1; i < endRow; i++) {
    // 存在障碍物
    if (chessBoard[i][col] !== 0) {
      return false;
    }
  }
  return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 方块移动的核心代码

这里就是整个 2048 最核心的代码了,如果上侧为空,并且中间不存在障碍物,那么直接移动过去。如果上侧的值与当前方块的值相同并且中间不存在障碍物,那么移动过去进行合并,更新分数。

这边都没什么问题,问题在于细节上的 bug

一种情况:

image.png

往上移动时,会出现一种 bug 情况:

image.png

实际上,我们想要的:

image.png

早上很早起来打算修复这个 bug,发现按了葫芦起了瓢,改动了,其他列会受影响。 晚上回来的时候,改了一会没改成,有点生气,我连一个二维数组的算法都搞不定的话,干脆改行算了。

我认真思索了一下,变成 8,是因为从 [2 2 4 0] 变成 [4 0 4 0],然后底下的那个 4 又往上移动合并成了 8,因此要定义一个一维数组 canMoveTopRowIndex表示每一列上的方块所可以移动到最上方的行索引,刚开始是 0,表明可以移动到第一行。

一旦上方有发生合并,就不能是 0 了,这一列上的最上方的变为 1,这时候 [4 0 4 0] 就只能变成 [4 4 0 0 ],也就是我们想要的效果了。

/**
 * 往上移动
 */
function moveUp() {
  if (!canMoveUp()) {
    // 如果不能移动
    return false;
  }

  // 每一列上的方块可以移动到最顶端的那个行索引
  let canMoveTopRowIndex = [0, 0, 0, 0];

  for (let rowIndex = 1; rowIndex < 4; rowIndex++) {
    for (let columnIndex = 0; columnIndex < 4; columnIndex++) {
      if (chessBoard[rowIndex][columnIndex] !== 0) {
        for (let k = canMoveTopRowIndex[columnIndex]; k < rowIndex; k++) {
          if (
            chessBoard[k][columnIndex] === 0 &&
            noBlockVer(columnIndex, k, rowIndex)
          ) {
            // 上侧为空,不存在障碍物
            showMoveAnimation(rowIndex, columnIndex, k, columnIndex);
            // 移动过去
            chessBoard[k][columnIndex] = chessBoard[rowIndex][columnIndex];
            // 之前的消失
            chessBoard[rowIndex][columnIndex] = 0;
            continue;
          } else if (
            chessBoard[k][columnIndex] === chessBoard[rowIndex][columnIndex] &&
            noBlockVer(columnIndex, k, rowIndex)
          ) {
            showMoveAnimation(rowIndex, columnIndex, k, columnIndex);
            chessBoard[k][columnIndex] = 2 * chessBoard[rowIndex][columnIndex];
            chessBoard[rowIndex][columnIndex] = 0;
            score.value = score.value + chessBoard[k][columnIndex];
            canMoveTopRowIndex[columnIndex] = k + 1;
            continue;
          }
        }
      }
    }
  }
  return true;
}
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
39
40
41
42
43
44

# 3.3.3 游戏结束的判断

游戏结束要同时满足以下两个条件

  1. 方格上没有空白空间了
  2. 方格上的所有点都不能移动了,上下左右四个方向都不能移动了。

空白空间的判断

/**
 * 判断棋盘中还有空间吗
 */
function noSpace() {
  for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      // 还有空间
      if (chessBoard[i][j] === 0) {
        return false;
      }
    }
  }
  return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

判断是否可以移动

/**
 * 判断是否可以移动
 *
 * @returns false 可以移动, true 无法移动
 */
function noMove() {
  // 只要有一个方向可以移动,就能移动
  if (canMoveDown() || canMoveLeft() || canMoveRight() || canMoveUp()) {
    return false;
  }

  // 无法移动
  return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 四、游戏结果

排行榜

我只玩到了第十名,他们实在太卷了,最后感谢 SAKURA和其他同学的大力捧场,谢谢大家的支持。

GitHub 仓库地址:https://github.com/stevenling/vue-2048 (opens new window)

游戏体验地址:http://2048.yunhu.wiki/ (opens new window)