你玩过 2048 吗?那个在 4x4 棋盘上滑动数字、两两合并,直到凑出 2048 的小游戏。本文带你用 Python 纯标准库实现一个终端版:读完你不仅能亲手玩,还能彻底搞懂"移动、合并、生成新块"这套核心算法,以后再看到任何棋盘类游戏都不会发怵。

1. 游戏规则

规则一句话就能讲清:棋盘是 4x4 网格,每次操作把全部数字朝一个方向滑动,相同的数字碰到一起就合并成它们的和(2+2=4,4+4=8……),合并后随机空位会生成一个新的 2 或 4。当某个格子出现 2048,游戏胜利;当棋盘被填满且再也无法合并,游戏结束。

这里有个新手最容易忽略的细节:一次滑动中,每对数字只合并一次。比如一行是 [2, 2, 2, 2],向左滑动后应该是 [4, 4, 0, 0],而不是一路合并成 [8, 0, 0, 0]。这个规则直接决定合并算法的写法,我们后面会专门处理。

操作方式用键盘的四键:

按键含义
w / a / s / d上 / 左 / 下 / 右 滑动
q退出游戏

整个游戏的流程可以概括成一个循环:显示棋盘 → 读取方向 → 计算新棋盘 → 判断是否结束。所谓"得分",就是所有合并产生的数字之和:2+2 合并出 4 得 4 分,4+4 合并出 8 得 8 分。这个计分规则简单直接,也最容易验证算法对不对——手算一局,对着分数就能排查 bug。

2. 模块拆解

写游戏前先拆模块。2048 的核心动作可以抽象成四个独立函数,每个只做一件事:

棋盘的数据结构用最简单的二维列表:board[r][c] 表示第 r 行第 c 列的数值,0 表示空格。先看看数据长什么样:

SIZE = 4

# board[r][c] 存数字,0 表示空格
board = [[0] * SIZE for _ in range(SIZE)]
board[0][0] = 2
board[1][1] = 4
for row in board:
    print(row)

创建棋盘的关键是列表推导式 [[0] * SIZE for _ in range(SIZE)]——千万不要写成 [[0] * SIZE] * SIZE,后者创建的是"同一行的四个引用",改一格全行跟着变,这是 Python 新手必踩的坑。接着实现创建与生成新块:

import random

SIZE = 4

def new_board():
    return [[0] * SIZE for _ in range(SIZE)]

def spawn(board):
    empty = [(r, c) for r in range(SIZE) for c in range(SIZE)
             if board[r][c] == 0]
    if not empty:
        return False
    r, c = random.choice(empty)
    board[r][c] = 2 if random.random() < 0.9 else 4
    return True

board = new_board()
spawn(board)
spawn(board)
for row in board:
    print(row)

生成新块时,90% 的概率出 2、10% 的概率出 4,和原版游戏一致;random.choice 从空位列表里随机挑一个位置。spawn 返回 False 表示没有空位可放,这个返回值后面判断游戏结束会用到。

到这里,"棋盘是什么样"和"新块从哪来"已经清楚了。注意空位列表的推导式 [(r, c) for r in range(SIZE) for c in range(SIZE) if board[r][c] == 0] 用了两层 for 加一个 if 过滤,这种"先枚举全部坐标、再按条件筛选"的写法在棋盘类程序里非常常见,值得记住。

3. 核心算法:一行怎么合并

四个方向的移动看起来复杂,其实只要解决"一行向左合并"这一个问题,其他方向都能通过旋转棋盘复用同一段代码。合并分两步走:

第一步压缩:把非零数字挤到左边,去掉中间的空格;第二步合并:从左到右扫描,发现相邻相同就合并一次,然后继续往后扫——注意是"合并后跳过这一对",这正是"一次滑动只合并一次"的落点。

SIZE = 4

def merge_row(row):
    nums = [x for x in row if x != 0]      # 压缩:去掉 0
    out = []
    gain = 0
    i = 0
    while i < len(nums):
        if i + 1 < len(nums) and nums[i] == nums[i + 1]:
            out.append(nums[i] * 2)        # 合并一次
            gain += nums[i] * 2
            i += 2                         # 跳过已合并的一对
        else:
            out.append(nums[i])
            i += 1
    return out + [0] * (SIZE - len(out)), gain

