ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C++实现2048小游戏:核心合并算法与控制台编程实战解析

用C++实现2048小游戏:核心合并算法与控制台编程实战解析 简介这是一份基于C实现的2048小游戏完整工程面向初学C、备战课程设计或对经典小游戏实现感兴趣的读者帮助理解数字合并逻辑、二维数组状态管理与键盘事件驱动流程。压缩包共五十五个文件约十二点六MB包含.cpp与.h源码、Visual Studio解决方案及工程文件、编译生成的exe可执行程序与pdb、ilk调试文件同时附有git配置目录、说明文档和data.ini配置文件方便直接运行体验也便于在VS中打开单步跟踪。已有六百三十七人学习下载。源码中实现了上下左右四个方向的滑动合并算法、随机生成2或4、游戏结束判定等功能并展示了如何用标准库或容器维护棋盘状态预留了扩展得分记录、重置选项等模块入口。对于希望深入理解C面向对象编程和基础游戏逻辑的开发者这是一份紧凑实用的参考资料比常见控制台版本更具工程完整性。1. 用 C 写 2048 小游戏先拆棋盘、合并与输赢三条主线网上搜“用 C 写 2048 小游戏”能拿来直接跑的大多是控制台版本只用标准库就把游戏做通。这类需求最要紧的不是先画界面而是把规则翻译成三件事4x4 棋盘怎么存一次按键之后 16 个格子怎么算每一步之后怎么判断这局还能不能继续。反直觉的是难点不在随机生成和画面而在合并算法2 2 4 4 一行向左合并一次应该是 4 8不是 8 或 16这个细节换到上、下、右方向时还会反复翻车。这篇文章从棋盘数据结构讲起把移动、合并、随机生成、胜负判断、控制台交互拆开讲最后补一个能自动跑几百局验证正确性的测试模式适合刚过 C 语法关的入门者跟着敲一遍也适合拿它当课程设计底稿的读者直接改成自己的版本。2. 棋盘与合并算法4x4 数组加一次遍历搞定四个方向2.1 为什么选 int board[4][4]固定棋盘模型与 0 的语义2048 的棋盘是规则写死的 4x4不需要动态扩容。int board[4][4] 在栈上是连续内存编译器能把它整体放进缓存遍历 16 个格子几乎不花钱。相比之下 vectorvector 的每个子数组独立分配在堆上遍历时多一层间接寻址。对一次按键只算 16 格来说差距不明显但后面做 AI 模拟时每走一步要复制棋盘、预测 4 个方向这个差距会被放大几十倍。另一个原因是复制成本。模拟某个方向会得到什么局面只需要 int copy[4][4]; memcpy(copy, board, sizeof(board)) 一行16 个 int 的拷贝对后续的“自动对局”和“贪心 AI”非常友好。如果用嵌套 vector拷贝还要手动循环代码会糊成一团。这里的 0 不只是“空格”它同时承担合并判断的哨兵职责。向左移动时所有非零数字按原序滑到左侧零全部落到尾部所以“合并两个相邻相同数字”里的“相邻”只针对非零数字零天然被跳过。这个语义贯穿整个游戏逻辑后面 mergeLeft 的第一步就是先把零过滤掉。2.2 mergeLeft先抽非零、再相邻合并、最后补零一次向左移动可以拆成三步把所有非零数字按原序抽到一个临时数组从左到右看有没有相邻且相同的两个数有就合并成它们的二倍并累加分数最后把临时数组写回行首尾部补零。下面这个函数是全游戏最核心的 20 行。// 合并一行的核心方向固定为“向左” // row 是棋盘的一行score 用引用累加本次合并得到的分值 void mergeLeft(int row[4], int score) { int tmp[4] {0}; // 临时数组只存放非零数字 int idx 0; for (int i 0; i 4; i) { if (row[i] ! 0) tmp[idx] row[i]; // 第一步过滤空格 } int out 0; for (int k 0; k idx; k) { if (k 1 idx tmp[k] tmp[k 1]) { row[out] tmp[k] * 2; // 第二步相邻相同则合并 score tmp[k] * 2; k; // 跳过被合并的那个数 } else { row[out] tmp[k]; // 不相同的数字保持原样 } } while (out 4) row[out] 0; // 第三步尾部补零 }函数入参的 row 虽然是数组写法传进函数后实际退化成指针所以函数内对 row 的修改会直接反映到原棋盘不需要返回值。score 用引用传递这样调用方拿到的是“每一步的增量分数”游戏总分由外层累加。这里最容易翻车的是那个 k。看 2 2 2 2 这一行正确结果应该是 4 4 0 0而不是 8 0 0 0。2048 的规则是一次按键只允许相邻的一对数字合并一次左边两个 2 合成 4 之后这个新 4 不能继续和右边第三个 2 合并因为第三个 2 已经作为“被比较者”参与过一次配对。k 的作用就是把它跳过。如果漏掉这一步连续相同的数字越多翻车越明显直接一步合出 8 甚至 16。2.3 四个方向怎么做列抽取与反向回填只有“向左”还不够游戏有四个方向。常见做法不是再写三个 merge而是把“上、下、右”都通过数据变换复用 mergeLeft。向右移动时先把一行倒序拷贝到临时数组调用 mergeLeft再倒序写回向上移动时把每一列抽出来当成“行”处理合并后再写回原列向下移动只是向上移动的反向版本。// 向左移动直接对每行调用 mergeLeft void moveLeft(int board[4][4], int score) { for (int i 0; i 4; i) mergeLeft(board[i], score); } // 向右移动把一行倒序放进临时数组合并后再倒序写回 void moveRight(int board[4][4], int score) { for (int i 0; i 4; i) { int row[4]; for (int j 0; j 4; j) row[j] board[i][3 - j]; mergeLeft(row, score); for (int j 0; j 4; j) board[i][3 - j] row[j]; } } // 向上移动把每一列抽成“行”借用同一个合并逻辑 void moveUp(int board[4][4], int score) { for (int j 0; j 4; j) { int col[4]; for (int i 0; i 4; i) col[i] board[i][j]; mergeLeft(col, score); for (int i 0; i 4; i) board[i][j] col[i]; } }向下移动的写法与 moveRight 对称对每一列做倒序拷贝、合并、倒序回填就行。这样整套游戏里“合并”只有一份实现四个方向只负责调整数据的进出顺序逻辑上非常容易验证。有人会尝试在同一个循环里边移动边合并比如直接从右往左遍历并用下标递减判断一旦一行里出现两个以上空格很容易漏掉中间的空位。把“过滤零”和“合并”拆成两个阶段后这类边界情况就消失了。3. 完整游戏循环随机生成、方向移动与胜负判断的实现3.1 游戏循环输入→移动→生成→判断的调度有了按方向移动的四个函数游戏主体就是一个状态机循环渲染棋盘读一个方向尝试移动只有棋盘真的变了才生成新块然后判断输赢。有一个细节很多人第一次写会漏掉——如果玩家按了一个没有效果的方向比如棋盘第一行全是 2按了左没有产生任何变化这时不应该生成新块。用新旧棋盘对比来判断“是否移动过”是最稳妥的。// 主循环棋盘发生变化时才允许生成新块 bool gameOver false; bool winShown false; int score 0; while (!gameOver) { render(board, score); Direction d readKey(); int oldBoard[4][4]; memcpy(oldBoard, board, sizeof(board)); int gained 0; applyMove(board, d, gained); // 内部根据 d 分发到四个 move 函数 if (memcmp(oldBoard, board, sizeof(board)) ! 0) { score gained; addRandomTile(board); if (isGameOver(board)) { render(board, score); printf(Game Over\n); gameOver true; } if (has2048(board) !winShown) { printf(2048 reached! keep going\n); winShown true; // 只提示一次不退出 } } }这里用的是位存储快照对比法memcpy 保存旧棋盘移动后再 memcmp 比较。如果相等说明这个方向没有任何滑动和合并直接跳过生成新块的逻辑。applyMove 内部拿 Direction 做 switch把当前方向分发给 moveLeft、moveRight、moveUp、moveDown 之一这样就避免在主循环里堆四个 if。另一个关键决策是“赢了不退出”。原版 2048 在合成 2048 之后仍可以继续玩直到棋盘无路可走。很多实现一检测到 2048 就直接 break把游戏结束掉这对追求更高分的玩家不友好。winShown 这个 bool 保证“2048 arrived”提示只打印一次但循环继续跑。3.2 addRandomTilemt19937 与 90/10 的 2 和 4 生成比例每次合法移动后系统要在所有空格子里随机挑一个放入 2 或 4。原版游戏的概率接近 90% 出 2、10% 出 4这个比例也被大多数复刻版沿用。实现时可以先把所有空位收集到一个固定数组里再随机选下标。// 在空位里随机生成一个 2 或 42 的概率约 90%4 约 10% void addRandomTile(int board[4][4]) { std::pairint, int empty[16]; int cnt 0; for (int i 0; i 4; i) for (int j 0; j 4; j) if (board[i][j] 0) empty[cnt] {i, j}; if (cnt 0) return; static std::mt19937 rng{std::random_device{}()}; std::uniform_int_distributionint pick(0, cnt - 1); std::uniform_int_distributionint face(0, 9); auto [r, c] empty[pick(rng)]; board[r][c] (face(rng) 9) ? 2 : 4; }值得说明的细节是 rng 声明成 static。如果每次调用 addRandomTile 都重新构造一个 mt19937之前的随机状态全部丢弃效果等于不断重启随机序列。static 变量在第一次调用时初始化之后一直保留在全局数据段随机序列是连续的。现在 C 的 比老的 rand() 可靠得多rand() 在部分平台上高位随机性差甚至有的版本会周期性重复。关于 random_device它在多数平台用系统熵源启动时会有一点点延迟在 Windows 的 MinGW 环境下首次调用甚至可能有几十到几百毫秒但只发生一次可以接受。如果实在介意可以用 std::chrono::steady_clock::now().time_since_epoch().count() 混合当前时间做种子随机性略弱但游戏场景足够。3.3 结束与胜利判断满盘不代表 game over很多人会把结束条件写成“棋盘满了就结束”这是错的。棋盘满了但还有两个相邻相同数字时玩家再移动一次仍然可以合并出空格游戏不能结束。真正的 Game Over 是满盘且没有任何相邻相同数字四个方向都动不了才判定。// 只要存在空格或者左右/上下存在相邻相同数字游戏就没有结束 bool isGameOver(const int board[4][4]) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { int v board[i][j]; if (v 0) return false; if (j 1 4 v board[i][j 1]) return false; if (i 1 4 v board[i 1][j]) return false; } } return true; } // 检查是否已经合成过 2048注意这个函数不参与结束判定 bool has2048(const int board[4][4]) { for (int i 0; i 4; i) for (int j 0; j 4; j) if (board[i][j] 2048) return true; return false; }isGameOver 在每个合法移动、生成新块后调用一次16 格遍历开销可以忽略。has2048 独立出来是为了让“胜利提示”与“结束判定”解耦。对入门者还有一个测试小技巧在调试阶段可以把 2048 改成 16 或 32这样不用打半小时就能验证胜利提示的逻辑正式发布前再改回去。4. 控制台渲染与键盘交互Windows、macOS 与 Linux 的三套差异处理4.1 渲染printf 固定宽度与 ANSI 清屏控制台版本的渲染目标很简单每次按键后清屏把 4x4 格子重新画一遍。这里优先用 printf 而不是 cout因为 printf 的格式控制可以写得很紧凑cout 要每次 setw 对齐代码会啰嗦不少。// ANSI 转义\033[2J 清屏\033[H 把光标移回左上角 void clearScreen() { printf(\033[2J\033[H); } void render(const int board[4][4], int score) { clearScreen(); printf(SCORE: %d\n\n, score); for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (board[i][j]) printf(%7d, board[i][j]); else printf(%7s, .); } printf(\n\n); } }宽度统一用 7 个字符。2048 游戏中单格数值理论上能涨到 131072占 6 位7 格宽度足够显示所有合法值。空格用“.”占位是为了让棋盘形状在终端里一眼能看清也可以换成空格但大量空格在黑色背景下经常看着像空屏新手容易误以为程序卡住了。ANSI 转义在 Windows Terminal、VS Code 终端、Linux、macOS 的默认终端里都支持但老版 cmd 窗口默认不解析会原样打出 [2J。这个坑在第 5 章专门排查。如果只是在课程设计演示也可以直接用 system(cls) 清屏缺点是执行一次要启动一个子进程按键频繁时能感觉到闪屏而且 system 调用在部分在线评测环境里会被禁用。4.2 方向键读取_getch 与 termios 的等价实现键盘输入是控制台游戏跨平台里最绕的部分。Windows 下可以用 _getch() 直接读方向键方向键返回两个字节第一次调用返回 224第二次返回扫描码上下左右分别对应 72、80、75、77。Linux 和 macOS 没有 conio.h方向键以 ESC 序列的形式进入终端比如上键是 \033[A必须用 termios 把终端切成原始模式才能逐字节读取。// Windows 分支路径 #ifdef _WIN32 #include conio.h Direction readKey() { int ch _getch(); if (ch 224 || ch 0) { // 方向键的第一字节 switch (_getch()) { // 第二字节才是真正的键值 case 72: return UP; case 80: return DOWN; case 75: return LEFT; case 77: return RIGHT; default: return NONE; } } switch (ch) { case w: case W: return UP; case s: case S: return DOWN; case a: case A: return LEFT; case d: case D: return RIGHT; default: return NONE; } } #endif// Linux / macOS 分支需要先关闭终端的行缓冲与回显 #else #include termios.h #include unistd.h void setRawMode(bool on) { static struct termios saved, raw; if (on) { tcgetattr(STDIN_FILENO, saved); raw saved; raw.c_lflag ~(ICANON | ECHO); // 关闭行缓冲和回显 raw.c_cc[VMIN] 1; // 至少读一个字节 raw.c_cc[VTIME] 0; // 不设置超时 tcsetattr(STDIN_FILENO, TCSANOW, raw); } else { tcsetattr(STDIN_FILENO, TCSANOW, saved); } } Direction readKey() { char c; if (read(STDIN_FILENO, c, 1) ! 1) return NONE; switch (c) { case w: case W: return UP; case s: case S: return DOWN; case a: case A: return LEFT; case d: case D: return RIGHT; case \033: { // ESC 序列开始 char seq[2]; if (read(STDIN_FILENO, seq[0], 1) 1 seq[0] [) { if (read(STDIN_FILENO, seq[1], 1) 1) { switch (seq[1]) { case A: return UP; case B: return DOWN; case C: return RIGHT; case D: return LEFT; } } } return NONE; } default: return NONE; } } #endifLinux 分支里 c_lflag 的 ECHO 要仔细。如果只关掉 ICANON 不关 ECHO按方向键时终端会把 ESC 序列回显到屏幕上游戏画面下方冒出一串 [[A非常难看。VMIN1、VTIME0 的组合表示阻塞式读取程序停在 readKey 里等待按键这是预期行为。Windows 和 Linux 两套实现的差异可以简单对比如下。行为Windows 的 _getchLinux/macOS 的 termios是否等待回车不等待按键即返回需要关闭 ICANON 才不等待方向键形式两字节224扫描码三字节 ESC 序列回显控制默认不回显需要关闭 ECHO头文件依赖conio.htermios.h、unistd.h4.3 WASD 兼容与终端模式恢复方向键很直观但在一些远程 SSH 终端里会被桌面系统拦截或者在 vscode 终端里因 ESC 序列延迟导致识别失败。常见做法是同时支持 WASD 和方向键WASD 作为兜底代码里已经实现了。主循环里每读一次键后要不要做防抖取决于手感偏好。很多控制台小游戏新手还会遇到另一个“玄学”问题程序正常结束后终端进入了原始模式输入命令时回车不换行、打字不回显。原因就是关闭了 ICANON 和 ECHO退出前没有恢复终端状态。解决办法是注册一个 atexit 回调进程无论正常退出还是被 CtrlC 中断都会被调用。#ifdef __linux__ int main(int argc, char **argv) { setRawMode(true); std::atexit([] { setRawMode(false); printf(\033[?25h); // 恢复光标显示避免游戏结束后光标消失 }); // ... 初始化棋盘并进入主循环 } #endif在游戏过程中把光标隐藏可以让画面更干净进入循环前用 printf(\033[?25l) 隐藏光标退出时恢复。这一对转义序列不复杂但能让演示效果提升一个档次。5. C 2048 常见问题排查编译、按键与随机数的 5 个坑5.1 编译报错getch 未声明现象在 Linux 上用 g 编译网上的 C 2048 源码报 error: ‘getch’ was not declared in this scope。原因getch 是 Windows conio.h 提供的函数C 标准库里没有。很多人从 Windows 的 Visual Studio 教程复制代码直接把 conio.h 带到了 Linux自然找不到这个函数。解决把代码按平台分支开Windows 用 _getch其它平台用 termios。普通课程设计场景不需要追求两者完全一致只要保证自己演示的平台能编译即可。编译时用 C17 标准并开优化g -stdc17 -O2 main.cpp -o 2048如果还报了其它错误先确认文件名是 .cpp 而不是 .cC 和 C 的编译规则不完全一样C 编译器不认布尔类型、引用和重载。5.2 按下一次方向键2 2 2 2 直接合成了 8 甚至 16现象一行里有三个以上相同数字时按一次方向键它们全部合并成一个更大的数而且分数跳得离谱。原因合并逻辑没有做“跳过被合并元素”的处理。比如 2 2 2 2第一对 2 和 2 合成 4循环游标继续往后走又拿这个新 4 和第三个 2 比较恰好相等就继续合并如果写成 while 内嵌 while 还可能把第四个数也卷进来。元素被合并之后它不应该再参与后续比较。解决用第 2 章的 mergeLeft 实现——先把非零数字抽到 tmp配对时用 k 跳过已经配对成功的数。想防止以后改坏可以在 main 开头放一个自动测试bool testMerge() { int row[4] {2, 2, 2, 2}; int s 0; mergeLeft(row, s); return row[0] 4 row[1] 4 row[2] 0 row[3] 0 s 8; } int main() { if (!testMerge()) { printf(mergeLeft test failed\n); return 1; } // ... 正常启动游戏 }这个测试用例在模块改完后立刻能暴露问题比反复用手按方向键试错快得多。5.3 每次重开游戏初始棋子和后续序列几乎一样现象程序每次启动第一个随机块的位置、后面几局的走势都差不多换个时间启动也一样。原因老式写法 srand(time(NULL)) 在同一个秒级时间窗口内多次启动时种子相同如果用了 rand() 但忘了 srand种子每次都是 0序列完全固定。更隐蔽的是random_device 在个别 Windows 版本上被实现成固定熵源直接用它做种效果也差。解决用 mt19937 带 random_device 种子并且 rng 对象保留成 static不要每次生成新块都重新构造。如果启动时明显卡顿可以用高精度时钟做补充种子。游戏场景不需要密码学强度但至少要保证两次启动序列不同。5.4 vscode 终端里按方向键显示 [[A游戏不响应现象在 VS Code 集成终端里运行按上方向键屏幕输出 [[A棋盘纹丝不动。原因VS Code 终端是 Linux/macOS 或纯终端模拟方向键会发送三字节 ESC 序列 \033[A。代码里如果用 getchar() 或普通 read 只读一个字节程序读到 \033 后不知道这是方向键的开头把它当成未知键丢弃剩下的 [ 和 A 被终端当作普通文本回显出来。解决在 readKey 里针对 ESC 开头做三字节读取也就是第 4.2 节 Linux 分支的写法。另外要注意 VS Code 集成终端对 ESC 序列的发送速度有时按下键后三个字节没有同时到达read 一次读一个字节的写法刚好能处理。如果仍偶发失灵优先用 WASD这在远程演示时反而更稳。5.5 cmd 窗口里显示的是 [2J 而不是清屏现象在 Windows cmd 里运行游戏分数上方出现一行可见的 [2J 字符屏幕没有刷新。原因老版本的 Windows 控制台默认不解析 ANSI 转义序列printf 把 \033[2J 原样输出。注意 Windows 10 之后的大部分系统其实支持只是需要程序先打开 VT 模式很多教程没提这一步。解决在 Windows 分支的 main 里显式启用虚拟终端处理#if defined(_WIN32) #include windows.h void enableAnsi() { HANDLE h GetStdHandle(STD_OUTPUT_HANDLE); DWORD mode 0; GetConsoleMode(h, mode); SetConsoleMode(h, mode | 0x0004); // ENABLE_VIRTUAL_TERMINAL_PROCESSING } #endif如果运行环境实在不支持 VT退而求其次用 system(cls) 清屏。它慢但老设备上至少能用。6. 进阶玩法最高分落盘、自动对局与 30 行贪心 AI6.1 最高分落盘与自动对局验证基础版本玩通之后建议先加两个小功能最高分保存和自动对局模式。最高分只需要读写一个文本文件在每次分数变化时更新int loadBest() { int best 0; std::ifstream in(2048.best); if (in) in best; return best; } void saveBest(int best) { std::ofstream out(2048.best); out best \n; }自动对局是更有价值的验证工具。给主循环加一个 -auto 启动参数让它自动选随机方向走完一整局统计最大块分布。它能一次性验证合并算法不会死循环、结束判断不会提前触发、随机生成不会把棋盘写坏。我一般把随机自动对局跑 200 局作为后续 AI 优化的对照组。随机策略通常很难超过 128这个基线数据在评估 AI 效果时非常有用。6.2 30 行贪心评估 AI空位数 平滑度 角落奖励最后这个贪心 AI 是很好的收尾练习。思路是把当前棋盘复制成四份分别模拟向左、向右、向上、向下移动后的局面用评估函数给每个新局面打分挑分数最高的方向真正执行。评估函数不用复杂三件事就够了空格子越多越好相邻格子数值差越小越平滑最大数字靠在角落会有优势。double evaluate(const int b[4][4]) { double score 0; int empty 0; int maxVal 0, maxR 0, maxC 0; for (int i 0; i 4; i) { for (int j 0; j 4; j) { int v b[i][j]; if (v 0) empty; if (v maxVal) { maxVal v; maxR i; maxC j; } if (i 1 4) score - std::abs(b[i][j] - b[i 1][j]); if (j 1 4) score - std::abs(b[i][j] - b[i][j 1]); } } double corner (maxR 1 maxC 1) ? 2.0 : 0.0; return empty * 8.0 score * 0.2 corner; } Direction suggestMove(const int b[4][4]) { Direction best UP; double bestScore -1e9; for (int d 0; d 4; d) { int copy[4][4]; memcpy(copy, b, sizeof(copy)); int gained 0; applyMove(copy, static_castDirection(d), gained); if (memcmp(copy, b, sizeof(copy)) 0) continue; // 无效方向直接跳过 double s evaluate(copy) gained; // 短期收益也算进去 if (s bestScore) { bestScore s; best static_castDirection(d); } } return best; }权重怎么调是这部分的乐趣所在。empty 权重给到 8 是因为 2048 里生存优先空格太少再平滑也撑不了多久。gained 让 AI 不会为了布局放弃眼前可以拿的分。这个单步贪心 AI 跑自动对局通常能稳定摸到 512 到 1024运气好偶尔上 2048。想更强就得做两到三层期望搜索或者上 expectimax那对性能的要求就上一个台阶了。我自己的习惯是保留一个编译开关随时能把游戏切成“AI 自动操作”观察几百局每次改完合并或评估逻辑都先跑一遍回归。调参时也把权重写进配置文件只动数值不动代码对比结果比单局手玩可靠得多。这个方向从几行核心函数展开到能自动验证的完整程序正好验证了控制台 C 小游戏值得做的原因——麻雀虽小五脏俱全。希望帮到你。本文还有配套的精品资源点击获取
返回列表