☰
C++从零实现2048小游戏:二维数组与核心算法详解
2026/10/1 6:04:59 网站建设 项目流程

简介:这份资源是面向C++初学者与课程设计学习者的2048小游戏完整工程,围绕数字合并类益智游戏的实现展开,帮助读者理解如何用C++落地游戏板数据结构、滑动合并算法与随机生成逻辑。压缩包共55个文件,约12.6MB,包含cpp与h源码、head头文件、sln与vcxproj工程配置、exe可执行程序,以及pdb、obj、tlog等编译调试中间产物,另附ReadMe.txt、data.ini与git相关配置,目录结构保留了Visual Studio项目的完整组织方式。已有637人学习下载,适合作为C++面向对象编程与事件驱动开发的练手项目。读者可从中获得可直接编译运行的工程源码,参考上下左右四个方向的操作函数、结束条件判断、得分与重置等扩展功能的实现思路,并借助调试文件排查编译与运行问题,加深对C++语言与项目构建流程的理解。

1. 从零写一个能玩的 2048:为什么 C++ 是练手的最佳选择

很多人第一次接触 2048 小游戏,是在手机或网页上滑动方块,觉得规则简单到不值一提。但真让你用 C++ 从零实现一个能跑、能玩、能判断输赢的 2048,你会发现它几乎把 C++ 基础里最核心的东西全串了一遍:二维数组、随机数、循环与分支、函数拆分、状态判断。这也是为什么「2048小游戏 用c++」一直是 C++ 入门和课程设计的高频题目——它足够小,小到一天能写完;又足够完整,完整到能暴露你对数组边界、随机数种子、游戏状态机的真实掌握程度。

这篇文章面向两类人:一类是刚学完 C++ 基础语法,想找一个「不像冒泡排序那么无聊」的练手项目的新手;另一类是想拿它当课程设计或面试作品,需要把代码写得规范、可扩展的进阶者。我会从数据结构选型讲到核心算法,再到编译运行和常见翻车点,全部用可复现的代码和参数说明带你走一遍。读完你至少能得到一个能在本地编译运行、逻辑正确的 2048,并且知道每一步为什么这么写。

2. 数据结构与核心机制:4x4 棋盘到底该怎么存

2.1 用二维数组还是 vector:选型理由与内存布局

2048 的棋盘固定是 4x4,这个「固定」非常关键。很多新手一上来就用vector<vector<int>>,觉得灵活,但实际上对于固定尺寸的棋盘,int board[4][4]才是更合适的选择。原因有三点:第一,栈上分配,访问速度快,没有堆内存管理的开销;第二,尺寸编译期确定,编译器能做更好的优化;第三,代码更直观,board[i][j]一眼就能看出是第 i 行第 j 列。

当然,如果你后续想扩展成可变尺寸(比如 5x5 或 6x6),那就该用std::vector<std::vector<int>>或者一维std::vector<int>加索引换算。一维数组board[i * 4 + j]在缓存友好性上其实更好,但可读性差一些。对于练手项目,我一般建议先用二维数组把逻辑跑通,等逻辑稳定了再考虑泛化。

// 棋盘固定 4x4,用二维数组存储,0 表示空格 const int SIZE = 4; int board[SIZE][SIZE]; // 初始化:全部置 0 void initBoard(int b[SIZE][SIZE]) { for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) b[i][j] = 0; }

这段代码里SIZE用const int而不是宏#define,是因为const int有类型检查,调试时能看到符号名。initBoard接收数组参数时,实际上传的是指针,所以b[i][j]的写法等价于*(*(b + i) + j),这是 C++ 数组退化的经典行为,新手必须理解这一点,否则后面传参时会踩坑。

2.2 随机数生成:为什么你的 2048 每次开局都一样

2048 开局会在随机两个空格生成数字,90% 概率是 2,10% 概率是 4。听起来简单,但「随机数」是新手翻车最集中的地方。如果你不调用srand()设置种子,rand()每次运行产生的序列完全相同,结果就是每次开局棋盘一模一样。这就是典型的「玄学 bug」——代码看着没问题,跑起来就是不对。

