别急着写 UI:一个 2048 小游戏,藏着很经典的数组算法

在这里插入图片描述

最近在整理自己的小游戏项目:

https://github.com/Rodert/shiyu-games

这个仓库里面已经放了不少 Web 小游戏,包括:

2048、贪吃蛇、俄罗斯方块、五子棋、Flappy Bird、迷宫、赛车、节奏游戏、打字游戏等等。

这些小游戏看起来简单,但如果真的自己从零写一次,就会发现里面其实藏着不少非常经典的程序设计问题。

今天不讲整个项目。

我们只挑其中的 2048,研究一个问题:

用户按下左键以后,程序到底应该怎么计算下一帧的棋盘?

很多人第一反应可能是:

遍历所有方块,然后不断修改它们的 x、y 坐标。

能不能做?

当然可以。

但这样写着写着,你很快就会掉进各种边界条件里面。

真正比较漂亮的实现,其实是:

不要先考虑二维坐标,而是把一次移动降维成“一行数字怎么合并”的问题。


一、2048 本质上是一个状态转换器

一个最普通的 2048 棋盘,可以表示成一个 4×4 矩阵:

2    0    2    4
4    4    0    0
2    2    2    0
0    0    0    0

如果玩家向左移动:

2    0    2    4

应该变成:

4    4    0    0

而:

4    4    0    0

应该变成:

8    0    0    0

再比如:

2    2    2    0

结果不是:

0    0    0    8

也不是:

2    4    0    0

正确结果是:

4    2    0    0

因为同一个方块在一次移动中只能参与一次合并。

所以整个游戏最关键的问题其实是:

旧棋盘
  ↓
执行某个方向的移动
  ↓
压缩
  ↓
合并
  ↓
再次压缩
  ↓
生成新数字
  ↓
得到新棋盘

这其实就是一个非常标准的:

State → Action → New State

模型。


二、先别处理二维棋盘

假设现在只有一行:

const row = [2, 0, 2, 4]

玩家向左移动。

我们先把所有的 0 去掉:

const values = row.filter(value => value !== 0)

得到:

[2, 2, 4]

这一步可以理解成:

压缩。


三、然后处理相邻数字

接下来,从左到右扫描:

[2, 2, 4]

发现:

2 == 2

那么合并:

2 + 2 = 4

于是得到:

[4, 4]

最后补零:

[4, 4, 0, 0]

完整代码可以先写成这样:

function moveRowLeft(row: number[]) {
  const values = row.filter(value => value !== 0)

  const result: number[] = []

  for (let i = 0; i < values.length; i++) {
    if (
      i + 1 < values.length &&
      values[i] === values[i + 1]
    ) {
      result.push(values[i] * 2)
      i++
    } else {
      result.push(values[i])
    }
  }

  while (result.length < 4) {
    result.push(0)
  }

  return result
}

测试一下:

console.log(
  moveRowLeft([2, 0, 2, 4])
)

结果:

[4, 4, 0, 0]

再测试:

console.log(
  moveRowLeft([2, 2, 2, 0])
)

结果:

[4, 2, 0, 0]

继续:

console.log(
  moveRowLeft([2, 2, 2, 2])
)

得到:

[4, 4, 0, 0]

这就是 2048 最核心的算法之一。


四、为什么 [2,2,2,2] 不能直接变成 [8,0,0,0]

这是很多第一次写 2048 的人会犯的错误。

假设:

2 2 2 2

向左移动。

第一对:

2 2

合成:

4

第二对:

2 2

合成:

4

最终应该是:

4 4 0 0

而不是:

8 0 0 0

原因就是:

一次移动过程中,新生成的数字不能立即再次参与同一次合并。

因此我们上面的代码中有这样一句:

i++

完整逻辑:

if (values[i] === values[i + 1]) {
  result.push(values[i] * 2)

  // 跳过已经参与合并的下一个元素
  i++
}

虽然只是一个 i++,实际上它表达了整个 2048 游戏中非常重要的一条业务规则。

这也是为什么我一直觉得小游戏特别适合学编程。

一个看起来不起眼的需求:

一个方块一次只能合并一次。

落到程序里,就是:

如何设计状态以及如何控制遍历过程。


五、顺便把分数也算出来

2048 每发生一次合并,都应该增加对应分数。

比如:

2 + 2 = 4

得 4 分。

32 + 32 = 64

得 64 分。

所以我们可以让算法同时返回:

interface MoveResult {
  row: number[]
  score: number
}

实现:

function moveRowLeft(row: number[]): MoveResult {
  const values = row.filter(value => value !== 0)

  const result: number[] = []
  let score = 0

  for (let i = 0; i < values.length; i++) {
    if (
      i + 1 < values.length &&
      values[i] === values[i + 1]
    ) {
      const merged = values[i] * 2

      result.push(merged)

      score += merged

      i++
    } else {
      result.push(values[i])
    }
  }

  while (result.length < 4) {
    result.push(0)
  }

  return {
    row: result,
    score
  }
}