for row in ([2, 2, 2, 2], [2, 0, 2, 4], [4, 4, 8, 0], [0, 0, 0, 0]):
    print(row, '->', merge_row(row))

测试输出可以验证规则:[2, 2, 2, 2] 合并成 [4, 4, 0, 0] 而不是 [8, 0, 0, 0];[2, 0, 2, 4] 先压缩成 [2, 2, 4],再合并出 [4, 4, 0, 0]。函数同时返回得分 gain,合并出多少就加多少,方便主循环累加总分。

4. 四两拨千斤:旋转大法

合并函数只认识"向左",那向下、向右、向上怎么办?答案是先把整个棋盘转个方向,让目标方向变成"左",合并完再转回去。旋转 90 度有一行经典写法 list(zip(*board[::-1])),值得背下来:

SIZE = 4

def merge_row(row):
    nums = [x for x in row if x != 0]
    out = []
    gain = 0
    i = 0
    while i < len(nums):
        if i + 1 < len(nums) and nums[i] == nums[i + 1]:
            out.append(nums[i] * 2)
            gain += nums[i] * 2
            i += 2
        else:
            out.append(nums[i])
            i += 1
    return out + [0] * (SIZE - len(out)), gain

def move(board, d):
    """d: 0左 1下 2右 3上"""
    b = [list(row) for row in board]
    for _ in range(d):                     # 顺时针旋转 d 次
        b = [list(row) for row in zip(*b[::-1])]
    rows, gain = [], 0
    for row in b:
        new_row, g = merge_row(row)
        rows.append(new_row)
        gain += g
    for _ in range(d):                     # 逆时针转回去
        rows = [list(row) for row in zip(*rows)][::-1]
    return rows, gain, rows != board

board = [[2, 2, 0, 0],
         [0, 0, 0, 0],
         [0, 0, 0, 0],
         [0, 0, 0, 0]]
print('向左:', move(board, 0))
print('向下:', move(board, 1))

move(board, d) 用方向编号 d(0 左、1 下、2 右、3 上)控制旋转次数:先顺时针转 d 次,合并,再逆时针转 d 次复原。以"向下"为例,顺时针转 90 度后,原来的"下"恰好变成"左"。返回值里 rows != board 用来判断棋盘是否真的变了——如果没变,主循环就不该生成新块,否则玩家随便按一个无效方向,棋盘也会莫名其妙长出数字。

为什么旋转而不是直接写四个方向的合并?除了省代码,更重要的是减少出错面:四个方向的逻辑各不相同,每个方向都要单独调试;而旋转方案只有一套合并逻辑,四个方向共用,测好一个方向就等于测好了四个。工程上这叫"单一实现",是降低 bug 密度的有效手段。

5. 胜负判定与画面渲染

游戏结束的条件:棋盘满了,且任意相邻两格(上下或左右)都不相等。反过来想,"存在空位"或者"存在可合并的相邻对"都意味着还能继续,只有两者都不满足才结束:

import os

SIZE = 4

def game_over(board):
    if any(0 in row for row in board):
        return False
    for r in range(SIZE):
        for c in range(SIZE):
            v = board[r][c]
            if (r + 1 < SIZE and board[r + 1][c] == v) or \
               (c + 1 < SIZE and board[r][c + 1] == v):
                return False
    return True

def draw(board, score):
    os.system('cls' if os.name == 'nt' else 'clear')
    print('得分:', score)
    for row in board:
        print('+----' * SIZE + '+')
        print(''.join(f'|{v:^4}' if v else '|    ' for v in row) + '|')
    print('+----' * SIZE + '+')