#include <cstdlib> #include <ctime> // 在 main 函数开头调用一次,只需一次 srand(static_cast<unsigned int>(time(nullptr))); // 在随机空格生成一个新数字 void addRandomTile(int b[SIZE][SIZE]) { // 先收集所有空格位置 std::vector<std::pair<int,int>> empty; for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) if (b[i][j] == 0) empty.push_back({i, j}); if (empty.empty()) return; // 没有空格,直接返回 // 随机选一个空格 int idx = rand() % empty.size(); int r = empty[idx].first; int c = empty[idx].second; // 90% 生成 2,10% 生成 4 b[r][c] = (rand() % 10 == 0) ? 4 : 2; }

这里有几个参数和逻辑需要说清楚。srand(time(nullptr))用当前时间做种子,保证每次运行序列不同,但注意它只需要在main里调用一次,放在循环里会导致同一秒内种子相同。rand() % empty.size()取模得到[0, size-1]的索引,这是标准做法,但当empty.size()很大时取模会有轻微偏差,对 2048 这种最多 16 个空格来说完全无所谓。rand() % 10 == 0实现 10% 概率,比浮点运算更简洁,也避免了rand()/RAND_MAX的精度问题。

提示:C++11 之后更推荐用<random>库的std::mt19937和std::uniform_int_distribution,分布更均匀。但如果你用的是老教材或 Dev C++ 默认标准,rand()足够用,先跑通再优化。

2.3 移动与合并:一行四个格子的处理逻辑

2048 最核心的算法是「一行怎么合并」。不管你是上下左右哪个方向,都可以把问题归约成「把一行向左合并」,其他方向通过旋转或索引映射复用同一套逻辑。这是整个项目里最值得花时间想清楚的地方。

一行向左合并的规则是:先把所有非零数字紧凑到左边,然后从左到右扫描,相邻且相等的合并,合并后右边补零。注意每个数字一次移动只能合并一次,比如[2,2,2,2]合并后是[4,4,0,0]而不是[8,0,0,0]。

// 对一行(长度 4)执行向左合并,返回是否发生了变化 bool slideLeft(int row[SIZE]) { bool changed = false; // 第一步:紧凑,把所有非零移到左边 int tmp[SIZE] = {0}; int pos = 0; for (int i = 0; i < SIZE; ++i) { if (row[i] != 0) { tmp[pos++] = row[i]; } } // 第二步:相邻合并 for (int i = 0; i < SIZE - 1; ++i) { if (tmp[i] != 0 && tmp[i] == tmp[i + 1]) { tmp[i] *= 2; tmp[i + 1] = 0; changed = true; i++; // 跳过下一个,防止连锁合并 } } // 第三步:再次紧凑,把合并产生的空位补上 int finalRow[SIZE] = {0}; pos = 0; for (int i = 0; i < SIZE; ++i) { if (tmp[i] != 0) { finalRow[pos++] = tmp[i]; } } // 第四步:写回并判断是否变化 for (int i = 0; i < SIZE; ++i) { if (row[i] != finalRow[i]) changed = true; row[i] = finalRow[i]; } return changed; }

这段代码里i++那行是防连锁合并的关键。如果不加,[2,2,4,0]会先合并成[4,4,0,0],然后i继续走到第二个 4,又和后面的合并,变成[8,0,0,0],这是错的。changed标志位的作用是判断这一步操作是否真的改变了棋盘,只有改变了才生成新数字,否则玩家按了无效方向也会冒出新块,体验很差。

3. 完整实现:从棋盘到可玩程序的四步搭建

3.1 四个方向的统一处理:旋转映射而不是写四遍

有了slideLeft,上下右三个方向不需要重写逻辑。常见做法有两种:一种是把棋盘旋转到「左」的方向,处理完再转回去;另一种是直接按索引映射读取。旋转法代码更清晰,但涉及临时数组拷贝;索引法效率高但容易写错。我一般用旋转法,因为 4x4 的拷贝开销可以忽略,可读性优先。

// 把棋盘顺时针旋转 90 度 void rotateCW(int b[SIZE][SIZE]) { int tmp[SIZE][SIZE]; for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) tmp[j][SIZE - 1 - i] = b[i][j]; for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) b[i][j] = tmp[i][j]; } // 统一移动接口:dir 0=左 1=上 2=右 3=下 bool move(int b[SIZE][SIZE], int dir) { // 旋转次数:左 0 次,上 1 次,右 2 次,下 3 次 int times = dir; for (int t = 0; t < times; ++t) rotateCW(b); bool changed = false; for (int i = 0; i < SIZE; ++i) { if (slideLeft(b[i])) changed = true; } // 旋转回来 for (int t = 0; t < (4 - times) % 4; ++t) rotateCW(b); return changed; }