例如:

const result = moveRowLeft([
  2, 2, 4, 4
])

console.log(result)

结果:

{
  row: [4, 8, 0, 0],
  score: 12
}

这里其实又出现了一个很典型的设计思路:

不要让函数偷偷修改全局状态。

最好:

输入
↓
纯计算
↓
返回结果

这样算法非常容易测试。


六、那向右移动怎么办?

聪明的地方来了。

根本没必要重新写一遍算法。

向右移动:

[2, 0, 2, 4]

实际上可以:

先翻转。

[4, 2, 0, 2]

然后执行向左移动:

[4, 4, 0, 0]

最后再翻回来:

[0, 0, 4, 4]

代码:

function moveRowRight(row: number[]) {
  const reversed = [...row].reverse()

  const result = moveRowLeft(reversed)

  return {
    row: result.row.reverse(),
    score: result.score
  }
}

这样就避免维护两套几乎一样的算法。

这其实属于一个很重要的编程技巧:

把多个问题转换成同一个标准问题。

左移已经解决。

那右移就转换成左移。


七、二维棋盘怎么处理?

到了这里,事情反而简单了。

假设:

const board = [
  [2, 0, 2, 4],
  [4, 4, 0, 0],
  [2, 2, 2, 0],
  [0, 0, 0, 0]
]

向左移动,只需要:

function moveLeft(board: number[][]) {
  let totalScore = 0

  const newBoard = board.map(row => {
    const result = moveRowLeft(row)

    totalScore += result.score

    return result.row
  })

  return {
    board: newBoard,
    score: totalScore
  }
}

是不是一下就简单了?

原来我们考虑的是:

16 个格子
x/y 坐标
谁挡住谁
谁移动到哪里
两个方块什么时候碰撞

现在变成:

处理第一行
处理第二行
处理第三行
处理第四行

复杂度瞬间降低。


八、上下移动怎么办?

向上和向下看起来麻烦。

其实仍然不用重新设计算法。

可以把:

列转成行。

例如:

2  4  2  0
0  4  2  0
2  0  2  0
4  0  0  0

第一列:

2
0
2
4

拿出来就是:

[2, 0, 2, 4]

然后继续调用:

moveRowLeft()

因此可以写一个矩阵转置函数:

function transpose(board: number[][]) {
  return board[0].map((_, columnIndex) =>
    board.map(row => row[columnIndex])
  )
}

然后向上移动:

function moveUp(board: number[][]) {
  const transposed = transpose(board)

  const result = moveLeft(transposed)

  return {
    board: transpose(result.board),
    score: result.score
  }
}

于是:

向左

成为整个游戏唯一真正需要实现的核心算法。

其他方向:

右 = 翻转 + 左 + 翻转

上 = 转置 + 左 + 转置

下 = 转置 + 右 + 转置

这种思维其实比代码本身更值得学。


九、千万不要用“棋盘满了”判断 Game Over

再讲一个很容易出错的问题。

很多人会写:

if (!hasEmptyCell(board)) {
  gameOver = true
}

看起来没毛病。

但实际上是错的。

考虑:

2    4    8    16
4    8    16   32
8    16   32   64
16   32   64   64

棋盘是不是满了?

是。

但是游戏结束了吗?

没有。

因为最后:

64 64

仍然可以合并。

所以 Game Over 必须同时满足:

没有空位
+
水平方向不能合并
+
垂直方向不能合并

例如:

function canMove(board: number[][]): boolean {
  const size = board.length

  // 存在空格
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      if (board[y][x] === 0) {
        return true
      }
    }
  }

  // 检查左右是否存在相同数字
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size - 1; x++) {
      if (board[y][x] === board[y][x + 1]) {
        return true
      }
    }
  }

  // 检查上下是否存在相同数字
  for (let y = 0; y < size - 1; y++) {
    for (let x = 0; x < size; x++) {
      if (board[y][x] === board[y + 1][x]) {
        return true
      }
    }
  }

  return false
}

然后:

const gameOver = !canMove(board)

这个判断才完整。


十、随机生成方块也有坑

2048 每移动一次,需要随机出现一个新数字。

通常:

90% 概率生成 2
10% 概率生成 4

最简单可以写:

function randomValue() {
  return Math.random() < 0.9 ? 2 : 4
}

但是随机位置不能直接:

const x = Math.floor(Math.random() * 4)
const y = Math.floor(Math.random() * 4)

为什么?

因为这个位置可能已经有方块。

一种更稳妥的实现是:

先找出所有空位:

interface Position {
  x: number
  y: number
}

function getEmptyCells(
  board: number[][]
): Position[] {
  const cells: Position[] = []

  for (let y = 0; y < board.length; y++) {
    for (let x = 0; x < board[y].length; x++) {
      if (board[y][x] === 0) {
        cells.push({
          x,
          y
        })
      }
    }
  }

  return cells
}

然后:

