翻到两年前写的2048小游戏代码,忍不住还是觉得这个项目对C++新手来说太合适了。当时我刚学完C++语法,指针、引用、vector这些概念背得滚瓜烂熟,但让我独立写点东西就发懵。后来在地铁上玩了几把2048,突然意识到这个游戏逻辑并不复杂,用C++在命令行里做一版应该不难——这个念头让我花掉了两个完整的周末,却也真正把C++的语法从头到尾用了个遍。
这篇文章就把整个2048小游戏的编写过程完整拆开:从怎么设计数据结构,到怎么处理移动合并的核心算法,再到控制台界面怎么渲染,最后附上完整的C++代码和编译运行方法。不管你是刚学完C++基础语法、想找一个能落地的小项目,还是面试前想复习一下数组和状态机相关的写法,这篇都能直接拿来参考。
1. 为什么用C++写2048:需求分析与整体设计
1.1 这个项目到底在练什么
先聊聊这个项目本身的定位。2048的规则一句话就能说完:4乘4棋盘,滑动让相同数字碰撞合并,每次滑动后随机位置生成一个2或4,棋盘被填满且无法合并时游戏结束。规则越简单,越适合拿来当练手项目。
很多新手学C++最大的困惑是“语法都会,但不知道能做什么”。图书管理系统、学生成绩管理这类项目确实经典,但坦白讲有点枯燥。2048不一样,玩过的人多、规则零门槛、代码量又在几百行的规模内。写这个项目的过程,等于把C++里最常用的一批技能点全部过了一遍:
- 二维数组的定义与遍历,这是棋盘数据结构的核心;
- 函数的拆分与模块化,把一个完整游戏拆成初始化、渲染、移动、判定等函数;
- 随机数的用法,每次滑动后在空位生成新方块;
- 控制台输入输出的处理,包括方向键读取和界面刷新。
比如我当年写的时候,最大的收益是彻底搞懂了“函数到底该怎么拆”。你会很自然地发现,如果把所有逻辑堆在主函数里,代码会迅速膨胀到没法调试。拆成函数之后,每改一个地方都不用从头捋一遍,这种体感是刷题刷不出来的。
1.2 整体架构与模块划分
动手之前先明确一下这个项目由哪几个部分组成。我实现的版本分为下面这些模块:
| 模块 | 职责 | 核心函数 |
|---|---|---|
| 初始化 | 生成棋盘、开局随机两个方块 | init、spawnBlock |
| 输入处理 | 读取方向键和退出指令 | handleInput |
| 移动合并 | 核心逻辑,将某个方向的格子滑动并合并 | move、moveLeft、rotateClockwise |
| 随机生成 | 在空位生成2或4 | spawnBlock |
| 状态判定 | 判断游戏是否结束、是否达成2048 | isGameOver、胜利检测 |
| 界面渲染 | 把棋盘数组打印到控制台 | drawBoard |
这个设计是照着“一局游戏的标准流程”来的:初始化、等待输入、执行移动、生成新块、渲染、判断结束。每个步骤是独立函数,主循环只需要把这些函数串起来就行。
采用这种拆分方式的直接原因是可调试性。2048最麻烦的问题不是写不出来,而是出bug之后不好定位。如果把方向键读取、移动算法、渲染逻辑全部塞在一起,棋盘一旦错乱,很难判断是哪个环节出了偏差。拆开之后,每个函数只干一件事,单独测试一个方向的移动逻辑也就顺手很多。
关于二维数组的初始化多说一句。棋盘一开始全部填0,之后每次生成一个2或4,这个设计让判断空格变得非常简单:只要值是0,就是一个可生成新方块的位置。我见过有人用-1表示空位,也能跑,但0的判断明显更符合直觉,打印空位的时候单独处理一下显示效果就行。
2. 移动合并算法:核心思路与数据结构
2.1 棋盘用二维数组还是vector
棋盘用int board[4][4]就够了,没必要用vector。固定大小意味着可以避免动态分配的开销,而且旋转、搬运数据时用memcpy一类操作也很方便。如果用vector,代码写起来会更“现代”,但在这种固定规模场景下不会带来多少额外收益。
当然,如果以后想扩展成动态尺寸的棋盘(比如5乘5、6乘6),再把数组换成vector也是水到渠成的事,移动算法的核心思路不变。这里选固定数组,主要是为了让代码对新手更友好,索引访问和整体拷贝都直观。
2.2 怎么把四种滑动方向统一成一种
2048最核心的算法是“移动+合并”。四个方向的移动逻辑本质是一样的:把非零数字向目标方向靠拢,相邻相同数字合并成一个。如果为上、下、左、右分别写一套逻辑,代码会变得又长又容易错。我自己实验过,四个方向分开写大概要160行以上,而且调试的时候要在四个方向之间来回比对着改。
更聪明的做法是“旋转矩阵”。以“向左移动”为基准,把其他三个方向通过旋转后统一处理,具体对应关系是:
- 左移:不做处理,直接对每行执行左移;
- 右移:把棋盘旋转180度,执行左移,再旋转180度;
- 上移:逆时针旋转90度,执行左移,再顺时针旋转90度;
- 下移:顺时针旋转90度,执行左移,再逆时针旋转90度。
这样只需要维护一个“单行左移”的核心函数,其他方向全是“旋转+左移+旋转回去”的组合,逻辑集中了,问题自然好定位。旋转函数本身也不需要花哨,直接用一个临时数组做坐标映射:
void rotateClockwise() { 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]; } } }旋转的本质是坐标变换,把(i, j)映射到(j, SIZE - 1 - i)。这个公式可以自己在纸上画一个2乘2矩阵推一遍:左上角会旋转到右上角,右上角会旋转到右下角,方向是顺时针的。整个过程用到一个临时数组,这是为了避免原地覆盖数据。
2.3 单行合并的实现细节
单行左移是整个游戏逻辑的核心,也是新手最容易写错的地方。这里的关键点有两个:第一是非零数字要全部靠左;第二是相邻相同数字合并,并且“同一行一次滑动中最多只能合并一次”。
举个例子,一行是2 2 2 2,滑动后结果应该是4 4 0 0,而不是8 0 0 0。一行是4 4 8,滑动后是8 8 0;一行是2 2 4 4,滑动后是4 8 0 0。如果从左边开始连续合并,就会做出一行2 2 2 2滑成8的bug,这是最常见的翻车点。
我把单行处理拆成三步:压缩、合并、补零。
第一步压缩:把这一行的非零数字按顺序提取出来,形成一个没有空位的新数组。 第二步合并:从左往右扫描这个数组,如果当前数字和下一个数字相等,就合并成一个(当前数字乘2),分数累加,然后跳过下一个数字。 第三步补零:长度不够4的用0补满,最后整体写回原棋盘。
对应的代码是:
bool moveLeft() { bool changed = false; for (int i = 0; i < SIZE; ++i) { int row[SIZE]; int n = 0; for (int j = 0; j < SIZE; ++j) { if (board[i][j] != 0) { row[n++] = board[i][j]; } } int merged[SIZE]; int m = 0; for (int j = 0; j < n; ++j) { if (j + 1 < n && row[j] == row[j + 1]) { merged[m++] = row[j] * 2; score += row[j] * 2; ++j; } else { merged[m++] = row[j]; } } while (m < SIZE) merged[m++] = 0; for (int j = 0; j < SIZE; ++j) { if (board[i][j] != merged[j]) changed = true; board[i][j] = merged[j]; } } return changed; }合并时为什么要跳过下一个数字,核心就在“同一行一次滑动中最多只能合并一次”。比如2 2 2 2,从左往右合并索引0和1得到4,如果继续用合并后的4去和索引2的2比较,就会错;必须跳过它,去比较索引2和索引3,最终得到4 4。这个细节是2048规则里非常容易被忽略的地方,也是我在测试中发现很多初版实现会踩中的坑。
函数结束时返回一个bool类型的changed,表示这一行有没有发生实际变化。这个返回值会传给上层:如果一次移动后棋盘完全没变化,就不生成新方块;如果完全没变化,说明这个方向键按了也白按,这个细节在玩的时候体验差异很大。
3. 完整游戏循环:随机生成、胜负判定与输入响应
3.1 开局初始化与随机方块生成
游戏开局时,棋盘的16个格子全是0。我的做法是调用spawnBlock两次,让开局就有两个可用的初始方块。每次spawnBlock做两件事:先扫描所有值为0的格子,把它们的位置收集到一个数组里;然后用随机数从中选一个位置,填入新方块。
新方块的值不是等概率的:按照2048的官方规则,90%的概率生成2,10%的概率生成4。代码上就是:
int val = (rand() % 10 == 0) ? 4 : 2;srand要在main函数最前面调用一次,用当前时间做种子,这样每次运行游戏的随机序列才不一样。容易踩的坑是:有人会在spawnBlock内部反复调用srand,这样一来同一个程序内多次调用,随机结果反而会被时间种子重置,出现“新方块总是出现在同一个位置”之类的诡异现象。srand只在程序入口调用一次就够了。
3.2 怎么判断游戏还能不能继续
游戏结束的条件是:棋盘没有空格,且任意相邻的两个格子都不相等。这里“相邻”包括上下和左右两个方向。等价写法是“还能不能移动”:只要存在至少一个空格,就一定还能继续;或者存在任意两个相邻的相同数字,也一定能通过一次滑动合并出空格。
对应的函数是:
bool isGameOver() { for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (board[i][j] == 0) return false; if (i + 1 < SIZE && board[i][j] == board[i + 1][j]) return false; if (j + 1 < SIZE && board[i][j] == board[i][j + 1]) return false; } } return true; }这个判定的思路是先找“还能继续”的证据,一旦找到就直接返回false,全程遍历完之后还没找到,才能确定游戏真正结束。这种先否定再肯定的写法,比直接尝试枚举所有移动方向是否有效要简单不少。
3.3 控制台方向键读取
在Windows控制台里,要实现方向键操作,最常用的方案是读conio.h中的_getch函数。麻烦的是方向键不是单个字符,而是一个前缀加上一个扫描码:按上方向键时,_getch第一次返回224(或者0),第二次才返回真正的方向码72。
所以我专门写了一个处理函数:
int handleInput() { int ch = _getch(); if (ch == 224 || ch == 0) { ch = _getch(); switch (ch) { case 72: return 0; // 上 case 80: return 1; // 下 case 75: return 2; // 左 case 77: return 3; // 右 default: return -2; // 无效输入 } } if (ch == 'q' || ch == 'Q') return -1; // 退出 return -2; }这个函数是一个很好的“状态机”示例。如果你见过别人写的键盘控制小游戏,多半都会遇到这种“两个字符代表一个键位”的情况,提前理解它对后面做其他控制台项目也很有帮助。
注意这套方案是Windows专属的,Linux/macOS的终端里通常没有conio.h。如果你在macOS或者Linux上编译这份代码,会直接报错。后面我会在第5节给出一些替代方案。
4. 控制台渲染:把数组变成能玩的界面
4.1 先解决对齐再谈美观
棋盘在控制台里的显示,本质就是把二维数组按照某种格式打印出来。看起来简单,但直接cout输出的话,数字位数不同会导致对不齐,比如2和1024的宽度完全不同,画面会歪得没法看。
我是用iomanip中的setw来固定每个格子占6个字符宽度,并且用边框线把格子分隔开。这样不管数字多长,都能保持表格形状:
void drawBoard() { system("cls"); cout << "Score: " << score << "\n\n"; for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) cout << "+------"; cout << "+\n"; for (int j = 0; j < SIZE; ++j) { cout << "|"; if (board[i][j] == 0) cout << " "; else cout << setw(6) << board[i][j]; } cout << "|\n"; } for (int j = 0; j < SIZE; ++j) cout << "+------"; cout << "+\n\n"; }setw(6)是右对齐,所以数字会贴右侧,看起来比较整齐。如果你喜欢居中,可以自己写一个居中对齐函数,但整体工作量不大,多数人用默认右对齐就够了。
4.2 清屏、闪烁与低成本彩色渲染
很多初学者会直接用system("cls")清屏,然后在下一帧重新打印。这个方式的优点是简单,缺点也明显:每次清屏,控制台会闪烁一下,而且system函数本质上是在启动一个新的命令行进程来执行清屏命令,性能开销远比你想象的大。
如果你只是做一个小游戏,闪烁问题不致命,但体验确实会打折扣。更平滑的做法是“光标复位”,也就是把光标移动到控制台左上角,重新打印棋盘,避免清空整屏。Windows下可以用SetConsoleCursorPosition,这也是我最后采用的方案。
至于颜色,Windows控制台可以通过SetConsoleTextAttribute来改变文字颜色。不同数字用不同颜色,界面会一下好看很多。我在这版代码里保留了最基础的配色逻辑:2是白色,4是绿色,8是黄色,16和32是红色,再大的数字换蓝底之类。实际效果就是看着不累,玩起来舒服一些。做这些优化一共只需要几十行代码,性价比很高。
这里有一个小的体验问题:如果你在程序里输出了中文字符,Windows控制台默认的代码页是GBK,而源代码文件可能是UTF-8编码,混起来很容易出现乱码。最简单的做法是程序开头执行一次system("chcp 65001 > nul")切换到UTF-8代码页,或者干脆全部用英文提示。我在这版代码里全部用英文,就是为了避开这个坑。
5. 完整代码与编译运行:直接拿走可用
5.1 全量代码
下面把我最后整理的完整版本放出来,这条代码在Windows下用VS2022或者MinGW都可以直接编译运行。
#include <iostream> #include <iomanip> #include <cstdlib> #include <cstring> #include <ctime> #include <conio.h> #include <windows.h> using namespace std; const int SIZE = 4; int board[SIZE][SIZE]; long long score = 0; void rotateClockwise() { 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]; } } } bool moveLeft() { bool changed = false; for (int i = 0; i < SIZE; ++i) { int row[SIZE]; int n = 0; for (int j = 0; j < SIZE; ++j) { if (board[i][j] != 0) { row[n++] = board[i][j]; } } int merged[SIZE]; int m = 0; for (int j = 0; j < n; ++j) { if (j + 1 < n && row[j] == row[j + 1]) { merged[m++] = row[j] * 2; score += row[j] * 2; ++j; } else { merged[m++] = row[j]; } } while (m < SIZE) merged[m++] = 0; for (int j = 0; j < SIZE; ++j) { if (board[i][j] != merged[j]) changed = true; board[i][j] = merged[j]; } } return changed; } bool move(int dir) { switch (dir) { case 0: rotateClockwise(); rotateClockwise(); rotateClockwise(); break; case 1: rotateClockwise(); break; case 3: rotateClockwise(); rotateClockwise(); break; default: break; } bool changed = moveLeft(); switch (dir) { case 0: rotateClockwise(); break; case 1: rotateClockwise(); rotateClockwise(); rotateClockwise(); break; case 3: rotateClockwise(); rotateClockwise(); break; default: break; } return changed; } bool hasEmpty() { for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (board[i][j] == 0) return true; } } return false; } void spawnBlock() { if (!hasEmpty()) return; int emptyCells[SIZE * SIZE][2]; int cnt = 0; for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (board[i][j] == 0) { emptyCells[cnt][0] = i; emptyCells[cnt][1] = j; ++cnt; } } } int idx = rand() % cnt; board[emptyCells[idx][0]][emptyCells[idx][1]] = (rand() % 10 == 0) ? 4 : 2; } bool isGameOver() { for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (board[i][j] == 0) return false; if (i + 1 < SIZE && board[i][j] == board[i + 1][j]) return false; if (j + 1 < SIZE && board[i][j] == board[i][j + 1]) return false; } } return true; } void drawBoard() { system("cls"); cout << "Score: " << score << "\n\n"; for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) cout << "+------"; cout << "+\n"; for (int j = 0; j < SIZE; ++j) { cout << "|"; if (board[i][j] == 0) { cout << " "; } else { cout << setw(6) << board[i][j]; } } cout << "|\n"; } for (int j = 0; j < SIZE; ++j) cout << "+------"; cout << "+\n\n"; } int handleInput() { int ch = _getch(); if (ch == 224 || ch == 0) { ch = _getch(); switch (ch) { case 72: return 0; case 80: return 1; case 75: return 2; case 77: return 3; default: return -2; } } if (ch == 'q' || ch == 'Q') return -1; return -2; } int main() { srand(static_cast<unsigned>(time(NULL))); memset(board, 0, sizeof(board)); spawnBlock(); spawnBlock(); drawBoard(); while (true) { int dir = handleInput(); if (dir == -1) break; if (dir == -2) continue; bool changed = move(dir); if (changed) spawnBlock(); drawBoard(); bool win = false; for (int i = 0; i < SIZE && !win; ++i) { for (int j = 0; j < SIZE && !win; ++j) { if (board[i][j] == 2048) win = true; } } if (win) { cout << "You Win! Press any key to continue...\n"; _getch(); } if (isGameOver()) { cout << "Game Over! Press any key to exit...\n"; _getch(); break; } } return 0; }这段代码的移动方向设定是:0上、1下、2左、3右,q退出。运行之后,你就可以在控制台里玩上一整局,达到2048会提示胜利,无路可走时提示游戏结束。
5.2 编译运行与常见环境问题
编译本身不复杂,我常用的命令是:
g++ 2048.cpp -o 2048 -std=c++11在VS2022里,新建一个空C++控制台应用,把代码粘贴进去,直接按F5运行。如果用MSVC的cl命令行,则是:
cl 2048.cpp /EHsc这里有一个很多新手会遇到的报错:“error: microsoft visual c++ 14.0 or greater is required.”。这句话字面意思是“需要14.0或更高版本的Microsoft Visual C++”,但你首先要搞清楚:它根本不是编译C++程序时报的错,而是写Python扩展模块时,pip在安装带C扩展的包时找不到MSVC编译器导致的。如果你是在跑C++项目而不是pip install,多半不会遇到这个提示。真正遇到的话,装一个VS2022 Build Tools基本就能解决,路径是Visual Studio Installer里勾选“使用C++的桌面开发”。
还有一个常见问题是运行生成的exe时提示msvcp140.dll缺失。这个dll属于微软的Visual C++运行库,不是程序本身的bug。解决办法是到微软官网下载Visual C++ Redistributable(vcredist_x64.exe)安装一遍,装完基本就一路顺畅了。
6. 踩坑记录与优化方向
6.1 我在开发中踩过的三个坑
这个项目虽然不大,但我在实现和帮别人调试的过程中,遇到过不少重复的问题,挑三个典型的分享一下。
第一个坑是前面提过的“重复合并”。一行2 2 2 2,第一版代码从左往右连续合并,直接合并成16,结果是数值暴涨但棋盘没满,这是最容易被新手忽略的规则细节。第二版代码修正后,变成了4 4 0 0,这一步做完,一整局玩下来都不会再出错。
第二个坑是“移动后没变化也生成新方块”。这个会带来幽灵方块:你在无处可去的方向按了一下,本来不该有变化,结果莫名其妙多出一个新方块,把布局打乱。加一个changed返回值,把“实际有没有变化”反馈到主循环,问题就不存在了。
第三个坑是随机数种子。我第一次在spawnBlock里面调用srand,结果游戏每次重新开始后,生成的方块位置似乎总是一样的。调试了两三个小时才意识到,是srand被反复重置导致的。这个教训挺值钱的:随机种子设置一次就好,别放在会被重复调用的函数里。
6.2 下一步可以怎么扩展
能做到这一步,基础版的2048已经完成。如果你想继续加点东西,我建议按下面的顺序扩展,每加一个功能都能学到新东西:
- 存档系统:退出时把棋盘数据写进文件,下次进入时读回。涉及文件读写和序列化,可以练习fstream。
- 撤销功能:用一个栈记录历史棋盘,按一次撤销键就回退一步。这是“stack”这个数据结构的典型应用。
- AI自动玩:实现一个简单的启发式算法,让程序自己决定往哪个方向走。哪怕是最朴素的“优先合大数”“不往上走”规则,也能跑到一两千分。
- Web版移植:把核心算法抽出来放到前端JavaScript里,可以顺便对比一下C++和JS在实现同一个逻辑时,代码结构的差别。
- 大小可变的棋盘:把SIZE改成可配置参数,需要把固定数组改成动态数组或者vector,核心算法不变,但你会对“固定大小”和“动态大小”两种模式有更直观的认识。
我自己的体会是,这个项目写到能跑只是第一步,真正值钱的是你在拆功能、调bug、加功能的过程中,建立起来的那套“把规则转化成数据结构”的思维。这个能力靠看别人的代码学不来,只能自己一行一行敲出来。最后一句话想分享给正卡在“写完语法不知道干什么”阶段的朋友:找一个规则简单的游戏,用尽可能短的时间先做出来,再花时间慢慢改,这个循环走一次,你对C++的理解会比刷十遍语法书都扎实。