别急着写 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


浙公网安备 33010602011771号