demo = [[2, 4, 8, 16], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
draw(demo, 30)
full = [[2, 4, 8, 16], [4, 8, 16, 32], [8, 16, 32, 64], [16, 32, 64, 128]]
print('有空格时结束吗?', game_over(demo))      # False
print('满盘且无相邻相等时结束吗?', game_over(full))  # True

draw() 每次画面前调用 os.system('clear')(Windows 上是 cls)清屏,让棋盘始终固定在同一个位置;格式化字符串 f'|{v:^4}' 把数字居中在 4 个字符宽度里,0 则画成空格,再用 + 和 - 拼出表格边框,终端里看起来非常整齐。

6. 完整代码

把前面的函数拼起来,加上主循环,就是一个完整的 2048 终端版。保存为 2048.py,运行 python 2048.py 开玩:

import os
import random

SIZE = 4

def new_board():
    return [[0] * SIZE for _ in range(SIZE)]

def spawn(board):
    empty = [(r, c) for r in range(SIZE) for c in range(SIZE)
             if board[r][c] == 0]
    if not empty:
        return False
    r, c = random.choice(empty)
    board[r][c] = 2 if random.random() < 0.9 else 4
    return True

def merge_row(row):
    nums = [x for x in row if x != 0]
    out = []
    gain = 0
    i = 0
    while i < len(nums):
        if i + 1 < len(nums) and nums[i] == nums[i + 1]:
            out.append(nums[i] * 2)
            gain += nums[i] * 2
            i += 2
        else:
            out.append(nums[i])
            i += 1
    return out + [0] * (SIZE - len(out)), gain

def move(board, d):
    b = [list(row) for row in board]
    for _ in range(d):
        b = [list(row) for row in zip(*b[::-1])]
    rows, gain = [], 0
    for row in b:
        new_row, g = merge_row(row)
        rows.append(new_row)
        gain += g
    for _ in range(d):
        rows = [list(row) for row in zip(*rows)][::-1]
    return rows, gain, rows != board

def game_over(board):
    if any(0 in row for row in board):
        return False
    for r in range(SIZE):
        for c in range(SIZE):
            v = board[r][c]
            if (r + 1 < SIZE and board[r + 1][c] == v) or \
               (c + 1 < SIZE and board[r][c + 1] == v):
                return False
    return True

def draw(board, score):
    os.system('cls' if os.name == 'nt' else 'clear')
    print('得分:', score)
    for row in board:
        print('+----' * SIZE + '+')
        print(''.join(f'|{v:^4}' if v else '|    ' for v in row) + '|')
    print('+----' * SIZE + '+')

def main():
    board = new_board()
    spawn(board)
    spawn(board)
    score = 0
    keys = {'a': 0, 's': 1, 'd': 2, 'w': 3}
    while True:
        draw(board, score)
        if game_over(board):
            print('游戏结束,最终得分:', score)
            break
        try:
            cmd = input('w/a/s/d 移动,q 退出 > ').strip().lower()
        except EOFError:
            break
        if cmd == 'q':
            break
        if cmd not in keys:
            continue
        board, gain, changed = move(board, keys[cmd])
        if changed:
            score += gain
            spawn(board)

if __name__ == '__main__':
    main()

主循环的流程:画棋盘 → 检查是否结束 → 读输入 → 移动 → 有变化就加分并生成新块。用字典 keys 把按键映射成方向编号;input() 读到的内容先 strip() 去掉首尾空白;遇到 EOFError(比如玩家按 Ctrl+D)就安全退出,不会抛异常。完整程序只有 80 多行,而且每个函数都可以单独拿出来测试。

如果你想调整难度,把 SIZE 改成 5 就是 5x5 棋盘,合并逻辑一行都不用动;把 random.random() < 0.9 的概率调低,新块出 4 的概率变高,游戏会明显更难——这两个参数就是 2048 的全部"调参旋钮"。

7. 扩展想法

8. 总结与练习

这篇的核心收获有三个:一是"压缩 + 合并"两步走的行合并算法,二是用旋转复用代码的巧思,三是把游戏拆成独立小函数的模块化思路。2048 的算法并不难,难的是组织代码的方式——函数拆得好,后面加功能就是往架子上挂东西。

练习建议:

最后提醒一点:如果运行时报 ModuleNotFoundError,说明环境里没有 Python,先装解释器再玩;clear 命令在某些精简终端里不存在,清屏失败不影响游戏,只是画面会叠在一起,照样能玩。

💡 旋转大法在二维数组处理里非常通用,做俄罗斯方块的旋转判定、图像翻转、矩阵转置时都能用到,值得熟练掌握。