2048 大概是我见过规则最简单、却最容易让人上头的数字游戏之一。明明只是在一个 4×4 的棋盘里滑动数字、合并相同的方块却能让无数人一局接一局停不下来。作为一个 Python 学习者这个游戏还有一个隐藏价值它背后的数据结构和逻辑几乎能把你入门阶段的语法全部串起来。我第一次带新人学 Python 时最喜欢的练手项目就是这个。不是因为 2048 有多简单而是因为它难写——难得恰到好处写棋盘要懂二维列表处理合并要懂循环和条件判断输赢要懂函数封装随机生成方块还要碰一下 random 库的用法。把这些打通你再看那些教程里的爬虫、量化分析、可视化例子会发现很多代码你都能看得懂了。这篇文章不聊高大上的框架也不做花哨的 GUI就用一个干净的终端版 2048把核心算法一步步拆开讲明白。你拿到手就能跑跑起来就能玩。1. 为什么拿 2048 当 Python 练手项目之前经常有人问我Python 基础语法学完了但写完几个练习题就不知道干什么怎么办我的建议往往是同一个——找一个“写完能玩、跑起来有反馈、逻辑量刚好卡在脑子上”的小项目。2048 就是教科书级别的选择。它好就好在三个地方。第一规则足够简单不需要游戏策划文档三句话能说清楚4×4 棋盘里有数字方块每次滑动所有方块朝同方向移动相同数字碰到一起就合并成它们的和每移动一次棋盘空位会随机出现一个新的 2 或 4。第二逻辑链路完整从数据结构设计到算法实现再到流程控制和交互输入一套下来就是一次完整的程序开发演练。第三可扩展性极强做完基础版你能加颜色、加动画、加最高分存档还能给游戏写一个自动演示的 AI。这种“做完还能继续折腾”的项目才不容易让学习热情熄灭。1.1 一个刚刚好的练手项目很多人学 Python 都会踩两个极端。一个极端是只抄别人的示例代码抄完等于没学脑子里留不下半点结构另一个极端是上手就啃过于庞大的项目比如直接去写爬虫框架、写量化交易回测系统结果被各种第三方库的报错劝退。2048 恰好站在中间。它用到的 Python 知识点非常集中二维列表的创建与索引、列表推导式、函数定义与返回值、while 循环、if 条件分支、random 随机数、字符串格式化、以及最容易被忽略的“深拷贝与浅拷贝”问题。这些知识点单个拿出来都很基础但要在一段代码里配合着用干净就需要一点组织能力。这正是实际开发里最看重的东西不是你会多少语法糖而是能不能把一堆基础零件拼成一个能正常运行的系统。另外这个项目还有一个很实际的好处——反馈极其直观。你写完一个显示函数运行一下就能看到棋盘写完合并逻辑玩两步就知道有没有 bug。不像某些后端项目吭哧半天看不到一个界面很容易失去耐心。1.2 技术选型为什么推荐“控制台版”也许有人会问2048 网页版那么多为什么不直接用 tkinter 做图形界面或者用 pygame 做动画我的回答是第一版最好别碰 GUI碰了就偏离学习重点了。控制台版有一个天然优势零第三方依赖。你只需要一个 Python 解释器不需要 pip install 任何东西代码量也小注意力可以全部集中在游戏的核心算法上。等你把棋盘数据结构、移动合并逻辑、输赢判断这些都吃透了再想套 GUI 其实非常简单——界面只是换了一层皮底层的move()、merge_row()函数可以原封不动搬过去复用。还有一个不那么正经、但很现实的原因终端版非常适合在等待编译、测试跑批的间隙摸两把。黑底白字的窗口看起来像在盯日志和正在 Debug 的界面没有本质区别。不过说真的游戏再好玩工作该交付还是要交付摸鱼也要讲基本法我是说——记得让代码跑完。方案第三方依赖学习重点开发耗时“隐蔽性”控制台版无算法与流程控制1-2 小时高像看日志tkinter 版标准库自带GUI 布局与事件绑定3-4 小时低明显有个窗口pygame 版需 pip 安装游戏循环、精灵、音效5 小时以上低窗口形态太明显2. 把游戏规则翻译成数据结构写游戏之前最重要的一步是把现实规则翻译成程序里的数据模型。2048 的棋盘是一张 4×4 的方格那么程序里最自然的对应物就是“二维列表”也就是一个每个元素又是一个列表的列表。别小看这个选择数据结构定好了后面所有算法都会顺。2.1 环境准备一个 Python 解释器就够了如果你还没装 Python先去官网下载对应操作系统的安装包。Windows 用户尤其要记得一个细节安装向导第一个界面就把“Add Python to PATH”勾上否则后面在终端敲python会提示找不到命令。装完之后打开终端Windows 是 PowerShell 或 CMDmacOS/Linux 是 Terminal输入python --version看到版本号就说明环境好了。本文的代码不挑版本Python 3.8 以上随便跑语法非常保守3.6 之后的版本问题都不大。编辑器随你VS Code 装个 Python 扩展就能自动补全和调试PyCharm 也行甚至纯记事本也能写。重点是能跑出结果工具不是瓶颈。整篇文章里我们只会用到random和os这两个标准库不需要额外安装任何包。2.2 4×4 棋盘用二维列表承载棋盘用二维列表表示每一行是一个列表棋盘本身是包含 4 个列表的列表。创建空棋盘最直接的写法是SIZE 4 board [[0] * SIZE for _ in range(SIZE)]这里有个很多新手必踩的坑不要写成board [[0] * SIZE] * SIZE。如果这样写表面上看是 4 行实际上四行都是同一个列表对象的引用。你修改board[0][0]board[1][0]、board[2][0]、board[3][0]会一起变因为它们是同一个东西。这就好像你把一张表格复印了四份结果发现四份其实是同一张纸。用列表推导式[[0] * SIZE for _ in range(SIZE)]才会生成四个独立的列表。理解了棋盘结构还要理解“二维列表的索引”这个高频考点。board[r][c]表示第 r 行第 c 列r 和 c 都从 0 开始。遍历棋盘时我们通常用两重 for 循环外层管行内层管列。后面所有算法都建立在这个索引规则上。2.3 先让棋盘“看得见”写一个打印函数写算法之前先把显示函数做了。原因很简单没有一个能看见棋盘的界面你根本没法调试移动逻辑。显示函数不需要花哨只要把二维列表里的数字按格子对齐打印出来就行。import os def display(board, score): os.system(cls if os.name nt else clear) print(fScore: {score}) for row in board: print( ------ * SIZE) print(| .join(f{cell:^6}| if cell else | for cell in row)) print( ------ * SIZE)这段代码里有两个小技巧。第一用f{cell:^6}做居中格式化让每个数字占 6 个字符宽度这样 2、4、16、128、2048 各种位数都能对齐不会越打越乱。第二cell为 0 时输出六个空格看起来就是一个空位。如果你懒得处理空位显示直接打印 0 也可以只是观感差一些。os.system()用来清屏Windows 上是clsmacOS/Linux 上是clear。这个细节后面还会再提因为清屏在不同系统里的兼容性是很多初学者会遇到的第一个环境问题。3. 核心算法移动与合并这才是灵魂2048 的灵魂不在界面而在移动合并的算法。很多人以为这个游戏的核心逻辑很复杂实际上它完全可以简化为一个很优雅的处理流程。我把整个问题拆成三个层次先解决“一行怎么合并”再解决“四个方向怎么统一”最后加上“随机生成新方块”。3.1 单行合并压缩、合并、再压缩把问题先降维——我们只处理一行。这一行里有数字也有 0表示空位。向左移动这一行本质上要完成三件事把所有非零数字挪到左边这叫压缩把相邻且相同的数字合并这叫合并合并之后可能又产生空位所以需要再一次压缩把数字重新靠左。举个例子。一行数据是[2, 0, 2, 2]。向左移动后正确结果应该是[4, 2, 0, 0]。为什么不是[2, 2, 0, 2]也不是[8, 0, 0, 0]因为 2048 的规则里一次滑动过程中一个数字只能参与一次合并。最右边的两个 2 合并成 4中间那个 2 跟左边第一个 2 本来可以由一次滑动依次合并出 8但规则不允许连续合并。所以只能是最右侧的两个 2 先碰头。用代码实现这个逻辑我推荐“三步法”def merge_row(row): # 第一步取出所有非零数字压缩到左边 tiles [v for v in row if v ! 0] # 第二步从左到右扫描相邻相同就合并被合并的位先置 0 score 0 for i in range(len(tiles) - 1): if tiles[i] tiles[i 1]: tiles[i] * 2 score tiles[i] tiles[i 1] 0 # 第三步再把非零数字压缩到左边后面补 0 tiles [v for v in tiles if v ! 0] tiles [0] * (SIZE - len(tiles)) return tiles, score第二步是核心。比如处理[4, 4, 4, 4]第一步拿到[4, 4, 4, 4]第二步扫描i0 时两个 4 合并变成[8, 0, 4, 4]i1 时 0 和 4 不相等不动i2 时两个 4 合并变成[8, 0, 8, 0]第三步压缩、补零最后得到[8, 8, 0, 0]。这个过程符合 2048 规则最左边两个 4 合并成 8最右边两个 4 合并成另一个 8这个 8 不会继续跟前面的 8 合并。你可能会问为什么合并且置 0 之后再压缩一遍就能保证“只合并一次”因为我们在合并过程中把被合并的位置改成了 0接下来的扫描就不会再把它当成活跃数字去跟后面的数字碰。这个置 0 的操作是防止连锁合并的关键。3.2 四个方向统一成“行左移”一行合并的逻辑写完了接下来处理整个棋盘。如果只有向左移动事情就简单了对每一行调用merge_row()即可。但 2048 有上下左右四个方向难道要写四份几乎一样的循环不需要这里有一个非常优雅的技巧把四个方向都转化成“行处理”。向左移动直接对每行merge_row()。 向右移动先把每行反转对反转后的行做merge_row()再把结果反转回来。原因是右移等价于“从右边压缩合并”反转之后就变成了从左边处理。 向上移动把棋盘转置对转置后的“行”也就是原来的列做merge_row()再转置回来。 向下移动先转置再反转每行做merge_row()再反转回来最后转置回来。转置操作在 Python 里可以一行搞定def transpose(board): return [list(row) for row in zip(*board)]zip(*board)会把原来按行存储的数据变成按列分组正好完成转置。不理解zip也没关系你只要知道这个函数能把棋盘的 4 行变成 4 列即可。完整的移动函数这样写def move(board, direction): total_score 0 if direction left: for i, row in enumerate(board): board[i], score merge_row(row) total_score score elif direction right: for i, row in enumerate(board): board[i], score merge_row(row[::-1]) board[i] board[i][::-1] total_score score elif direction up: board transpose(board) for i, row in enumerate(board): board[i], score merge_row(row) total_score score board transpose(board) elif direction down: board transpose(board) for i, row in enumerate(board): board[i], score merge_row(row[::-1]) board[i] board[i][::-1] total_score score board transpose(board) return board, total_score这里有一点需要注意函数内部直接修改了传入的board同时也返回了修改后的board。这种写法在实际项目里并不推荐因为它可能造成“调用方数据被悄悄改动”的混乱。但在游戏主循环里我们恰恰需要这个效果因为棋盘是共享状态的移动之后原棋盘应该直接更新。为了规避风险主循环里会提前保存一份旧棋盘的副本用于判断这次移动有没有产生实际变化。这个“统一成行处理”的思路是整篇代码里最值得反复品味的部分。它体现的正是编程里“抽象”的价值把复杂问题降维把重复代码收敛。以后你写任何涉及网格、表格、矩阵处理的程序都可以试着找找有没有类似的降维手段。3.3 随机生成新方块与开局移动之后要在空位中随机生成一个新方块。原版 2048 的设定是90% 的概率生成 210% 的概率生成 4。这个比例不是拍脑袋定的生成 4 的概率太高游戏难度会陡增玩家很难滚起来概率太低前期又过于平淡。所以沿用 90/10 这个经典比例就好。import random def add_new_tile(board): empty_cells [(r, c) for r in range(SIZE) for c in range(SIZE) if board[r][c] 0] if not empty_cells: return r, c random.choice(empty_cells) board[r][c] 2 if random.random() 0.9 else 4创建棋盘时一般先放两个初始方块def create_board(): board [[0] * SIZE for _ in range(SIZE)] for _ in range(2): add_new_tile(board) return board这两步很基础但不要漏。没有初始的两个数字整个棋盘全是 0玩家一开局就会懵。4. 游戏闭环主循环、计分与输赢判断核心函数都写完接下来就是把这些函数串成一个能持续运行的游戏主循环。这一部分包含输入处理、分数累计、胜负判断和完整代码属于“最后拼图”阶段。4.1 主循环接收指令、移动、生成新块游戏主循环的逻辑很简单显示棋盘 → 读玩家指令 → 移动棋盘 → 生成新块 → 再次显示棋盘。用 while 循环包住即可。def main(): board create_board() score 0 won False while True: display(board, score) if is_game_over(board): print(Game Over) break if is_win(board) and not won: print(恭喜合成了 2048按 y 继续挑战其他键退出) won True if input().strip().lower() ! y: break cmd input(WASD 移动Q 退出).strip().lower() if cmd q: break if cmd not in (w, a, s, d): continue old_board [row[:] for row in board] board, gained move(board, cmd) score gained # 只有棋盘真的发生变化才生成新方块 if board ! old_board: add_new_tile(board)这里有两个关键细节。第一old_board [row[:] for row in board]这一行绝不能用old_board board代替。因为old_board board只是给同一个对象起了个别名move()修改了boardold_board也会跟着变那board ! old_board永远不成立每次输入都会触发随机生成新块哪怕你只是按了一个无效键。这个 bug 非常隐蔽一旦中招游戏会“凭空冒数字”。所以深拷贝是必须的。第二为什么要先比较棋盘变化再生成新块因为 2048 的规则约定滑动方向如果没有产生任何有效移动就不应该刷新棋盘。如果每次都生成玩家往死路上按几个无效键棋盘就会被新块填满游戏失衡。4.2 计分把每一分的来路算清楚计分规则在不同的 2048 版本里略有差异最通用的是两个数字合并成新数字时加分为新数字本身。比如两个 2 合并成 4加 4 分两个 4 合并成 8加 8 分。这个逻辑我直接放进了merge_row()里所以move()的返回值里带上了total_score。主循环里每次移动后把gained累加到score再传给display()显示。分数不是拍脑袋想出来的它是玩家操作的直接反馈也是后期做排行榜、做存档的基础。4.3 输赢判断游戏什么时候结束胜利条件很简单棋盘上出现 2048。def is_win(board): return any(2048 in row for row in board)难点在失败判断。游戏结束有两种情况同时满足棋盘没有空格了而且相邻格子也没有可以合并的数字对。注意是“而且”不是“或者”。棋盘满了但还能合并游戏就没结束棋盘没满但相邻可合并游戏也没结束。所以先写一个“是否还有邻居相等”的检查函数def can_merge(board): for r in range(SIZE): for c in range(SIZE): if r 1 SIZE and board[r][c] board[r 1][c]: return True if c 1 SIZE and board[r][c] board[r][c 1]: return True return False这里最容易写错的地方是越界。判断右边邻居时要保证c 1 SIZE判断下边邻居时要保证r 1 SIZE。一旦漏掉这个条件程序会在访问最后一列或最后一行的邻居时抛IndexError。很多新手反复调试才发现是这个问题。有了can_merge失败判断就很清晰了def is_game_over(board): if any(0 in row for row in board): return False return not can_merge(board)4.4 先把完整可运行版本给你上面分段讲的代码拼起来就是完整版。为了避免结构分散这里放一个可以直接运行的整包。你复制到你的.py文件里运行就能玩。import os import random SIZE 4 TARGET 2048 def create_board(): board [[0] * SIZE for _ in range(SIZE)] for _ in range(2): add_new_tile(board) return board def add_new_tile(board): empty_cells [(r, c) for r in range(SIZE) for c in range(SIZE) if board[r][c] 0] if not empty_cells: return r, c random.choice(empty_cells) board[r][c] 2 if random.random() 0.9 else 4 def display(board, score): os.system(cls if os.name nt else clear) print(fScore: {score}) for row in board: print( ------ * SIZE) print(| .join(f{cell:^6}| if cell else | for cell in row)) print( ------ * SIZE) def transpose(board): return [list(row) for row in zip(*board)] def merge_row(row): tiles [v for v in row if v ! 0] score 0 for i in range(len(tiles) - 1): if tiles[i] tiles[i 1]: tiles[i] * 2 score tiles[i] tiles[i 1] 0 tiles [v for v in tiles if v ! 0] tiles [0] * (SIZE - len(tiles)) return tiles, score def move(board, direction): total_score 0 if direction left: for i, row in enumerate(board): board[i], score merge_row(row) total_score score elif direction right: for i, row in enumerate(board): board[i], score merge_row(row[::-1]) board[i] board[i][::-1] total_score score elif direction up: board transpose(board) for i, row in enumerate(board): board[i], score merge_row(row) total_score score board transpose(board) elif direction down: board transpose(board) for i, row in enumerate(board): board[i], score merge_row(row[::-1]) board[i] board[i][::-1] total_score score board transpose(board) return board, total_score def is_win(board): return any(TARGET in row for row in board) def can_merge(board): for r in range(SIZE): for c in range(SIZE): if r 1 SIZE and board[r][c] board[r 1][c]: return True if c 1 SIZE and board[r][c] board[r][c 1]: return True return False def is_game_over(board): if any(0 in row for row in board): return False return not can_merge(board) def main(): board create_board() score 0 won False while True: display(board, score) if is_game_over(board): print(Game Over) break if is_win(board) and not won: print(恭喜合成了 2048按 y 继续挑战其他键退出) won True if input().strip().lower() ! y: break cmd input(WASD 移动Q 退出).strip().lower() if cmd q: break if cmd not in (w, a, s, d): continue old_board [row[:] for row in board] board, gained move(board, cmd) score gained if board ! old_board: add_new_tile(board) if __name__ __main__: main()这个版本没有任何第三方依赖复制粘贴即玩。如果你只想照着我的代码跑通看到这里就够了。但如果你想把这个小项目变成“拿得出手”的作品下面这些进阶方向值得再看一遍。5. 进阶打磨从“能玩”到“好玩”第一版能运行、能玩之后你就有了一个绝佳的升级试验田。我建议按下面的顺序做打磨每加一个功能你都能体会到“代码在生长”的感觉。5.1 给数字上个色终端版默认的白字黑底玩久了确实有点干。ANSI 转义序列可以给输出加颜色而且不需要任何第三方库。基本思路是不同的数字用不同的颜色编码打印完成后用\033[0m重置。COLOR_MAP { 2: \033[93m, 4: \033[96m, 8: \033[92m, 16: \033[95m, 32: \033[94m, 64: \033[91m, 128: \033[1;33m, 256: \033[1;36m, 512: \033[1;32m, 1024: \033[1;35m, 2048: \033[1;31m }然后在display()里把每个数字包一层颜色代码cell_str f{cell:^6} color COLOR_MAP.get(cell, ) print(colored(f|{cell_str}|, end))注意 Windows 老版本 CMD 对 ANSI 支持不好颜色可能不会显示而是一堆乱码。现在 Windows Terminal 和较新的 PowerShell 都支持但如果你用的是远古 CMD这功能就别开了。做颜色之前先考虑终端环境这是过来人的经验。5.2 存档最高分没有存档的游戏成就感少一半。加一个最高分记录非常简单用文本文件就能搞定。游戏开始时读文件游戏结束时如果当前分数超过纪录就写回文件。def load_best(): try: with open(best_score.txt) as f: return int(f.read().strip()) except FileNotFoundError: return 0 def save_best(score): best load_best() if score best: with open(best_score.txt, w) as f: f.write(str(score))这里顺带练习了 Python 文件读写的三个点open()打开文件、with上下文自动关闭、异常处理捕获“文件不存在”。在显示界面时把最高分跟当前分数并排打出来游戏的竞技性马上就出来了。5.3 还能怎么扩展做完颜色和存档之后如果还有精力下面这些方向按难度递增支持撤销。每次移动前把棋盘和分数压进一个栈按 U 键就能回退一步。这个功能涉及“历史状态栈”是数据结构里的经典应用。自定义棋盘大小。把SIZE改成可以让玩家输入的值比如玩 5×5 或者 3×3。这听起来简单但你会发现合并逻辑和 UI 对齐都要跟着调。写一个自动演示模式。让程序随机走几千步统计平均得分。这个实验能让你直观地看到“无脑乱滑”和“策略滑动”之间的差距。制作一个 AI 自动玩。通过启发式评分比如空位数、最大数字的位置、平滑度来选方向。这条路已经足够你从算法小白跳到策略优化入门了。别小看这些扩展。现实中的很多项目核心功能可能只有几百行代码但真正让它好用的就是这些细致打磨和外围功能。6. 实测踩坑记录与排查清单这一节是我最想写的内容。下面的坑是我带人写这个项目时真实遇到过的几乎每条都有对应的“看起来很合理、实际上悄悄埋雷”的写法。6.1 合并只合并一次是这里最大的坑很多初学者写合并逻辑时会写出“连续合并”的效果。比如处理[4, 4, 4, 4]有人期待变成[16, 0, 0, 0]因为 4488816一路连加下去。但 2048 的规则明确一次滑动中同一个格子最多参与一次合并。所以正确结果是[8, 8, 0, 0]。这个坑的根源在于“一边合并一边继续扫描”的写法。解决思路就是我在 3.1 里写的三步法先压缩、合并时把被合并位置置 0、再压缩。被合并的格子一旦置 0后续扫描就不会再让它参与下一轮合并自然就保证了“只合并一次”。如果你发现自己写出来的合并结果异常可以先拿[2, 2, 4, 4]、[4, 4, 4, 4]、[2, 2, 2, 2]这三组数据分别测一测能通过这三组合并逻辑基本就稳了。6.2 深拷贝、随机数与“无效移动”这个坑我在 4.1 里提到过但值得单独拿出来再强调一次。old_board board不是复制棋盘只是取了个别名。正确做法是old_board [row[:] for row in board]每一行都复制一份。更深层的理解是Python 里的切片row[:]已经生成了新列表所以逐行切片就能完成二维列表的深拷贝。如果只是board[:]外层是新列表内层还是共享的旧行改一行会全盘皆动。另一个随机数相关的小坑是如果你在调试时总是得到同样的开局有可能是不小心调用了random.seed()并传了固定值。正常情况下不要设置 seed让每次运行都真正随机。如果设置了要记得那是“可复现实验”用的不是默认玩法。6.3 终端兼容清屏、输入、方向键跨平台这个事儿在写终端游戏时躲不开。os.system(cls if os.name nt else clear)是我推荐的做法但要知道背后的原因Windows 的 CMD 用的是clsLinux/macOS 的 shell 用的是clear不存在一个通用的清屏命令。输入方面初学阶段老老实实用input() WASD 就好。有同学问能不能直接用方向键我可以负责任地告诉你方向键在终端里会变成类似\x1b[A的转义序列处理起来很麻烦而且不同终端的行为还不完全一致。为了一个回车键的差异去折腾跨平台输入监听对新手来说是典型的“投入产出不成比例”。先用 WASD 玩起来以后做 GUI 版本时再考虑键盘事件。6.4 常见问题速查表现象可能原因解决方案按方向键没反应出现^[[A乱码终端把方向键转成了转义序列改用 WASD 输入数字对不齐棋盘越来越乱打印时没有固定字符宽度用f{cell:^6}统一宽度棋盘没变化却不断生成新块用old_board board比较改成[row[:] for row in board]游戏还能合并却提示 Game Over判断失败条件写错了同时检查“无空格”和“无相邻可合并”合并结果多翻了一倍合并逻辑允许连续合并合并后将被合并位置 0再压缩清屏不生效报clear不存在Windows 环境用了clear用os.name nt区分cls/clear如果你在运行过程中碰到了表里没列到的问题也别太急。把merge_row()单独拎出来多喂几组数据测试是排查合并问题最快的方法。对于这种小项目调试的价值不在于修好一个 bug而在于建立“把算法拆开验证”的思维习惯。我自己带新人刷 Python 基础前前后后试过不少项目2048 是少数几个能让人主动加练的。别小看这个终端小游戏你把merge_row和move弄明白以后遇到任何数组变换、表格数据、地图寻路的问题思路都会顺很多。摸鱼也好、消遣也罢重点是你亲手写出的第一版能跑、能玩就已经比那些收藏了一堆教程却从不落地的人强太多了。