刚学完C++基础语法,最想做的事不是继续啃书,而是动手写点能跑起来的东西。我身边很多朋友会选2048小游戏作为第一个C++小项目,原因很简单:规则清楚、界面不复杂、纯控制台就能跑,但又足够考验数组、循环、状态判断这些基本功。这篇就把我用C++实现2048的完整过程拆开讲一遍,包括设计思路、核心算法、可直接运行的代码,以及我在写的过程中踩过的坑。如果你正在找C++入门项目,或者想理解“一个游戏到底是怎么从逻辑变成代码的”,这篇应该能帮上忙。这个项目不需要任何图形库,只要一个能编译C++的环境就能复现,非常适合作为课程设计、自学练手或新人分享主题。
1. 项目分析与核心设计思路
1.1 规则回顾:2048到底在做什么
先简单回顾一下玩法:棋盘是4x4方格,初始时随机出现两个数字方块,数字不是2就是4。玩家通过键盘控制所有方块整体往上、下、左、右四个方向滑动,滑动时相同的数字会合并成它们的和,例如两个2相遇变成4,两个4相遇变成8。每次滑动完成后,棋盘的空位里又会随机生成一个新的2或4。只要棋盘上某个格子的数字达到2048,游戏就算胜利;如果棋盘被填满,并且不存在任何相邻且相等的方块,那游戏就结束。
把规则翻译成程序语言,其实就两个核心问题:第一,怎么表示这个4x4棋盘;第二,怎么实现“滑动并合并”这个动作。别小看这两个问题,前者考验你对数据结构的理解,后者考验你对数组下标、循环和边界条件的控制。很多新人一开始会觉得2048很简单,真正动手写的时候才发现,上下左右四个方向怎么统一处理,合并时怎么避免重复合并,都是需要仔细设计的。
这里我顺手说一下这个项目的影响力:在C++入门阶段,能跑通的“完整程序”本来就少,很多练习都是输出几行文字就结束,2048是少数能在控制台里形成完整交互闭环的小游戏。它既能用来检验语法掌握程度,又能作为面试或课程设计中的项目展示,还能在社区里分享给别人玩,反馈感和成就感都很强。所以尽管已经有很多现成实现,我还是非常推荐新手把它作为第一个正经的C++项目。
1.2 数据结构选型:为什么会选二维数组
棋盘的天然模型就是二维矩阵,C++里最朴素的表达方式有两种:一是固定大小的内置数组 int board[4][4],二是用 STL 的 std::vector<std::vector<int>>。我最终选了后者,主要出于三个原因。第一,vector 可以动态扩容,如果以后想把4x4改成5x5甚至6x6,只需要改一个 SIZE 常量,不需要重新声明数组维数。第二,复制和赋值非常方便,比如判定一次移动有没有效,我需要把移动前的棋盘保存下来做对比,Board old = board; 这一句就能搞定,内置数组则需要手写循环拷贝。第三,也是比较现实的一点,vector 已经是现代C++的标配,通过这个游戏可以把 vector 的常用操作复习一遍,比如遍历、初始化、emplace_back 添加元素,对新手来说是很划算的练习。
如果你对性能有执念,用 std::array<std::array<int, 4>, 4> 也可以,它在栈上分配,没有堆开销,性能更好。但考虑到2048的棋盘只有16个格子,这点性能差异完全可以忽略,我更推荐优先选择代码清晰、便于维护的 vector 方案。实际写的时候我会定义 using Board = std::vector<std::vector<int>>;,这样后面所有函数传参、返回值都可以用简短的 Board 类型,代码会清爽很多。
我还在这个项目里额外加了一个全局变量 score 来统计当前得分,best 记录本局最高分。一开始我打算用局部变量传引用到处传,后来发现全局变量在小程序里更直观,毕竟这个项目的核心目标是玩游戏,不是演示依赖注入。当然,等你后面要封装成更大的项目时,再把全局变量收进一个 Game 类里也不迟。
1.3 统一方向处理:转置和反转,解决一半的代码量
我见过不少新人写2048,最直接的想法是写四个移动函数 moveUp、moveDown、moveLeft、moveRight,每个函数里分别处理行或列的循环逻辑。这种写法不是不行,但很容易出现两类问题:一是代码重复严重,四个函数加起来七八十行;二是某个方向的边界条件容易写错,比如列向上移动时,下标是行号从1到3还是从0到2,稍不留神就混了。
更优雅的做法是,把所有方向都统一到“左移”这一个基准操作上。逻辑是这样的:左移就是对每一行从左往右做压实和合并;右移相当于先把每一行反转,变成左移后再反转回来;上移相当于把整个矩阵转置,转置后再左移,最后再转置回来;下移相当于先转置,然后执行右移,最后再转置回来。这里面的两个基础操作是矩阵转置和行反转,代码量都非常短。转置就是让 board[i][j] 和 board[j][i] 交换,行反转就是把每一行的元素逆序。有了这两个工具,我就只需要把左移逻辑写好,其余三个方向全部复用,整个项目的核心代码量几乎减少一半。
光说可能有点抽象,我举一个具体例子。假设棋盘是:
text复制2 0 0 0
0 0 0 0
2 0 0 0
0 0 0 0
此时如果玩家按了向上键,第一列的两个2应该合并成4,并且移动到第一行第一列。如果只写一个“向上移动”函数,你需要遍历每一列,再把列里的数字往上压实、合并,逻辑上要小心列下标的关系。但用转置方案后,我先把整个矩阵转置,棋盘变成:
text复制2 0 2 0
0 0 0 0
0 0 0 0
0 0 0 0
这时原来的“向上移动”就等价于新矩阵的“向左移动”。第一行 [2, 0, 2, 0] 经过左移会变成 [4, 0, 0, 0],新矩阵变成:
text复制4 0 0 0
0 0 0 0
0 0 0 0
0 0 0 0
最后再转置回去:
text复制4 0 0 0
0 0 0 0
0 0 0 0
0 0 0 0
结果和真实2048完全一致。这个“用标准操作 + 矩阵变换统一方向”的思路,其实不只是2048能用。很多矩阵类游戏、图像处理、棋盘类AI题目都会用到类似技巧,学会一次,后面遇到复杂矩阵问题时思路会开阔很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心功能拆解与关键实现
2.1 棋盘初始化与随机方块生成
初始化的逻辑很简单,把所有格子置0,然后生成两个随机方块。在C++中我建议写一个 addRandom() 函数,它负责在空格子里随机挑一个位置,然后给这个位置赋一个新数字。具体实现是先遍历一遍棋盘,把所有值为0的格子坐标收集到一个 std::vector<std::pair<int, int>> 里,然后用 rand() % 空格数 随机选一个下标,新数字用 rand() % 10 < 9 来决定:取随机数对10取模,结果小于9的概率是90%,则生成2;剩下的10%生成4。这样基本符合原始2048的出块概率,虽然 rand() 本身质量一般,但作为控制台小游戏完全够用。
这里有一个容易忽略的细节:init() 里要先确保棋盘全0,再连续调用两次 addRandom()。如果忘记清空棋盘,上一次游戏留下的数字会导致开局就有多个方块,游戏体验非常奇怪。另外,如果只调用一次,开局只有一块数字,也不符合2048的初始状态。所以初始化顺序一定不能反。我在实际写的时候也犯过这个错,浪费了不少时间在调一个莫名其妙的“开局多数字”问题上。
如果你用的是现代C++,也可以把 rand() 换成 <random> 库里的 std::mt19937 和 std::uniform_int_distribution,分布更均匀,也更规范。不过考虑到这是新手项目,rand() 配合 srand(time(0)) 已经足够简单,而且代码量少很多,等以后需要更高质量的随机数时再升级也不迟。
2.2 左移合并的完整逻辑
左移是整个项目的灵魂,后面三个方向都靠它。我先把左移的函数写好,再统一封装。左移的目标是:对每一行的数组,把所有非0数字往左压实,相邻且相同的数字合并一次,合并后的结果放在左侧,右侧用0补齐。
先说一个典型的误区:有人会写成从前往后遍历,遇到相邻相同就直接合并。比如 [2, 2, 2, 2],如果边遍历边合并,程序会把第一个2和第二个2合并成4,然后第三个2又和第四个2合并成4,结果变成 [4, 4, 0, 0],这其实是正确的。但如果一开始就 [2, 2, 2],从前往后合并会变成 [4, 2, 0],也是对的。问题是 [4, 4, 4] 这种要正确变成 [8, 4, 0],如果写得不小心,可能会在合并第一个4和第二个4后,又错误地把合并结果8与第三个4比较,产生错误。
所以我的做法分为两步。第一步,把该行的所有非0数字提取到一个临时数组里,这一步相当于“物理压实”,比如 [0, 2, 0, 2] 变成 [2, 2]。第二步,在临时数组上做合并:从第一个元素开始扫描,如果当前元素和下一个元素相等,就把当前元素乘2放入合并结果,然后下标跳过下一个元素;如果不等,就直接把当前元素放入合并结果。扫描结束后,把合并结果按顺序写回原行,后面补0。这样既不会漏合并,也不会出现重复合并。
举个例子,[4, 4, 4] 压实后还是 [4, 4, 4]。扫描时,k=0,发现 compact[0] == compact[1],所以合并出一个8,k 变成2;接着 k=2,只剩最后一个4,没有下一个元素可比,所以把4放进合并结果。最终结果是 [8, 4, 0],完全正确。如果是 [2, 2, 2, 2],第一次合并出4,跳过一对;后面又合并出一个4,最终是 [4, 4, 0, 0],也正确。
多说一句,为什么不能直接在原始数组上合并?因为原始数组里可能有0,0会干扰判断,而且合并后需要移动位置,原地操作需要大量移动元素,很容易出错。先压实再合并,思路非常清晰,写完以后几乎不用调试。这也是我在这个项目里学到的很重要的一点:当你觉得一个逻辑很乱的时候,先别急着写循环,试着把问题拆成几个小步骤,每一步都保证正确,最后串起来就不容易出错。
2.3 胜利、失败与有效移动判定
游戏状态的判定就三件事:赢、输、这次移动是否真的有效。
赢的判断最简单,每次移动并生成新方块之后,遍历整个棋盘,只要发现某个格子数字大于等于2048,就算胜利。这里我用“大于等于”而不是“等于”,是为了稳妥,万一以后改目标成4096,代码不用大改。
失败的判断稍微绕一点。失败的条件是:棋盘里没有任何空格,同时任意上下左右相邻的两个格子的数字都不相等。为什么看“相邻相等”?因为只要还有一对相邻相等,就说明还能通过一次移动让它们合并,游戏还可以继续。所以判断时就两次遍历,第一次看空格,第二次看横向相邻和纵向相邻是否有相等的情况。如果两个条件都满足,游戏结束。
有效移动的判断是很多新手会忽略的。玩家按了一个方向键,但棋盘可能没变化,例如所有数字已经全部靠左,再按左键就不会有任何效果。如果不判断这个,就会每次都调用 addRandom(),结果就是棋盘上越来越多方块,毫无逻辑地快速塞满,游戏很快就死了。正确做法是:在移动前用一个 Board before = board 保存副本,执行移动后比较 board 和 before 是否相等。如果相等,说明这次移动没有产生任何变化,就不生成新方块,直接提示无法移动;如果不等,说明移动有效,再调用 addRandom()。
这种“先备份 -> 操作 -> 比较变化”的思路在很多游戏开发里都很常用。比如角色移动、碰撞检测、状态回退,都依赖对“操作前后状态”的对比。你在这个小项目里把这个逻辑练熟,以后写更复杂的游戏状态管理会顺畅很多。
3. 完整可运行代码与编译运行指南
3.1 完整C++代码
下面是我整理的完整代码,代码在Windows控制台环境下编译运行,用到了 <conio.h> 的 _getch() 读取按键,以及 system("cls") 清屏。全文没有引入任何第三方库,C++11及以上标准都能编译。
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <iomanip>
#include <conio.h>
using Board = std::vector<std::vector<int>>;
const int SIZE = 4;
Board board(SIZE, std::vector<int>(SIZE, 0));
int score = 0;
int best = 0;
void addScore(int num) {
score += num;
if (score > best) best = score;
}
void addRandom() {
std::vector<std::pair<int, int>> emptyCells;
for (int i = 0; i < SIZE; i++)
for (int j = 0; j < SIZE; j++)
if (board[i][j] == 0)
emptyCells.emplace_back(i, j);
if (emptyCells.empty()) return;
int idx = rand() % (int)emptyCells.size();
int val = (rand() % 10 < 9) ? 2 : 4;
board[emptyCells[idx].first][emptyCells[idx].second] = val;
}
void init() {
for (auto &row : board)
std::fill(row.begin(), row.end(), 0);
score = 0;
addRandom();
addRandom();
}
void printBoard() {
system("cls");
std::cout << "===== C++ 2048 =====\n";
std::cout << "Score: " << score << " Best: " << best << "\n\n";
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
if (board[i][j] == 0)
std::cout << " .";
else
std::cout << std::setw(5) << board[i][j];
}
std::cout << "\n\n";
}
std::cout << "W/A/S/D 或方向键移动,Q 退出\n";
}
bool moveLeft() {
Board before = board;
for (int i = 0; i < SIZE; i++) {
std::vector<int> compact;
for (int j = 0; j < SIZE; j++)
if (board[i][j] != 0)
compact.push_back(board[i][j]);
std::vector<int> merged;
for (int k = 0; k < (int)compact.size(); k++) {
if (k + 1 < (int)compact.size() && compact[k] == compact[k + 1]) {
merged.push_back(compact[k] * 2);
addScore(compact[k] * 2);
k++;
} else {
merged.push_back(compact[k]);
}
}
for (int j = 0; j < SIZE; j++) {
board[i][j] = (j < (int)merged.size()) ? merged[j] : 0;
}
}
return board != before;
}
void transpose() {
for (int i = 0; i < SIZE; i++)
for (int j = i + 1; j < SIZE; j++)
std::swap(board[i][j], board[j][i]);
}
void reverseRows() {
for (int i = 0; i < SIZE; i++)
for (int j = 0; j < SIZE / 2; j++)
std::swap(board[i][j], board[i][SIZE - 1 - j]);
}
bool moveRight() {
reverseRows();
bool changed = moveLeft();
reverseRows();
return changed;
}
bool moveUp() {
transpose();
bool changed = moveLeft();
transpose();
return changed;
}
bool moveDown() {
transpose();
bool changed = moveRight();
transpose();
return changed;
}
bool isWin() {
for (int i = 0; i < SIZE; i++)
for (int j = 0; j < SIZE; j++)
if (board[i][j] >= 2048) return true;
return false;
}
bool isLose() {
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;
}
int main() {
srand((unsigned)time(nullptr));
init();
while (true) {
printBoard();
if (isWin()) {
std::cout << "恭喜你合成了 2048!\n";
break;
}
if (isLose()) {
std::cout << "游戏结束,无路可走了。\n";
break;
}
int key = _getch();
if (key == 0 || key == 224)
key = _getch();
bool changed = false;
switch (key) {
case 'w': case 'W': case 72:
changed = moveUp(); break;
case 's': case 'S': case 80:
changed = moveDown(); break;
case 'a': case 'A': case 75:
changed = moveLeft(); break;
case 'd': case 'D': case 77:
changed = moveRight(); break;
case 'q': case 'Q':
return 0;
default:
continue;
}
if (changed)
addRandom();
}
return 0;
}
这个版本我已经实际跑过,没有明显的逻辑问题。如果你想快速体验,直接把这段代码复制进一个 .cpp 文件编译运行即可。
3.2 编译与运行说明
在Windows下,最简单的编译方式是用MinGW的g++。如果你的环境变量配好了,进入代码所在目录,执行:
bash复制g++ -o 2048 2048.cpp
然后在同一目录下运行 2048.exe 就能看到游戏界面。需要说明的是,我用的 _getch() 和 system("cls") 都是Windows平台相关的函数,所以如果是Linux、macOS环境,直接编译大概率会报 conio.h 找不到。跨平台兼容方案我放在后面的常见问题里讲。
如果你用的是VSCode,需要注意两点。第一是编译器路径一定要配好,很多新手在VSCode里写C++遇到“找不到头文件”或“无法打开源文件”的报错,十有八九是MinGW环境变量或者 c_cpp_properties.json 里的编译器路径没配对。第二是运行控制台程序时,建议直接使用终端面板或外部集成终端,而不是输出面板,否则 _getch() 这种控制台输入函数可能表现异常。最简单的编译任务可以在 tasks.json 里写一条,核心内容就是调用 g++ -g 源文件 -o 输出文件,比如:
json复制{
"version": "2.0.0",
"tasks": [
{
"label": "C++ 编译",
"type": "process",
"command": "g++",
"args": ["-g", "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}.exe"],
"group": "build"
}
]
}
当然,如果你不喜欢手动折腾VSCode,直接用Visual Studio新建一个空项目,把代码覆盖到主源文件里,编译运行即可,VS自带的工具链会自动处理Windows平台相关的配置,什么都不用额外调。
3.3 按键输入与运行效果
游戏启动后,屏幕会先显示一次当前棋盘状态。初始状态是两个数字方块,位置随机,剩下的格子显示成一个小点。屏幕下方会提示按键方式:支持WASD和方向键,Q退出。方向键在 _getch() 里是特殊键,读取时先返回一个前缀值 224,紧接着返回方向码,所以主循环里做了这样的处理:
cpp复制int key = _getch();
if (key == 0 || key == 224)
key = _getch();
224 前缀出现时,第二次 _getch() 拿到的值就是方向本身:上72、下80、左75、右77。这也是为什么我在 switch 里同时放了字母键和方向码两种选择。如果你只支持WASD,就可以不处理方向键,但控制台2048还是方向键用起来顺手,所以我保留了这层处理。
每次移动有效后,程序会调用 addRandom(),在新空格子里随机生成2或4,然后回到循环开头重新清屏打印。整个过程就是一个“读按键 -> 移动 -> 合并 -> 生成新块 -> 重新绘制”的事件循环,这也是几乎所有游戏都会用到的经典结构。就算以后要做GUI版本,这个循环模型也完全适用,只是把 _getch() 换成窗口消息响应而已。控制台界面虽然朴素,但数字用 std::setw(5) 做了右对齐,棋盘看起来会比较整齐,不会出现数字长短不一导致的歪斜问题。
4. 踩坑实录与体验优化
4.1 常见问题速查表
写这个项目的过程我踩了几个坑,也帮别人排查过几次,整理成一个速查表,遇到问题可以直接对号入座。
| 现象 | 原因 | 解决方案 |
|---|---|---|
编译时报 conio.h: No such file or directory |
代码依赖Windows控制台头文件 | 换成标准输入方案或使用Windows环境;Linux环境可改用termios,或只用WASD并去掉清屏 |
| 按下方向键没反应或移动方向不对 | 没有处理方向键的 224 前缀 |
在 _getch() 后判断返回值,如果等于0或224,再读一次 |
| 棋盘上每次按键都疯狂增加方块 | 没有判断这次移动是否有效就调用 addRandom() |
保存移动前棋盘,移动后比较,有变化才生成方块 |
合并结果不对,比如 [2,2,2] 变成了 [8,0,0] |
在一个循环内同时做了移动和合并,导致合并后的值又被使用 | 按“先压实 -> 再合并 -> 后补零”三个步骤分开处理 |
[2,2,2,2] 变成了 [8,0,0,0] |
合并后没有跳过被合并元素 | 合并成功后手动 k++,让指针跳过第二个合并对象 |
| 游戏开始时没有两个初始方块 | init() 里没有先清空棋盘,或 addRandom() 调用次数不对 |
先全部置0,再连续调用两次 addRandom() |
| 莫名提前判定游戏结束 | 失败判断只看是否满盘,没检查相邻是否相等 | 满盘且无相邻相同才算输,只要还有可合并的相邻项就不结束 |
这些坑大多是新手期一定会遇到的。尤其是“合并后跳过元素”这一点,几乎每个写2048的人都会卡一次。你只要记住一个口诀:合并一次,跳过一个。这样就不会在 [2,2,2,2] 这种连续相同数字的输入上出错。
还有一个不太容易被发现的问题:如果 moveLeft() 里在合并时修改了 score,但后来你检查“移动是否有效”时发现 board 没变,那么分数其实也不应该变。还好我的代码里 addScore 只在合并发生时调用,而只要有合并,棋盘就一定发生了变化,所以这个逻辑是自洽的。如果你自己在扩展时把计费逻辑写在移动外面,就要注意别把无效移动的分数也算进去。
4.2 体验优化方向与扩展思路
完整版跑通以后,如果你还想继续练手,下面几个方向按难度递增排列,都是不错的升级方向。
第一个是给游戏加分数和最高分存档。分数逻辑我已经在代码里加了,你可以把 best 在退出前写进一个文本文件,下次启动时读出来,就是简单的存档功能。这个练习能让你熟悉文件流 ifstream / ofstream 的用法,属于C++必学内容。比如退出前用 std::ofstream out("best.txt"); out << best; 就能把最高分存下来,开局时再读进去,改动量非常小。
第二个是给方块加颜色。Windows下可以用 SetConsoleTextAttribute 给不同数量级设置不同前景色和背景色,界面会好看很多。Linux/macOS下可以用ANSI转义序列,类似 "\033[1;32m" 这样的控制码。这个功能很直观,能加深你对“终端到底是怎么显示文字”的理解。做的时候要注意一个细节:每次输出完数字后都要记得恢复默认颜色,不然整行文字都会变成同一种颜色,看起来非常乱。
第三个是调整合并规则,比如支持5x5棋盘,再把目标值改成4096甚至更高。由于我的代码里 SIZE 是常量,只影响棋盘大小,合并逻辑和方向转换逻辑完全不用改,你会体会到抽象封装的好处——现在你改的只是参数,不是算法。不过如果棋盘变大,胜率当然会变低,你需要同时调整初始方块生成概率或者新块数值,否则游戏可能很难推进。
第四个是更进一步,做成带界面的版本,比如用Qt写桌面版,或者用SFML做图形版。这一步跨度比较大,但核心逻辑依然可以复用。到时候你会惊喜地发现,2048难度不在“画面”,而在“逻辑”,画面只是最后一层皮。甚至有人会尝试写一个简单的AI自动玩游戏,思路也很直接:每次决策前,把上下左右四种移动都模拟一遍,对得分、空格数量、最大方块位置做一个综合评分,然后选评分最高的方向。这个AI不需要多复杂,但能把“搜索 + 评估”的算法思想练一遍,非常有意思。
我在实际写这个项目的时候,最大的体会就是:看似简单的2048,其实把C++里最常用的语法和逻辑几乎全部串了一遍,数组遍历、条件分支、循环嵌套、函数封装、状态判断,一个都没落下。更关键的是,通过这个项目会慢慢养成一种“把现实游戏规则翻译成程序逻辑”的思维习惯,这种能力比单纯背语法重要得多。如果你刚学完C++基础,我强烈建议你别急着看下一个教程,先花一个晚上把这个2048完整写出来。等你能不依赖任何参考,从头到尾独立实现一遍,再回头看那些教程代码,会有一种“原来我也可以做到”的踏实感。
最后再分享一个小技巧:写完这个项目以后,你可以在纸上画一下4x4棋盘的几种关键局面,比如“满盘无路”“仅剩一格空格”“四个数字连续相同”,然后一个个拿到程序里验证。这种边界情况测试比随机玩很多局更能暴露代码问题。我就是靠这几个简单用例,把合并逻辑里一个不常出现的重复合并问题揪出来的。别觉得麻烦,调试本身就是写代码最值钱的那部分经验。