旋转法的核心是:把目标方向旋转到「左」,处理完再反向旋转回去。(4 - times) % 4处理了times=0时不需要旋转的情况。这里的方向编码0=左 1=上 2=右 3=下和旋转次数一一对应,是因为顺时针旋转一次,「上」方向就变成了「左」方向。这个映射关系建议在纸上画一遍,比死记硬背强。

3.2 游戏状态判断:赢了还是输了

每步操作后要判断两件事:是否出现了 2048(赢),以及是否还有可行操作(输)。输的判断不是「棋盘满了」,而是「棋盘满了且没有任何相邻相等」。很多新手只判断满没满,导致明明还能合并却提示游戏结束。

bool canMove(int b[SIZE][SIZE]) { // 还有空格,一定能动 for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) if (b[i][j] == 0) return true; // 没有空格,检查相邻是否有相等 for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (j + 1 < SIZE && b[i][j] == b[i][j + 1]) return true; if (i + 1 < SIZE && b[i][j] == b[i + 1][j]) return true; } } return false; } bool hasWon(int b[SIZE][SIZE]) { for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) if (b[i][j] == 2048) return true; return false; }

canMove先扫空格,有空格直接返回 true,这是最快的短路判断。没有空格时,只需要检查每个格子的右边和下边是否相等,因为左边和上边在之前的遍历中已经检查过了。这个「只查两个方向」的技巧能把检查次数减半,虽然对 4x4 无所谓,但养成习惯对写更大规模的程序有好处。

3.3 主循环与输入处理:让程序真正能玩

把上面的函数拼起来,加上主循环和输入,就是一个完整游戏。输入用WASD或方向键,这里用字符输入最简单,跨平台没有兼容问题。

#include <iostream> #include <vector> #include <cstdlib> #include <ctime> // ... 前面所有函数放在这里 ... void printBoard(int b[SIZE][SIZE]) { std::cout << "-----------------\n"; for (int i = 0; i < SIZE; ++i) { for (int j = 0; j < SIZE; ++j) { if (b[i][j] == 0) std::cout << " . "; else std::cout << " " << b[i][j] << " "; } std::cout << "\n"; } std::cout << "-----------------\n"; } int main() { srand(static_cast<unsigned int>(time(nullptr))); int board[SIZE][SIZE]; initBoard(board); addRandomTile(board); addRandomTile(board); while (true) { printBoard(board); if (hasWon(board)) { std::cout << "你赢了!\n"; break; } if (!canMove(board)) { std::cout << "游戏结束,没有可行操作。\n"; break; } std::cout << "输入 W/A/S/D 移动(Q 退出):"; char cmd; std::cin >> cmd; if (cmd == 'Q' || cmd == 'q') break; int dir = -1; if (cmd == 'A' || cmd == 'a') dir = 0; else if (cmd == 'W' || cmd == 'w') dir = 1; else if (cmd == 'D' || cmd == 'd') dir = 2; else if (cmd == 'S' || cmd == 's') dir = 3; else { std::cout << "无效输入\n"; continue; } if (move(board, dir)) { addRandomTile(board); } else { std::cout << "这个方向无法移动\n"; } } return 0; }

主循环的顺序很重要:先打印棋盘,再判断输赢,再读输入,再执行移动,移动有效才生成新块。move返回 false 时不生成新块,这是保证游戏公平性的关键。dir初始化为 -1 是为了捕获无效输入,虽然这里用 if-else 已经覆盖了,但养成给变量初始化的习惯能避免很多未定义行为。

3.4 编译运行:g++ 和 VS Code 两条路

代码写完了,怎么跑起来?如果你在 Linux 或 macOS 上,直接用 g++:

g++ -std=c++11 -O2 -Wall -o game2048 main.cpp ./game2048

-std=c++11指定标准,因为代码里用了std::vector的初始化列表和auto之类特性(虽然本文代码尽量兼容 C++98,但指定标准是好习惯)。-O2开优化,-Wall开所有警告,这一步能帮你发现很多潜在问题,比如未使用变量、符号比较等。

如果你在 Windows 上用 VS Code,需要先装 MinGW-w64 或 MSVC。VS Code 配置 C/C++ 环境的核心是三件事:装编译器、配tasks.json编译任务、配launch.json调试配置。新手最容易卡在编译器路径没加到系统 PATH,导致 VS Code 找不到 g++。验证方法是在终端输入g++ --version,能输出版本号就说明配好了。如果提示找不到命令,就把 MinGW 的bin目录加到系统环境变量 Path 里,重启 VS Code。

注意:如果你拿到的是别人编译好的 exe,运行时报缺少MSVCP140.dll或VCRUNTIME140.dll,那是没装 Microsoft Visual C++ Redistributable。去微软官网下载对应版本安装即可,这不是你代码的问题。

4. 避坑与排查:2048 实现里最容易翻车的五个地方

4.1 合并逻辑写成连锁合并

现象:[2,2,4,0]向左移动后变成[8,0,0,0],而不是正确的[4,4,0,0]。原因:合并循环里没有跳过已合并的元素,导致新合成的 4 又和后面的 4 合并。解决:在合并成功后执行i++,跳过下一个位置,确保每个格子一步内只参与一次合并。这是 2048 最经典的 bug,没有之一。

4.2 随机数每次开局相同

现象:每次运行程序,开局两个数字的位置和值完全一样。原因:没有调用srand()或种子固定。解决:在main开头调用一次srand(time(nullptr))。注意不要放在循环或addRandomTile里,否则同一秒内多次调用种子相同,反而更糟。

4.3 无效移动也生成新数字

现象:玩家按了一个无法移动的方向,棋盘没变,但冒出了新数字。原因:move函数没有返回是否变化,或者主循环忽略了返回值。解决:让move返回 bool,只有返回 true 时才调用addRandomTile。这个细节直接影响游戏体验,很多课程设计就栽在这里。

4.4 数组越界导致随机崩溃

现象:程序偶尔崩溃,或者输出乱码。原因:旋转或索引映射时下标算错,访问了board[4][j]或board[i][4]。解决:所有循环边界用SIZE而不是硬编码 4,旋转公式tmp[j][SIZE-1-i] = b[i][j]在纸上验证一遍。开-fsanitize=address编译能直接定位越界位置。

4.5 判断游戏结束时漏掉可合并情况

现象:棋盘满了但明明有相邻相同的数字,程序却提示游戏结束。原因:只判断了棋盘是否满,没判断相邻是否相等。解决:用canMove函数,先查空格,再查右和下两个方向的相邻相等。这个逻辑写一次就够,别在多个地方重复判断。

5. 进阶技巧:让 2048 从能玩变成值得展示

代码跑通只是第一步。如果你要拿它当课程设计或面试作品,有几个投入产出比很高的改进方向。第一是加撤销功能,用一个std::vector存历史棋盘状态,每次有效移动前压入,按 U 键弹出恢复。这能体现你对栈结构和状态管理的理解,代码量不到 30 行。第二是把棋盘尺寸参数化,用一维std::vector<int>加size变量,把slideLeft改成接收长度参数,这样 4x4、5x5 都能跑,展示泛化能力。第三是加分数系统,每次合并时把合并后的值累加到总分,显示在棋盘上方,这是 2048 的标准功能,实现简单但让程序完整度高很多。

验证你的实现是否正确,有一个笨但有效的办法:手动构造几个边界用例。比如[2,2,2,2]左移应该是[4,4,0,0],[4,4,8,8]左移应该是[8,16,0,0],[2,0,2,4]左移应该是[4,4,0,0]。把这些用例写成断言,每次改完代码跑一遍,比肉眼盯着屏幕靠谱得多。

// 简单的自测:验证 slideLeft 的边界行为 void testSlideLeft() { int a[SIZE] = {2,2,2,2}; slideLeft(a); // 期望 {4,4,0,0} assert(a[0]==4 && a[1]==4 && a[2]==0 && a[3]==0); int b[SIZE] = {2,0,2,4}; slideLeft(b); // 期望 {4,4,0,0} assert(b[0]==4 && b[1]==4 && b[2]==0 && b[3]==0); int c[SIZE] = {4,4,8,8}; slideLeft(c); // 期望 {8,16,0,0} assert(c[0]==8 && c[1]==16 && c[2]==0 && c[3]==0); }

这几个断言覆盖了连锁合并、中间有空格、多组合并三种情况。我自己的习惯是每写一个核心函数就先写三五个断言,跑通了再往下写。这样出问题时能立刻定位到是哪个函数的问题,而不是在一大坨代码里大海捞针。2048 这个项目不大,但把测试习惯带进去,你写下一个项目时会轻松很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询