如果让我选一个最适合用来练手 C++ 的经典小项目,2048 绝对排前三。棋盘就一个 4×4 二维数组,规则几句话能讲完,但真正实现起来却能踩出一堆 C++ 的细节坑:数组传参退化、随机数种子、控制台方向键读取、中文乱码、合并逻辑的边界条件……把这些坑都趟平了,你对 C++ 的理解会比啃十遍语法书都管用。
这篇文章我打算按一个完整的项目开发过程来讲:从环境准备到数据结构设计,从核心的移动合并算法到控制台交互,再到我实际测试中踩过的坑和后续可以扩展的方向。不管你是课设需要、面试准备,还是纯粹想用一个小游戏验证自己的 C++ 学习成果,都可以直接照着撸一遍。代码量不大,重点在思路。
1. 开发环境选择与项目规划:先想清楚再动手
1.1 编译器选型:VS Code 也可以,但别再折腾半天环境
搜 C++ 相关内容时,经常会看到“error: microsoft visual c++ 14.0 or greater is required”这类报错,很多新手一看到 MSVC、Build Tools、MinGW 就被绕晕了。这里我直接说结论:写 2048 这种纯控制台小程序,你只需要一个能编译 C++11 的编译器,不需要任何图形库。
三个常见方案我帮你对比一下:
| 方案 | 适合人群 | 优点 | 需要处理的问题 |
|---|---|---|---|
| Visual Studio Community(Windows) | 纯新手 | 装完直接用,F5 跑起来,不需要手动配环境 | 安装包比较大 |
| VS Code + MinGW-w64(Windows) | 喜欢折腾编辑器的人 | 轻量,界面舒服 | 需要手动配置 tasks.json 和 launch.json,编译器要加 PATH |
| g++ / clang(macOS / Linux) | 有一定命令行基础 | 一条命令编译,最接近“真实开发” | 终端操作对纯新手有门槛 |
我给新手的建议是:如果目标只是把 2048 写出来、把 C++ 语法跑通,直接用 Visual Studio Community,新建一个“控制台应用”项目,选 C++ 项目类型,代码写进去直接编译就行,省下的时间拿去调游戏逻辑不香吗。
如果你确实想用 VS Code,这里给一个最小可行的配置思路:安装 C/C++ 扩展后,在.vscode/tasks.json里配置编译任务,参数大概是:
{ "version": "2.0.0", "tasks": [ { "label": "build 2048", "type": "shell", "command": "g++", "args": ["-std=c++11", "-o", "2048.exe", "2048.cpp"], "group": "build" } ] }调试配置launch.json指向生成的 exe 即可。这套东西一次性配好,后面所有练习项目都能复用。
1.2 功能拆解:2048 本质上只需要五个模块
写代码之前最关键的一步,是把游戏规则翻译成程序逻辑。2048 的游戏规则其实可以用五句话覆盖:
- 棋盘是 4×4,每个格子要么是空,要么是 2 的若干次幂。
- 玩家按方向键,所有方块朝该方向移动,中间不留空位。
- 两个值相同的方块在移动方向相遇时合并成一个,数值翻倍,且一次移动中不能连续合并。
- 每次有效移动后,在空白位置随机生成一个新方块(通常 90% 是 2,10% 是 4)。
- 当没有任何空位、且上下左右都没有可合并的相邻方块时,游戏结束;合出 2048 则胜利。
对应到代码模块,五个部分就够了:
- 棋盘模块:用一个
int board[4][4]表示棋盘状态,用score记录累计得分; - 生成模块:负责在空格子随机生成 2 或 4;
- 移动模块:处理四个方向的移动和合并,这是整个游戏的核心;
- 判定模块:判断是否胜利、是否无路可走;
- 渲染交互模块:控制台输出棋盘、读取方向键输入。
模块划分清楚之后,你写的就不是“一坨代码”,而是可以单独测试的零件。我实际写的时候就是先把移动逻辑拎出来,用一个硬编码的棋盘测试,确定没问题了再接入主循环,排查问题的效率高很多。
1.3 核心常量与状态设计:把会变的东西都提到上面
写 2048 的时候,棋盘尺寸4、胜利目标值2048、新方块生成概率这些,都应该定义成常量,不要写死在代码各个角落。原因很简单:第一,以后想改成 5×5 或者 4096 目标值,只改一个地方;第二,面试或者课设答辩时,这能体现你写出可维护代码的意识。
我习惯这样组织全局状态:
const int SIZE = 4; // 棋盘边长 const int WIN_VALUE = 2048; // 胜利目标 const int BOARD_BYTES = SIZE * SIZE * sizeof(int); int board[SIZE][SIZE]; // 棋盘数组 int score = 0; // 当前得分 bool isWin = false; // 是否已经达成胜利条件 bool isOver = false; // 是否游戏结束这里有个 C++ 新手特别容易踩的坑:board[4][4]如果作为函数参数传递,会退化成指向一维数组的指针,sizeof(board)拿到的就不是整个棋盘的大小,而是4 * sizeof(int)或者甚至是指针大小。所以我的做法是尽量在全局直接访问board,保持简单;如果哪天你想把棋盘封装成类,就不要再对board用sizeof,而是明确用SIZE * SIZE来控制循环和清空范围。
2. 2048 移动合并算法:整个项目的灵魂
2.1 规则落地的关键:把“移动 + 合并”拆成两步
先说一个很多教程里没讲透彻的地方:一行方块朝一个方向移动,正确的处理方式是“压缩 → 合并 → 再压缩”。
以向左移动为例,将一行[2, 0, 2, 4]向左推:
- 第一次压缩:把非零元素全部靠左,塞满前面的位置,得到
[2, 2, 4, 0]; - 合并扫描:从左到右比较相邻元素,如果相等就合并到左边那个位置,右边清零。比较
2和2相等,合并成4,得到[4, 0, 4, 0]; - 第二次压缩:再次把非零元素靠左,得到
[4, 4, 0, 0]。
为什么合并之后还要再压缩一遍?因为合并过程中会产生“空洞”。比如[2, 2, 2, 2]如果只做合并扫描,第一次比较合并后变成[4, 0, 2, 2],继续扫描到后面,2和2又合并成4,最终得到[4, 0, 4, 0],中间就留了一个 0 的空位。必须再用一次压缩把所有数字顶到边上,棋盘的布局才符合 2048 的物理直觉。
我一开始写的时候直接漏掉了第二次压缩,结果跑出来的棋盘中间总是有空位,方块没有完全靠边,看起来非常奇怪。这个细节是排查过程中印象最深的一个问题。
2.2 单行处理方法:四行共用同一套逻辑
既然规则是对“每一行”做的,那我们就把“处理一行”的逻辑提取成函数。我自己用的写法是:
void slideRow(int row[]) { // 1. 压缩:非零元素全部靠左 int tmp[SIZE] = {0}; int idx = 0; for (int i = 0; i < SIZE; i++) { if (row[i] != 0) { tmp[idx++] = row[i]; } } // 2. 合并:相邻相同元素合并到左侧 for (int i = 0; i < SIZE - 1; i++) { if (tmp[i] != 0 && tmp[i] == tmp[i + 1]) { tmp[i] *= 2; score += tmp[i]; // 合并得分 tmp[i + 1] = 0; } } // 3. 再次压缩:去掉合并产生的空洞 idx = 0; for (int i = 0; i < SIZE; i++) { if (tmp[i] != 0) { row[idx++] = tmp[i]; } } for (int i = idx; i < SIZE; i++) { row[i] = 0; } }这样左移的时候,只要对四行分别调用slideRow(board[i])就可以了。注意这一步里没有做“每个方块只能合并一次”的特殊标记,因为“压缩-合并-再压缩”这个流程天然保证了每个原位置的方块最多参与一次合并——合并完右侧变 0,后续扫描就不会再碰到它了。
2.3 四个方向只写一套代码:矩阵旋转的妙用
如果说“处理行”是 2048 的第一层抽象,那么“四个方向”就是第二层。最直观的写法是把左、右、上、下各写一个函数,四个方向复制四份。但你会发现四个函数的逻辑几乎一模一样,只是数组遍历的行列顺序不同。
更好的方案是:把棋盘旋转一下,统一当成“左移”处理,处理完再转回来。
顺时针旋转 90 度的逻辑:
void rotateBoard() { int tmp[SIZE][SIZE]; for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { tmp[j][SIZE - 1 - i] = board[i][j]; } } for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { board[i][j] = tmp[i][j]; } } }然后移动的入口函数就可以这样设计:
void move(int dir) { // dir: 0=左, 1=上, 2=右, 3=下 switch (dir) { case 1: rotateBoard(); break; // 上移 -> 旋转后左移 case 2: rotateBoard(); rotateBoard(); break; // 右移 -> 旋转180度 case 3: rotateBoard(); rotateBoard(); rotateBoard(); break; // 下移 } bool changed = false; for (int i = 0; i < SIZE; i++) { int before[SIZE]; for (int j = 0; j < SIZE; j++) before[j] = board[i][j]; slideRow(board[i]); for (int j = 0; j < SIZE; j++) { if (before[j] != board[i][j]) { changed = true; break; } } } // 转回原始方向 switch (dir) { case 1: rotateBoard(); rotateBoard(); rotateBoard(); break; case 2: rotateBoard(); rotateBoard(); break; case 3: rotateBoard(); break; } if (changed) { spawnTile(); // 只有棋盘发生变化时才生成新方块 } }这个方案的隐含收益是:你不必为四个方向分别调试。旋转函数只要写对一次,四个方向的移动就全部正常,代码维护成本直线下降。对于这种 4×4 的小棋盘,旋转产生的开销完全忽略不计。
这里的changed变量也很关键。2048 的规则是“每次有效移动后才生成新方块”,如果玩家在方块已经全部靠左的情况下还按左键,棋盘没有任何变化,不应该生成新方块。我用before数组对比移动前后的每一行,只要有一行发生变化就认为这次移动有效。
3. 完整源码模块拆解:从随机生成到胜负判定
3.1 棋盘初始化与随机生成:两个细节很重要
开局时棋盘上应该有两个初始方块。初始化函数我这样写:
void initGame() { for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { board[i][j] = 0; } } score = 0; isWin = false; isOver = false; spawnTile(); spawnTile(); }新方块生成函数,我建议第一步先收集所有空位,再从中随机选一个。不要写成“随机选行再随机选列,如果非空就重试”,因为这种重试法在棋盘快满时效率很低,而且概率分布不均匀:
void spawnTile() { int emptyPos[SIZE * SIZE][2]; int emptyCount = 0; for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { if (board[i][j] == 0) { emptyPos[emptyCount][0] = i; emptyPos[emptyCount][1] = j; emptyCount++; } } } if (emptyCount == 0) return; int pos = rand() % emptyCount; int value = (rand() % 10 < 9) ? 2 : 4; // 90% 生成 2,10% 生成 4 board[emptyPos[pos][0]][emptyPos[pos][1]] = value; }随机数方面,经典 2048 是 90% 概率生成 2,10% 生成 4。rand()的随机性虽然一般,但做游戏完全够用。记得在main里用srand(time(NULL))设置种子,否则每次启动游戏生成的序列都一样。
3.2 胜负判定:这可是最容易想简单的地方
很多新手写判断结束只检查“棋盘满了没”,但这其实不严谨。2048 的结束条件是“满了”并且“上下左右都没有相邻相等的方块”。比如下面这种状态:
2 4 2 4 4 2 4 2 2 4 2 4 4 2 4 0棋盘虽然没满,但如果没有任何空格,且怎么移都合并不了,才会结束。正确的判定逻辑是:先找有没有空格,有空格说明还能生成新方块,没结束;再检查每一行、每一列是否存在相邻相等,只要有任意一对相邻相等,就还能合并,也没结束。只有当两者都不满足时,游戏才真正结束。
bool isGameOver() { for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { if (board[i][j] == 0) return false; if (j + 1 < SIZE && board[i][j] == board[i][j + 1]) return false; if (i + 1 < SIZE && board[i][j] == board[i + 1][j]) return false; } } return true; }胜利判定相对简单,每次移动后扫描全盘,看是否出现了不小于WIN_VALUE的方块:
bool checkWin() { for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { if (board[i][j] >= WIN_VALUE) return true; } } return false; }顺带一提,这里用>=而不是==,是因为合出 2048 之后继续玩,方块可能变成 4096、8192,它们显然也算“已经达到过胜利条件”。
3.3 主循环结构:把游戏状态机跑起来
游戏主循环可以设计得非常简单,本质就是一个 while 循环:渲染当前棋盘,读输入,执行移动,然后判定状态。我用的结构是:
int main() { srand(time(NULL)); initGame(); while (true) { clearScreen(); printBoard(); if (isGameOver()) { cout << "Game Over! Final Score: " << score << endl; break; } int dir = getDirection(); if (dir == -1) continue; // 无效按键 if (dir == 4) break; // 退出游戏 move(dir); if (checkWin()) { clearScreen(); printBoard(); cout << "You Win! Score: " << score << endl; cout << "Continue? (y/n): "; char ch; cin >> ch; if (ch != 'y' && ch != 'Y') break; isWin = true; // 把胜利标志置位,防止每次移动都触发 } } return 0; }这里有一个很实际的体验问题:如果玩家合出 2048 后不想退出,还想继续挑战更高数字,怎么办?我的处理是询问是否继续,继续之后把isWin标志置位,避免每次移动后都再次弹胜利提示。你也可以做成“达到 2048 后直接结束”,但那样游戏的扩展性就差了很多。
4. 控制台渲染与方向键输入:细节决定体验
4.1 棋盘输出:setw 对齐和中文乱码的那些事
控制台游戏也是要讲“UI 体验”的。如果输出像下面这样:
2 4 0 0虽然信息没丢,但数字长短不一时对齐很丑。用的setw可以很轻松地解决:
void printBoard() { cout << "Score: " << score << endl; for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { cout << setw(5) << board[i][j]; } cout << endl << endl; } }setw(5)会保证每个输出至少占 5 个字符宽度,数字位数不同的情况也能整齐对齐。
再来说一个真坑:中文乱码。如果你用的是 Windows 控制台,编译器以 UTF-8 编码保存源码,但控制台默认按 GBK 显示中文,就会打印出乱码。解决办法有几个:
- 游戏内部全用英文输出,最简单,最不容易出问题;
- 在
main最开始调用system("chcp 65001 > nul")切换到 UTF-8 代码页; - 用 Visual Studio 时,把源码文件保存为带 BOM 的 UTF-8。
我自己写小项目时图省事,界面用英文。你要是在课设演示时想用中文界面,推荐system("chcp 65001")这个方法,实测在 Win10 的 Windows Terminal 里效果没问题。
4.2 方向键读取:_getch() 为什么返回两个字节
控制台游戏读方向键,最常用的函数是里的_getch()(gcc 环境下可能叫getch())。它不需要按回车键就能立即拿到用户按键,很适合做实时输入。
但方向键比较特殊:按一次方向键,_getch()会返回两次结果。第一次返回 224(某些环境是 0),代表“这是一个扩展键”,第二次返回具体的扫描码:上 72、下 80、左 75、右 77。
int getDirection() { int key = _getch(); if (key == 224 || key == 0) { key = _getch(); switch (key) { case 72: return 1; // 上 case 80: return 3; // 下 case 75: return 0; // 左 case 77: return 2; // 右 } } // 兼容 WASD if (key == 'w' || key == 'W') return 1; if (key == 's' || key == 'S') return 3; if (key == 'a' || key == 'A') return 0; if (key == 'd' || key == 'D') return 2; if (key == 'q' || key == 'Q') return 4; return -1; }我建议在按键处理里同时支持 WASD 和方向键。原因是很多 IDE 的内嵌控制台对方向键的扫描码处理得很奇怪,我遇到过在 Visual Studio 调试窗口里方向键完全无效,但 WASD 能正常用的情况。两套方案都做,走到哪里都不慌。
4.3 清屏与跨平台宏:一句 system 就能搞定
清屏是控制台游戏绕不开的操作。Windows 用system("cls"),Linux/macOS 用system("clear")。像我这样主要用 Windows 的人可能觉得无所谓,但代码万一拿到别的环境编译就报错了。一个小宏就能解决:
#ifdef _WIN32 #define CLEAR_SCREEN() system("cls") #else #define CLEAR_SCREEN() system("clear") #endif写完之后在main里用CLEAR_SCREEN()代替system("cls"),就能保持跨平台兼容。
5. 实测中的边界问题与后续扩展思路
5.1 最容易被忽略的合并边界:一行四个相同方块怎么处理
先给结果:2 2 2 2向左合并后,应该得到4 4 0 0,而不是8 0 0 0。这是 2048 最基本的一条隐藏规则——“一次滑动中,一个方块只能参与一次合并”。如果按“直接从头扫到尾就地合并”的思路写,很容易得出错误的8。
我的“压缩-合并-再压缩”流程为什么天然正确?因为第一次压缩后所有非零元素靠左,合并扫描从左到右,当i位置和i+1位置合并后,i+1位置变成 0,后续扫描再往后走,不会回头二次合并。某个历史版本的[2,2,2,2]执行过程可以自己手推一遍,能彻底理解这个规则。
5.2 随机数种子与可复现性:测试时的一个小技巧
rand()生成的随机数序列实际上一个固定序列,只不过每次启动游戏前用srand(time(NULL))把它“搅”得不同。这在正常游戏时没问题,但测试时有个麻烦:你没法复现一个恰好需要某种局面的场景。
我测试时的做法是临时把srand(time(NULL))换成固定数字,比如srand(42),这样每次启动游戏生成的新方块位置和数值都一模一样。等验证完逻辑,再改回时间种子。你要是在写 AI 相关功能时也需要这种可控的随机,可以留着这个思路。
5.3 进阶方向:从“能玩”到“玩得漂亮”
核心功能跑通之后,可以按兴趣选几个方向继续加深:
- 存档系统:把
board二维数组和score写入文本文件,下次启动读回来。这是 C++ 文件流ifstream/ofstream的绝佳练习。 - AI 自动玩:只需要写一个评估函数,比如对每个方块根据它在棋盘上的位置给权重,再加上空位数、相邻相同方块对数,然后做深度优先搜索或者简单的期望值最大化。2048 的搜索空间不算大,AI 做到两三千分并不难。
- 彩色输出:Windows 10 以上的终端支持 ANSI 转义码。不同数字给不同颜色,视觉效果好很多。
- 图形化:把控制台版改成 SFML 或 SDL 图形版。移动逻辑完全不用动,只需要加一个渲染层。这也是体会“逻辑与表现分离”设计思想的好机会。
5.4 我对这个项目的整体评价
如果目标是把 C++ 的基础语法吃透,2048 覆盖的知识点非常全面:数组与多维数组、循环与分支、函数封装、随机数、输入输出、还有一点字符串处理和简单的状态机设计。如果目标是练习代码组织能力,这个项目又不会大到让人半途而废。我见过不少学生拿它当课程设计,也见过有人用它作为面试时展示代码风格的项目,确实是一个成本低、收益高的练手选择。
最后分享一个我自己改代码时用的技巧:先把SIZE临时改成 3 或者 5,跑一遍看算法是否还能正常工作。如果移动和合并逻辑在 3×3 棋盘上依然正确,说明你的核心逻辑没有把 4 这个魔法数字写死在代码里;如果一改就出问题,恭喜你,找到了下一处需要重构的地方。这个“改参数验证”的习惯,比多写一千行代码都值钱。