function addRandomTile(
  board: number[][]
) {
  const cells = getEmptyCells(board)

  if (cells.length === 0) {
    return board
  }

  const index = Math.floor(
    Math.random() * cells.length
  )

  const position = cells[index]

  const value =
    Math.random() < 0.9 ? 2 : 4

  const result = board.map(row => [...row])

  result[position.y][position.x] = value

  return result
}

相比不停随机:

do {
  x = random()
  y = random()
} while (当前位置被占用)

这种方式还有一个好处:

逻辑更加确定,也更加容易测试。


十一、React 其实只负责把结果显示出来

shiyu-games 里的这个 2048 使用的是:

React
TypeScript
Vite
TailwindCSS

但是这里有一个非常值得新手注意的问题:

不要把游戏算法全部写进 React JSX。

更合理的结构应该是:

UI
│
├── GameBoard
├── Tile
├── Score
└── Controls

       ↓

use2048Logic

       ↓

核心算法

├── moveLeft
├── moveRight
├── moveUp
├── moveDown
├── mergeRow
├── canMove
└── addRandomTile

React 负责:

const {
  board,
  score,
  move
} = use2048()

然后:

<GameBoard board={board} />

键盘:

window.addEventListener(
  'keydown',
  event => {
    if (event.key === 'ArrowLeft') {
      move('left')
    }
  }
)

至于:

到底怎么合并
加多少分
游戏有没有结束
哪里生成新方块

应该尽量放到纯逻辑层。


十二、这其实就是“关注点分离”

表面来看,我们只是写了一个小游戏。

但背后已经涉及几个非常重要的软件工程思想:

UI 和业务逻辑分离

二维问题降维

复杂操作标准化

不可变状态

纯函数

状态机

边界条件

随机算法

矩阵变换

甚至可以继续把移动抽象成:

type Direction =
  | 'left'
  | 'right'
  | 'up'
  | 'down'

function move(
  board: number[][],
  direction: Direction
) {
  switch (direction) {
    case 'left':
      return moveLeft(board)

    case 'right':
      return moveRight(board)

    case 'up':
      return moveUp(board)

    case 'down':
      return moveDown(board)
  }
}

上层完全不用知道算法细节。

它只需要:

move(board, 'left')

就行了。


十三、进一步还能怎么优化?

如果继续往工程化方向做,我还会加入单元测试。

比如:

describe('2048 merge', () => {
  test('[2,2,0,0]', () => {
    expect(
      moveRowLeft([2, 2, 0, 0]).row
    ).toEqual([4, 0, 0, 0])
  })

  test('[2,2,2,0]', () => {
    expect(
      moveRowLeft([2, 2, 2, 0]).row
    ).toEqual([4, 2, 0, 0])
  })

  test('[2,2,2,2]', () => {
    expect(
      moveRowLeft([2, 2, 2, 2]).row
    ).toEqual([4, 4, 0, 0])
  })

  test('[4,4,8,8]', () => {
    expect(
      moveRowLeft([4, 4, 8, 8]).row
    ).toEqual([8, 16, 0, 0])
  })
})

尤其是这种小游戏算法,特别适合用测试来验证。

因为:

输入明确
输出明确
几乎不依赖外部环境

它天生就是单元测试的好对象。


十四、为什么我推荐程序员写几个小游戏?

很多程序员学前端的时候,上来就是:

后台管理系统
用户管理
角色管理
菜单管理
增删改查

最后写了半年:

CRUD 很熟
算法和状态设计还是不会

小游戏反而会强迫你思考:

俄罗斯方块:

碰撞检测
矩阵旋转
消行算法

贪吃蛇:

队列
坐标系统
方向控制

2048:

数组压缩
合并规则
矩阵变换

五子棋:

棋盘状态
方向搜索
胜负判断

迷宫:

DFS
BFS
路径搜索

塔防:

路径规划
对象生命周期
碰撞检测
状态机

你会发现:

游戏其实是算法、数据结构和工程设计非常好的练习场。


最后

shiyu-games 这个项目表面上是一个小游戏合集:

https://github.com/Rodert/shiyu-games

但如果你愿意把每一个游戏拆开来看,其实可以挖出来很多值得学习的知识点。

今天讲的 2048 就是一个很好的例子。

一开始我们面对的问题是:

怎么让 16 个方块在二维棋盘里正确移动?

最后经过抽象,真正需要解决的问题只剩下:

[2, 0, 2, 4]

如何变成

[4, 4, 0, 0]

把二维问题降成一维。

把四种移动归一成一种移动。

把 UI 和算法拆开。

这才是这个小游戏里真正值得学习的东西。

很多时候,好的代码不是把复杂的问题“硬写出来”。

而是:

先把复杂的问题,变成一个简单的问题。

项目地址:

https://github.com/Rodert/shiyu-games

在这里插入图片描述

posted @ 2026-09-21 09:24  JavaPub  阅读(3)  评论(0)    收藏  举报