你玩过 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 的核心动作可以抽象成四个独立函数,每个只做一件事:
new_board()和spawn():负责棋盘的创建和随机生成新块merge_row():把一行数字"压缩 + 合并",是全部算法的地基move():处理四个方向的滑动,内部复用merge_row()game_over()和draw():判断终局、渲染画面
棋盘的数据结构用最简单的二维列表: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. 扩展想法
- 最高分存档:把最高分写入一个文本文件,下次启动时读出来对比,用
open('score.txt', 'r')加try/except处理文件不存在的情况 - 彩色数字:按数字大小用 ANSI 转义序列(如
\033[31m)给不同数字上色,终端立刻变好看 - 更大棋盘:把
SIZE改成 5 或 6,难度直线上升,而算法一行都不用改 - 滑动动画:用
time.sleep(0.05)分步显示合并过程,能做出"数字在滑动"的错觉
8. 总结与练习
这篇的核心收获有三个:一是"压缩 + 合并"两步走的行合并算法,二是用旋转复用代码的巧思,三是把游戏拆成独立小函数的模块化思路。2048 的算法并不难,难的是组织代码的方式——函数拆得好,后面加功能就是往架子上挂东西。
练习建议:
- 不借助旋转,直接写四个方向的
move_left、move_right等独立函数,对比两种写法的代码量 - 给游戏加"撤销上一步":每次移动前用
copy.deepcopy把棋盘存进列表,按 u 键回退 - 写一个自动玩 2048 的 AI:随机走一步,记录得分,统计哪种策略活得最久
最后提醒一点:如果运行时报 ModuleNotFoundError,说明环境里没有 Python,先装解释器再玩;clear 命令在某些精简终端里不存在,清屏失败不影响游戏,只是画面会叠在一起,照样能玩。
💡 旋转大法在二维数组处理里非常通用,做俄罗斯方块的旋转判定、图像翻转、矩阵转置时都能用到,值得熟练掌握。